010.002.story.md 13 KB

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.jsonexports 字段,支持子路径导出
  • [ ] 任务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):

// 广告管理端点
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客户端实现规范]

// 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();

package.json配置参考

[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 学到的关键点:

  1. 统一广告模块已创建完成,包含完整的管理员路由和用户展示路由
  2. 管理员路由使用 tenantAuthMiddleware,只有超级管理员(ID=1)可访问
  3. Entity 包含 @ManyToOne 关联 FileMtUnifiedAdvertisementType
  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选择器,避免文本冲突

测试执行命令

# 进入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代理填写