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