Ready for Review
As a 超级管理员, I want 一个统一广告管理的UI包, so that 可以在租户管理后台统一管理所有广告和广告类型。
packages/unified-advertisement-management-ui 包,包含完整的组件、API客户端、类型定义@d8d/unified-advertisements-module 的管理员路由/api/v1/admin/unified-advertisements 管理员端点[x] 任务1: 创建包结构和配置文件 (AC: 1)
packages/unified-advertisement-management-ui 目录package.json,配置包名为 @d8d/unified-advertisement-management-uitsconfig.jsonvitest.config.ts(设置 fileParallelism: false)src/ 子目录:components/, api/, types/tests/ 子目录:integration/, components/[x] 任务2: 实现 RPC 客户端管理器 (AC: 4)
src/api/unifiedAdvertisementClient.ts,使用单例模式@d8d/unified-advertisements-module 导入管理员路由UnifiedAdvertisementClientManager 类(init(), get(), reset() 方法)src/api/index.ts 导出客户端unifiedAdvertisementTypeClient.ts[x] 任务3: 定义类型 (AC: 5)
src/types/index.tsUnifiedAdvertisementResponse, CreateUnifiedAdvertisementRequest, UpdateUnifiedAdvertisementRequest[x] 任务4: 实现广告管理组件 (AC: 2, 6)
src/components/UnifiedAdvertisementManagement.tsxdata-testid 属性@d8d/shared-ui-components 中的 shadcn/ui 组件src/components/UnifiedAdvertisementTypeSelector.tsx(广告类型选择器)src/components/index.ts 导出组件[x] 任务5: 实现广告类型管理组件 (AC: 3, 6)
src/components/UnifiedAdvertisementTypeManagement.tsxdata-testid 属性[x] 任务6: 创建包导出入口 (AC: 1)
src/index.ts,导出组件、API客户端、类型package.json 的 exports 字段,支持子路径导出[x] 任务7: 编写组件集成测试 (AC: 7)
tests/setup.ts,添加必要的 mock(sonner, scrollIntoView)tests/integration/unified-advertisement-management.integration.test.tsx[x] 任务8: 代码质量检查 (AC: 1, 7)
client.index.$get 而非 client.$get)pnpm typecheck 确保无 TypeScript 错误新包位置:
packages/unified-advertisement-management-ui/
├── package.json
├── tsconfig.json
├── vitest.config.ts
├── src/
│ ├── api/
│ │ ├── unifiedAdvertisementClient.ts # 广告RPC客户端管理器
│ │ ├── unifiedAdvertisementTypeClient.ts # 广告类型RPC客户端管理器
│ │ └── index.ts
│ ├── components/
│ │ ├── UnifiedAdvertisementManagement.tsx # 广告管理组件
│ │ ├── UnifiedAdvertisementTypeManagement.tsx # 广告类型管理组件
│ │ └── index.ts
│ ├── types/
│ │ └── index.ts # 类型定义(RPC推断)
│ └── index.ts # 包入口
└── tests/
├── setup.ts # 测试设置
├── integration/ # 集成测试
└── components/ # 组件测试
参考模块:
packages/advertisement-management-ui-mtpackages/advertisement-type-management-ui-mt@d8d/unified-advertisements-module管理员接口 (使用 tenantAuthMiddleware):
// 广告管理端点
GET /api/v1/admin/unified-advertisements # 广告列表
POST /api/v1/admin/unified-advertisements # 创建广告
PUT /api/v1/admin/unified-advertisements/:id # 更新广告
DELETE /api/v1/admin/unified-advertisements/:id # 删除广告
// 广告类型管理端点
GET /api/v1/admin/unified-advertisement-types # 广告类型列表
POST /api/v1/admin/unified-advertisement-types # 创建广告类型
PUT /api/v1/admin/unified-advertisement-types/:id # 更新广告类型
DELETE /api/v1/admin/unified-advertisement-types/:id # 删除广告类型
[Source: docs/architecture/ui-package-standards.md#RPC客户端实现规范]
// src/api/unifiedAdvertisementClient.ts
import { adminUnifiedAdRoutes } from '@d8d/unified-advertisements-module';
import { rpcClient } from '@d8d/shared-ui-components/utils/hc';
export class UnifiedAdvertisementClientManager {
private static instance: UnifiedAdvertisementClientManager;
private client: ReturnType<typeof rpcClient<typeof adminUnifiedAdRoutes>> | null = null;
private constructor() {}
public static getInstance(): UnifiedAdvertisementClientManager {
if (!UnifiedAdvertisementClientManager.instance) {
UnifiedAdvertisementClientManager.instance = new UnifiedAdvertisementClientManager();
}
return UnifiedAdvertisementClientManager.instance;
}
public init(baseUrl: string = '/'): ReturnType<typeof rpcClient<typeof adminUnifiedAdRoutes>> {
return this.client = rpcClient<typeof adminUnifiedAdRoutes>(baseUrl);
}
public get(): ReturnType<typeof rpcClient<typeof adminUnifiedAdRoutes>> {
if (!this.client) {
return this.init();
}
return this.client;
}
public reset(): void {
this.client = null;
}
}
const unifiedAdvertisementClientManager = UnifiedAdvertisementClientManager.getInstance();
export const unifiedAdvertisementClient = unifiedAdvertisementClientManager.get();
export {
UnifiedAdvertisementClientManager,
unifiedAdvertisementClientManager,
unifiedAdvertisementClient
};
[Source: docs/architecture/ui-package-standards.md#类型推断最佳实践]
// src/types/index.ts
import type { InferResponseType, InferRequestType } from 'hono';
import type { adminUnifiedAdRoutes } from '@d8d/unified-advertisements-module';
import { unifiedAdvertisementClient } from '../api/unifiedAdvertisementClient';
// ✅ 正确:使用RPC推断类型(推荐)
export type UnifiedAdvertisementResponse = InferResponseType<typeof unifiedAdvertisementClient.index.$get, 200>;
export type UnifiedAdvertisementListItem = UnifiedAdvertisementResponse['data'][0];
export type CreateUnifiedAdvertisementRequest = InferRequestType<typeof unifiedAdvertisementClient.index.$post>;
export type UpdateUnifiedAdvertisementRequest = InferRequestType<typeof unifiedAdvertisementClient[':id']['$put']>;
// 搜索参数类型
export interface UnifiedAdvertisementSearchParams {
page: number;
pageSize: number;
keyword?: string;
}
// ❌ 错误:直接导入schema类型(可能导致Date/string不匹配)
// import type { UnifiedAdvertisement } from '@d8d/unified-advertisements-module/schemas';
[Source: docs/architecture/ui-package-standards.md#表单组件模式规范]
关键设计: 使用条件渲染两个独立的 Form 组件
// ✅ 正确:条件渲染两个独立的Form组件
{isCreateForm ? (
<Form {...createForm}>
{/* 创建表单内容 */}
</Form>
) : (
<Form {...updateForm}>
{/* 编辑表单内容 */}
</Form>
)}
// ❌ 错误:在单个Form组件上动态切换props(可能导致类型不兼容)
<Form {...(isCreateForm ? createForm : updateForm)}>
{/* 表单内容 */}
</Form>
测试选择器优化: 为关键交互元素添加 data-testid 属性
[Source: docs/architecture/ui-package-standards.md#测试选择器优化规范]
// 在组件中添加test ID
<DialogTitle data-testid="create-unified-advertisement-modal-title">创建广告</DialogTitle>
<Button data-testid="create-unified-advertisement-button">新建广告</Button>
// 在测试中使用test ID
const modalTitle = screen.getByTestId('create-unified-advertisement-modal-title');
const createButton = screen.getByTestId('create-unified-advertisement-button');
Radix UI组件测试环境修复: 在测试setup文件中添加必要的DOM API mock
[Source: docs/architecture/ui-package-standards.md#Radix UI组件测试环境修复]
// tests/setup.ts
import '@testing-library/jest-dom';
import { vi } from 'vitest';
// Mock sonner
vi.mock('sonner', () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
warning: vi.fn(),
info: vi.fn()
}
}));
// Mock scrollIntoView for Radix UI components
Element.prototype.scrollIntoView = vi.fn();
[Source: docs/architecture/ui-package-standards.md#package.json配置]
{
"name": "@d8d/unified-advertisement-management-ui",
"version": "1.0.0",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"import": "./src/index.ts"
},
"./components": {
"types": "./src/components/index.ts",
"import": "./src/components/index.ts"
},
"./api": {
"types": "./src/api/index.ts",
"import": "./src/api/index.ts"
}
},
"scripts": {
"build": "unbuild",
"dev": "tsc --watch",
"test": "vitest run",
"test:coverage": "vitest run --coverage",
"lint": "eslint src --ext .ts,.tsx",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@d8d/shared-types": "workspace:*",
"@d8d/shared-ui-components": "workspace:*",
"@d8d/unified-advertisements-module": "workspace:*",
"@hookform/resolvers": "^5.2.1",
"@tanstack/react-query": "^5.90.9",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"hono": "^4.8.5",
"lucide-react": "^0.536.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-hook-form": "^7.61.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"zod": "^4.0.15"
}
}
从故事 010.001 学到的关键点:
tenantAuthMiddleware,只有超级管理员(ID=1)可访问@ManyToOne 关联 FileMt 和 UnifiedAdvertisementTypeadvertisements-module-mt 完全一致code 字段唯一索引本故事需要注意的点:
/api/v1/admin/unified-advertisements),不是用户路由tests/integration/tests/components/tests/setup.ts[Source: docs/architecture/testing-strategy.md]
| 测试类型 | 最低要求 | 目标要求 |
|---|---|---|
| 组件测试 | 70% | 80% |
| 集成测试 | 50% | 60% |
# 进入UI包目录
cd packages/unified-advertisement-management-ui
# 运行所有测试
pnpm test
# 生成覆盖率报告
pnpm test:coverage
# 类型检查
pnpm typecheck
| Date | Version | Description | Author |
|---|---|---|---|
| 2026-01-03 | 1.0 | 初始故事创建 | Bob (Scrum Master) |
| 2026-01-03 | 1.1 | 完成所有开发任务 | James (Claude Code) |
| 2026-01-03 | 1.2 | 修复测试配置和规范文档补充 | James (Claude Code) |
| 2026-01-03 | 1.3 | 修复Select组件测试(添加typeId选择步骤和pointer events mock) | James (Claude Code) |
Claude Opus 4.5 (model ID: claude-opus-4-5-20251101)
/api/v1/admin/... 改为相对路径 /)index 属性作为根路径的别名,调用方式为 client.index.$get:id 参数期望 string 类型,需要将 number 转换为 stringjsx: "react-jsx" 和 lib: ["ES2022", "DOM", "DOM.Iterable"] 配置React 导入,且 wrapper 函数定义方式导致 esbuild 解析错误vi.fn().mockImplementation() 返回对象而非构造函数,导致 Radix UI 的 @radix-ui/react-use-size 报错 TypeError: ... is not a constructortypeId 字段,导致表单验证失败,API 调用从未触发userEvent.click() 测试 Radix UI Select 组件时,必须 mock hasPointerCapture、releasePointerCapture、setPointerCapture,否则报 TypeError: target.hasPointerCapture is not a functionpackages/unified-advertisement-management-ui 包UnifiedAdvertisementClientManager 和 UnifiedAdvertisementTypeClientManager 单例模式InferResponseType 和 InferRequestType from hono/client)UnifiedAdvertisementManagement:广告管理组件(列表、创建、编辑、删除)UnifiedAdvertisementTypeManagement:广告类型管理组件UnifiedAdvertisementTypeSelector:广告类型选择器组件isCreateForm ? <Form {...createForm}> : <Form {...updateForm}>)renderWithProviders 模式client.index.$get 调用方式renderWithProviders 函数模式,修复 ResizeObserver mock 为 class 模式docs/architecture/ui-package-standards.md 中补充 ResizeObserver mock 规范type-selector-trigger → type-selector-item-1)hasPointerCapture、releasePointerCapture、setPointerCapture mockdocs/architecture/ui-package-standards.md 中补充 pointer events mock 规范,强调使用 userEvent 而非 fireEvent新增文件:
packages/unified-advertisement-management-ui/package.jsonpackages/unified-advertisement-management-ui/tsconfig.jsonpackages/unified-advertisement-management-ui/vitest.config.tspackages/unified-advertisement-management-ui/src/api/unifiedAdvertisementClient.tspackages/unified-advertisement-management-ui/src/api/unifiedAdvertisementTypeClient.tspackages/unified-advertisement-management-ui/src/api/index.tspackages/unified-advertisement-management-ui/src/types/index.tspackages/unified-advertisement-management-ui/src/components/UnifiedAdvertisementManagement.tsxpackages/unified-advertisement-management-ui/src/components/UnifiedAdvertisementTypeManagement.tsxpackages/unified-advertisement-management-ui/src/components/UnifiedAdvertisementTypeSelector.tsxpackages/unified-advertisement-management-ui/src/components/index.tspackages/unified-advertisement-management-ui/src/index.tspackages/unified-advertisement-management-ui/tests/setup.tspackages/unified-advertisement-management-ui/tests/integration/unified-advertisement-management.integration.test.tsxpackages/unified-advertisement-management-ui/tests/integration/unified-advertisement-type-management.integration.test.tsx修改文件:
docs/architecture/ui-package-standards.md - 补充 ResizeObserver mock 规范,添加 pointer events mock 规范docs/stories/010.002.story.md - 更新 Dev Agent Recordpackages/unified-advertisement-management-ui/tests/setup.ts - 添加 pointer events mock(hasPointerCapture、releasePointerCapture、setPointerCapture)packages/unified-advertisement-management-ui/tests/integration/unified-advertisement-management.integration.test.tsx - 在创建广告测试中添加选择广告类型的步骤待QA代理填写