|
|
@@ -0,0 +1,405 @@
|
|
|
+# Story 010.002: 创建统一广告管理UI包
|
|
|
+
|
|
|
+## Status
|
|
|
+Approved
|
|
|
+
|
|
|
+## 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
|
|
|
+
|
|
|
+- [ ] **任务1: 创建包结构和配置文件** (AC: 1)
|
|
|
+ - [ ] 创建 `packages/unified-advertisement-management-ui` 目录
|
|
|
+ - [ ] 创建 `package.json`,配置包名为 `@d8d/unified-advertisement-management-ui`
|
|
|
+ - [ ] 创建 `tsconfig.json`
|
|
|
+ - [ ] 创建 `vitest.config.ts`(设置 `fileParallelism: false`)
|
|
|
+ - [ ] 创建 `src/` 子目录:`components/`, `api/`, `types/`
|
|
|
+ - [ ] 创建 `tests/` 子目录:`integration/`, `components/`
|
|
|
+
|
|
|
+- [ ] **任务2: 实现 RPC 客户端管理器** (AC: 4)
|
|
|
+ - [ ] 创建 `src/api/unifiedAdvertisementClient.ts`,使用单例模式
|
|
|
+ - [ ] 从 `@d8d/unified-advertisements-module` 导入管理员路由
|
|
|
+ - [ ] 实现 `UnifiedAdvertisementClientManager` 类(`init()`, `get()`, `reset()` 方法)
|
|
|
+ - [ ] 创建 `src/api/index.ts` 导出客户端
|
|
|
+ - [ ] 为广告类型创建独立客户端 `unifiedAdvertisementTypeClient.ts`
|
|
|
+
|
|
|
+- [ ] **任务3: 定义类型** (AC: 5)
|
|
|
+ - [ ] 创建 `src/types/index.ts`
|
|
|
+ - [ ] 使用 RPC 推断类型:`UnifiedAdvertisementResponse`, `CreateUnifiedAdvertisementRequest`, `UpdateUnifiedAdvertisementRequest`
|
|
|
+ - [ ] 推断搜索参数和分页响应类型
|
|
|
+ - [ ] 为广告类型定义对应类型
|
|
|
+
|
|
|
+- [ ] **任务4: 实现广告管理组件** (AC: 2, 6)
|
|
|
+ - [ ] 创建 `src/components/UnifiedAdvertisementManagement.tsx`
|
|
|
+ - [ ] 使用 React Query 进行数据查询和变更
|
|
|
+ - [ ] 实现广告列表展示(表格)
|
|
|
+ - [ ] 实现创建和编辑表单(使用条件渲染两个独立的 Form 组件)
|
|
|
+ - [ ] 为关键元素添加 `data-testid` 属性
|
|
|
+ - [ ] 使用 `@d8d/shared-ui-components` 中的 shadcn/ui 组件
|
|
|
+ - [ ] 创建 `src/components/index.ts` 导出组件
|
|
|
+
|
|
|
+- [ ] **任务5: 实现广告类型管理组件** (AC: 3, 6)
|
|
|
+ - [ ] 创建 `src/components/UnifiedAdvertisementTypeManagement.tsx`
|
|
|
+ - [ ] 使用 React Query 进行数据查询和变更
|
|
|
+ - [ ] 实现广告类型列表展示
|
|
|
+ - [ ] 实现创建和编辑表单(使用条件渲染两个独立的 Form 组件)
|
|
|
+ - [ ] 为关键元素添加 `data-testid` 属性
|
|
|
+
|
|
|
+- [ ] **任务6: 创建包导出入口** (AC: 1)
|
|
|
+ - [ ] 创建 `src/index.ts`,导出组件、API客户端、类型
|
|
|
+ - [ ] 配置 `package.json` 的 `exports` 字段,支持子路径导出
|
|
|
+
|
|
|
+- [ ] **任务7: 编写组件集成测试** (AC: 7)
|
|
|
+ - [ ] 创建 `tests/setup.ts`,添加必要的 mock(sonner, scrollIntoView)
|
|
|
+ - [ ] 创建 `tests/integration/unified-advertisement-management.integration.test.tsx`
|
|
|
+ - [ ] 测试列表加载、创建、编辑、删除功能
|
|
|
+ - [ ] 测试表单验证和错误处理
|
|
|
+ - [ ] 创建广告类型组件的集成测试
|
|
|
+
|
|
|
+- [ ] **任务8: 代码质量检查** (AC: 1, 7)
|
|
|
+ - [ ] 运行 `pnpm typecheck` 确保无 TypeScript 错误
|
|
|
+ - [ ] 运行 `pnpm lint` 确保代码符合规范
|
|
|
+ - [ ] 运行 `pnpm test` 确保所有测试通过
|
|
|
+
|
|
|
+## 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<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#类型推断最佳实践]
|
|
|
+
|
|
|
+```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<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 组件
|
|
|
+
|
|
|
+```typescript
|
|
|
+// ✅ 正确:条件渲染两个独立的Form组件
|
|
|
+{isCreateForm ? (
|
|
|
+ <Form {...createForm}>
|
|
|
+ {/* 创建表单内容 */}
|
|
|
+ </Form>
|
|
|
+) : (
|
|
|
+ <Form {...updateForm}>
|
|
|
+ {/* 编辑表单内容 */}
|
|
|
+ </Form>
|
|
|
+)}
|
|
|
+
|
|
|
+// ❌ 错误:在单个Form组件上动态切换props(可能导致类型不兼容)
|
|
|
+<Form {...(isCreateForm ? createForm : updateForm)}>
|
|
|
+ {/* 表单内容 */}
|
|
|
+</Form>
|
|
|
+```
|
|
|
+
|
|
|
+### 测试规范
|
|
|
+
|
|
|
+**测试选择器优化**: 为关键交互元素添加 `data-testid` 属性
|
|
|
+
|
|
|
+[Source: docs/architecture/ui-package-standards.md#测试选择器优化规范]
|
|
|
+
|
|
|
+```typescript
|
|
|
+// 在组件中添加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组件测试环境修复]
|
|
|
+
|
|
|
+```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) |
|
|
|
+
|
|
|
+## Dev Agent Record
|
|
|
+
|
|
|
+### Agent Model Used
|
|
|
+_待开发代理填写_
|
|
|
+
|
|
|
+### Debug Log References
|
|
|
+_待开发代理填写_
|
|
|
+
|
|
|
+### Completion Notes List
|
|
|
+_待开发代理填写_
|
|
|
+
|
|
|
+### File List
|
|
|
+_待开发代理填写_
|
|
|
+
|
|
|
+## QA Results
|
|
|
+_待QA代理填写_
|