# Story 010.002: 创建统一广告管理UI包 ## Status Ready for Review ## Story **As a** 超级管理员, **I want** 一个统一广告管理的UI包, **so that** 可以在租户管理后台统一管理所有广告和广告类型。 ## Acceptance Criteria 1. 创建 `packages/unified-advertisement-management-ui` 包,包含完整的组件、API客户端、类型定义 2. 实现广告管理组件(列表、创建、编辑、删除),API端点指向 `@d8d/unified-advertisements-module` 的管理员路由 3. 实现广告类型管理组件(列表、创建、编辑、删除) 4. 使用 RPC 客户端管理器模式,指向 `/api/v1/admin/unified-advertisements` 管理员端点 5. 类型定义使用 RPC 推断类型,避免直接导入 schema 类型 6. 表单组件使用条件渲染两个独立的 Form 组件(创建和编辑),避免动态切换 props 7. 包含组件测试和类型检查 ## Tasks / Subtasks - [x] **任务1: 创建包结构和配置文件** (AC: 1) - [x] 创建 `packages/unified-advertisement-management-ui` 目录 - [x] 创建 `package.json`,配置包名为 `@d8d/unified-advertisement-management-ui` - [x] 创建 `tsconfig.json` - [x] 创建 `vitest.config.ts`(设置 `fileParallelism: false`) - [x] 创建 `src/` 子目录:`components/`, `api/`, `types/` - [x] 创建 `tests/` 子目录:`integration/`, `components/` - [x] **任务2: 实现 RPC 客户端管理器** (AC: 4) - [x] 创建 `src/api/unifiedAdvertisementClient.ts`,使用单例模式 - [x] 从 `@d8d/unified-advertisements-module` 导入管理员路由 - [x] 实现 `UnifiedAdvertisementClientManager` 类(`init()`, `get()`, `reset()` 方法) - [x] 创建 `src/api/index.ts` 导出客户端 - [x] 为广告类型创建独立客户端 `unifiedAdvertisementTypeClient.ts` - [x] **任务3: 定义类型** (AC: 5) - [x] 创建 `src/types/index.ts` - [x] 使用 RPC 推断类型:`UnifiedAdvertisementResponse`, `CreateUnifiedAdvertisementRequest`, `UpdateUnifiedAdvertisementRequest` - [x] 推断搜索参数和分页响应类型 - [x] 为广告类型定义对应类型 - [x] **任务4: 实现广告管理组件** (AC: 2, 6) - [x] 创建 `src/components/UnifiedAdvertisementManagement.tsx` - [x] 使用 React Query 进行数据查询和变更 - [x] 实现广告列表展示(表格) - [x] 实现创建和编辑表单(使用条件渲染两个独立的 Form 组件) - [x] 为关键元素添加 `data-testid` 属性 - [x] 使用 `@d8d/shared-ui-components` 中的 shadcn/ui 组件 - [x] 创建 `src/components/UnifiedAdvertisementTypeSelector.tsx`(广告类型选择器) - [x] 创建 `src/components/index.ts` 导出组件 - [x] **任务5: 实现广告类型管理组件** (AC: 3, 6) - [x] 创建 `src/components/UnifiedAdvertisementTypeManagement.tsx` - [x] 使用 React Query 进行数据查询和变更 - [x] 实现广告类型列表展示 - [x] 实现创建和编辑表单(使用条件渲染两个独立的 Form 组件) - [x] 为关键元素添加 `data-testid` 属性 - [x] **任务6: 创建包导出入口** (AC: 1) - [x] 创建 `src/index.ts`,导出组件、API客户端、类型 - [x] 配置 `package.json` 的 `exports` 字段,支持子路径导出 - [x] **任务7: 编写组件集成测试** (AC: 7) - [x] 创建 `tests/setup.ts`,添加必要的 mock(sonner, scrollIntoView) - [x] 创建 `tests/integration/unified-advertisement-management.integration.test.tsx` - [x] 测试列表加载、创建、编辑、删除功能 - [x] 测试表单验证和错误处理 - [x] 创建广告类型组件的集成测试 - [x] **任务8: 代码质量检查** (AC: 1, 7) - [x] 修复 RPC 客户端路径问题(使用 `client.index.$get` 而非 `client.$get`) - [x] 修复 param 类型问题(number 转 string) - [x] 运行 `pnpm typecheck` 确保无 TypeScript 错误 ## Dev Notes ### 项目结构信息 **新包位置**: ``` 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/ # 组件测试 ``` **参考模块**: - 原多租户广告管理UI: `packages/advertisement-management-ui-mt` - 原多租户广告类型管理UI: `packages/advertisement-type-management-ui-mt` - 统一广告后端模块: `@d8d/unified-advertisements-module` ### API端点设计 **管理员接口** (使用 `tenantAuthMiddleware`): ```typescript // 广告管理端点 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 # 删除广告类型 ``` ### RPC客户端实现规范 [Source: docs/architecture/ui-package-standards.md#RPC客户端实现规范] ```typescript // 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> | null = null; private constructor() {} public static getInstance(): UnifiedAdvertisementClientManager { if (!UnifiedAdvertisementClientManager.instance) { UnifiedAdvertisementClientManager.instance = new UnifiedAdvertisementClientManager(); } return UnifiedAdvertisementClientManager.instance; } public init(baseUrl: string = '/'): ReturnType> { return this.client = rpcClient(baseUrl); } public get(): ReturnType> { 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#类型推断最佳实践] ```typescript // 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; export type UnifiedAdvertisementListItem = UnifiedAdvertisementResponse['data'][0]; export type CreateUnifiedAdvertisementRequest = InferRequestType; export type UpdateUnifiedAdvertisementRequest = InferRequestType; // 搜索参数类型 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 组件 ```typescript // ✅ 正确:条件渲染两个独立的Form组件 {isCreateForm ? (
{/* 创建表单内容 */}
) : (
{/* 编辑表单内容 */}
)} // ❌ 错误:在单个Form组件上动态切换props(可能导致类型不兼容)
{/* 表单内容 */}
``` ### 测试规范 **测试选择器优化**: 为关键交互元素添加 `data-testid` 属性 [Source: docs/architecture/ui-package-standards.md#测试选择器优化规范] ```typescript // 在组件中添加test ID 创建广告 // 在测试中使用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组件测试环境修复] ```typescript // 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(); ``` ### package.json配置参考 [Source: docs/architecture/ui-package-standards.md#package.json配置] ```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 学到的关键点**: 1. 统一广告模块已创建完成,包含完整的管理员路由和用户展示路由 2. 管理员路由使用 `tenantAuthMiddleware`,只有超级管理员(ID=1)可访问 3. Entity 包含 `@ManyToOne` 关联 `FileMt` 和 `UnifiedAdvertisementType` 4. API 响应结构与原 `advertisements-module-mt` 完全一致 5. 广告类型包含 `code` 字段唯一索引 **本故事需要注意的点**: 1. API客户端必须指向管理员路由(`/api/v1/admin/unified-advertisements`),不是用户路由 2. 类型必须使用 RPC 推断,避免直接导入 schema 3. 表单使用条件渲染两个独立的 Form 组件 4. 测试需要 mock sonner 和 scrollIntoView(Radix UI 组件需要) ### 技术栈 - **前端框架**: React 19.1.0 + TypeScript - **状态管理**: @tanstack/react-query - **表单**: react-hook-form + zod - **UI组件**: shadcn/ui (Radix UI) - **测试**: Vitest + Testing Library ## Testing ### 测试文件位置 - 集成测试: `tests/integration/` - 组件测试: `tests/components/` - 测试工具: `tests/setup.ts` ### 测试框架 - **Vitest**: 主要测试运行器 - **Testing Library**: React组件测试 - **sonner mock**: Toast通知mock ### 测试标准 [Source: docs/architecture/testing-strategy.md] | 测试类型 | 最低要求 | 目标要求 | |----------|----------|----------| | 组件测试 | 70% | 80% | | 集成测试 | 50% | 60% | ### 关键测试要求 1. **API调用测试**: 验证RPC客户端正确调用管理员端点 2. **类型安全测试**: 验证类型推断正确 3. **表单验证测试**: 验证创建和编辑表单独立工作 4. **错误处理测试**: 验证API错误正确显示给用户 5. **test ID测试**: 使用data-testid选择器,避免文本冲突 ### 测试执行命令 ```bash # 进入UI包目录 cd packages/unified-advertisement-management-ui # 运行所有测试 pnpm test # 生成覆盖率报告 pnpm test:coverage # 类型检查 pnpm typecheck ``` ## Change Log | 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) | ## Dev Agent Record ### Agent Model Used Claude Opus 4.5 (model ID: claude-opus-4-5-20251101) ### Debug Log References - RPC 客户端路径问题:发现后端模块在故事 010.003 中已修复路径规范(从 `/api/v1/admin/...` 改为相对路径 `/`) - Hono RPC 客户端解析:发现路由使用 `index` 属性作为根路径的别名,调用方式为 `client.index.$get` - param 类型问题:Hono RPC 客户端的 `:id` 参数期望 string 类型,需要将 number 转换为 string - **tsconfig.json 配置缺失**:缺少 `jsx: "react-jsx"` 和 `lib: ["ES2022", "DOM", "DOM.Iterable"]` 配置 - **测试语法错误**:测试文件缺少 `React` 导入,且 wrapper 函数定义方式导致 esbuild 解析错误 - **ResizeObserver mock 模式错误**:使用 `vi.fn().mockImplementation()` 返回对象而非构造函数,导致 Radix UI 的 `@radix-ui/react-use-size` 报错 `TypeError: ... is not a constructor` - **Select组件测试失败根因**:创建广告测试中未选择必填的 `typeId` 字段,导致表单验证失败,API 调用从未触发 - **Pointer events mock 缺失**:使用 `userEvent.click()` 测试 Radix UI Select 组件时,必须 mock `hasPointerCapture`、`releasePointerCapture`、`setPointerCapture`,否则报 `TypeError: target.hasPointerCapture is not a function` ### Completion Notes List 1. **包结构**:完整创建 `packages/unified-advertisement-management-ui` 包 2. **RPC 客户端**:实现了 `UnifiedAdvertisementClientManager` 和 `UnifiedAdvertisementTypeClientManager` 单例模式 3. **类型定义**:使用 RPC 推断类型(`InferResponseType` 和 `InferRequestType` from `hono/client`) 4. **组件实现**: - `UnifiedAdvertisementManagement`:广告管理组件(列表、创建、编辑、删除) - `UnifiedAdvertisementTypeManagement`:广告类型管理组件 - `UnifiedAdvertisementTypeSelector`:广告类型选择器组件 5. **表单设计**:使用条件渲染两个独立的 Form 组件(`isCreateForm ?
: `) 6. **测试**:创建了集成测试文件,使用 `renderWithProviders` 模式 7. **路径修复**:根据故事 010.003 的修复,使用 `client.index.$get` 调用方式 8. **配置修复**:修复 tsconfig.json 添加 JSX 和 DOM 库配置 9. **测试修复**:添加 React 导入,改用 `renderWithProviders` 函数模式,修复 ResizeObserver mock 为 class 模式 10. **规范更新**:在 `docs/architecture/ui-package-standards.md` 中补充 ResizeObserver mock 规范 11. **Select测试修复**:在创建广告测试中添加选择广告类型的步骤(`type-selector-trigger` → `type-selector-item-1`) 12. **Pointer events mock**:在测试 setup 中添加 `hasPointerCapture`、`releasePointerCapture`、`setPointerCapture` mock 13. **规范更新**:在 `docs/architecture/ui-package-standards.md` 中补充 pointer events mock 规范,强调使用 `userEvent` 而非 `fireEvent` ### File List **新增文件**: - `packages/unified-advertisement-management-ui/package.json` - `packages/unified-advertisement-management-ui/tsconfig.json` - `packages/unified-advertisement-management-ui/vitest.config.ts` - `packages/unified-advertisement-management-ui/src/api/unifiedAdvertisementClient.ts` - `packages/unified-advertisement-management-ui/src/api/unifiedAdvertisementTypeClient.ts` - `packages/unified-advertisement-management-ui/src/api/index.ts` - `packages/unified-advertisement-management-ui/src/types/index.ts` - `packages/unified-advertisement-management-ui/src/components/UnifiedAdvertisementManagement.tsx` - `packages/unified-advertisement-management-ui/src/components/UnifiedAdvertisementTypeManagement.tsx` - `packages/unified-advertisement-management-ui/src/components/UnifiedAdvertisementTypeSelector.tsx` - `packages/unified-advertisement-management-ui/src/components/index.ts` - `packages/unified-advertisement-management-ui/src/index.ts` - `packages/unified-advertisement-management-ui/tests/setup.ts` - `packages/unified-advertisement-management-ui/tests/integration/unified-advertisement-management.integration.test.tsx` - `packages/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 Record - `packages/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 Results _待QA代理填写_