# Customer Detail Simulation Gallery Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 在顾客详情页“过往照片和AI发型模拟”区块展示该顾客所有成功生成的 AI 模拟结果卡片,并支持点击卡片进入对应任务详情页。 **Architecture:** 后端在 `simulations` 模块新增一个按顾客查询 gallery 的只读接口,负责把 `ai_hair_simulation` + `ai_hair_simulation_task.result_images` 扁平化成图片卡片流。前端在现有顾客详情页并行拉取顾客详情和 gallery 数据,用真实卡片网格替换当前占位块,保持现有页面骨架不变。 **Tech Stack:** NestJS 11, Prisma, Jest, React 19, React Router 7, Vitest, TypeScript ## Global Constraints - 顾客详情页开发必须遵循原有的 UI 骨架(顶部操作栏、Hero 区、资料布局等),不得随意更改已有的 UI 设计。 - 前端顾客详情页中的“过往照片和AI发型模拟”区块继续使用当前区块位置和标题,不改整体页面骨架。 - 一个生成图片就是一个卡片,按模拟任务创建时间倒序输出,最新在前。 - 点击卡片统一跳转到 `/:locale/simulation/result/:simulationId`,不新增子任务详情页。 - 只展示成功生成且有真实图片的结果卡片;失败任务、处理中任务、空图片任务不进入图片区。 - 本次不做分页、筛选、顾客详情页轮询,也不修改任务详情页结构。 --- ### Task 1: 后端顾客 Gallery DTO 与 Service 查询 **Files:** - Create: `/Users/yuchen/Documents/hairstylist/backend/src/modules/simulations/dto/customer-simulation-gallery.dto.ts` - Modify: `/Users/yuchen/Documents/hairstylist/backend/src/modules/simulations/simulations.service.ts` - Test: `/Users/yuchen/Documents/hairstylist/backend/src/modules/simulations/simulations.service.spec.ts` **Interfaces:** - Consumes: `PrismaService.customer.findFirst()`, `PrismaService.aiHairSimulation.findMany()`, `PrismaService.aiHairSimulationTask.findMany()`, `PrismaService.hairTemplate.findMany()` - Produces: `SimulationsService.listCustomerSimulationGallery(hairstylistId: bigint, customerId: string): Promise<{ items: CustomerSimulationGalleryItemDto[] }>` - [ ] **Step 1: 写失败测试,定义 gallery 只返回成功图片并按最新在前展开** ```ts it('lists flattened customer gallery cards in descending simulation time order', async () => { prisma.customer.findFirst.mockResolvedValue({ id: BigInt(5) }); prisma.aiHairSimulation.findMany.mockResolvedValue([ { id: BigInt(91), customerId: BigInt(5), hairstylistId: BigInt(7), createTime: new Date('2026-06-28T09:00:00Z'), isDeleted: 0, }, { id: BigInt(88), customerId: BigInt(5), hairstylistId: BigInt(7), createTime: new Date('2026-06-27T09:00:00Z'), isDeleted: 0, }, ]); prisma.aiHairSimulationTask.findMany.mockResolvedValue([ { id: BigInt(1), simulationId: BigInt(91), templateId: '11', taskId: 'task-11', taskStatus: 'SUCCESSES', resultImages: JSON.stringify([ 'https://cdn.example.com/result-2.png', 'https://cdn.example.com/result-1.png', ]), isDeleted: 0, }, { id: BigInt(2), simulationId: BigInt(88), templateId: '12', taskId: 'task-12', taskStatus: 'FAILED', resultImages: null, isDeleted: 0, }, ]); prisma.hairTemplate.findMany.mockResolvedValue([ { id: BigInt(11), templateName: 'Layer Bob' }, ]); const result = await service.listCustomerSimulationGallery(BigInt(7), '5'); expect(result.items).toEqual([ { simulationId: '91', taskId: 'task-11', imageUrl: 'https://cdn.example.com/result-2.png', templateName: 'Layer Bob', createdAt: '2026-06-28T09:00:00.000Z', taskStatus: 'SUCCESSES', }, { simulationId: '91', taskId: 'task-11', imageUrl: 'https://cdn.example.com/result-1.png', templateName: 'Layer Bob', createdAt: '2026-06-28T09:00:00.000Z', taskStatus: 'SUCCESSES', }, ]); }); ``` - [ ] **Step 2: 跑单测确认失败** Run: `npm test -- src/modules/simulations/simulations.service.spec.ts --runInBand` Expected: FAIL,提示 `listCustomerSimulationGallery` 或 DTO 尚不存在 - [ ] **Step 3: 新增 DTO 并实现最小查询逻辑** ```ts export type CustomerSimulationGalleryItemDto = { simulationId: string; taskId: string; imageUrl: string; templateName: string; createdAt: string; taskStatus: 'SUCCESSES'; }; ``` ```ts async listCustomerSimulationGallery( hairstylistId: bigint, customerId: string, ): Promise<{ items: CustomerSimulationGalleryItemDto[] }> { const customer = await this.prisma.customer.findFirst({ where: { id: BigInt(customerId), hairstylistId, isDeleted: 0, }, select: { id: true }, }); if (!customer) { throw new NotFoundException('顾客不存在'); } const simulations = await this.prisma.aiHairSimulation.findMany({ where: { customerId: BigInt(customerId), hairstylistId, isDeleted: 0, }, orderBy: { createTime: 'desc' }, select: { id: true, createTime: true, }, }); if (!simulations.length) { return { items: [] }; } const tasks = await this.prisma.aiHairSimulationTask.findMany({ where: { simulationId: { in: simulations.map((item) => item.id) }, taskStatus: 'SUCCESSES', isDeleted: 0, }, select: { simulationId: true, templateId: true, taskId: true, taskStatus: true, resultImages: true, }, }); const templateIds = tasks .map((task) => { try { return BigInt(task.templateId); } catch { return null; } }) .filter((value): value is bigint => value !== null); const templates = templateIds.length ? await this.prisma.hairTemplate.findMany({ where: { id: { in: templateIds }, isDeleted: 0 }, select: { id: true, templateName: true }, }) : []; const templateMap = new Map(templates.map((item) => [item.id.toString(), item.templateName])); const simulationMap = new Map(simulations.map((item) => [item.id.toString(), item.createTime])); return { items: tasks.flatMap((task) => { const createdAt = simulationMap.get(task.simulationId.toString()); if (!createdAt) return []; return this.parseResultImages(task.resultImages).map((imageUrl) => ({ simulationId: task.simulationId.toString(), taskId: task.taskId, imageUrl, templateName: templateMap.get(task.templateId) ?? 'Unknown Template', createdAt: createdAt.toISOString(), taskStatus: 'SUCCESSES' as const, })); }), }; } ``` - [ ] **Step 4: 跑单测确认通过** Run: `npm test -- src/modules/simulations/simulations.service.spec.ts --runInBand` Expected: PASS - [ ] **Step 5: Commit** ```bash git add backend/src/modules/simulations/dto/customer-simulation-gallery.dto.ts \ backend/src/modules/simulations/simulations.service.ts \ backend/src/modules/simulations/simulations.service.spec.ts git commit -m "feat: add customer simulation gallery service" ``` ### Task 2: 后端 Controller 与 E2E 接口验证 **Files:** - Modify: `/Users/yuchen/Documents/hairstylist/backend/src/modules/simulations/simulations.controller.ts` - Modify: `/Users/yuchen/Documents/hairstylist/backend/test/simulations.e2e-spec.ts` **Interfaces:** - Consumes: `SimulationsService.listCustomerSimulationGallery()` - Produces: `GET /api/v1/simulations/customer/:customerId/gallery` - [ ] **Step 1: 写失败的 e2e 用例** ```ts it('/api/v1/simulations/customer/:customerId/gallery (GET) returns flattened result cards', async () => { const token = await signToken(); const response = await request(app.getHttpServer()) .get('/api/v1/simulations/customer/1/gallery') .set('Authorization', `Bearer ${token}`); expect(response.status).toBe(200); expect(response.body.data.items).toEqual( expect.arrayContaining([ expect.objectContaining({ simulationId: expect.any(String), imageUrl: expect.any(String), }), ]), ); }); ``` - [ ] **Step 2: 跑 e2e 确认失败** Run: `npm run test:e2e -- simulations.e2e-spec.ts` Expected: FAIL,提示路由不存在或返回结构不符 - [ ] **Step 3: 接出 controller 路由** ```ts @Get('customer/:customerId/gallery') gallery( @Req() request: AuthenticatedRequest, @Param('customerId') customerId: string, ) { return this.simulationsService.listCustomerSimulationGallery( BigInt(request.user.stylistId), customerId, ); } ``` - [ ] **Step 4: 跑 e2e 确认通过** Run: `npm run test:e2e -- simulations.e2e-spec.ts` Expected: PASS - [ ] **Step 5: Commit** ```bash git add backend/src/modules/simulations/simulations.controller.ts \ backend/test/simulations.e2e-spec.ts git commit -m "feat: expose customer simulation gallery endpoint" ``` ### Task 3: 前端 API 契约与请求封装 **Files:** - Modify: `/Users/yuchen/Documents/hairstylist/frontend/src/services/api/contracts.ts` - Modify: `/Users/yuchen/Documents/hairstylist/frontend/src/services/api/simulations.ts` - Test: `/Users/yuchen/Documents/hairstylist/frontend/src/services/api/__tests__/simulations.test.ts` **Interfaces:** - Consumes: `GET /simulations/customer/:customerId/gallery` - Produces: `listCustomerSimulationGallery(customerId: string): Promise<{ items: CustomerSimulationGalleryItem[] }>` - [ ] **Step 1: 写失败测试,约束 gallery 请求路径** ```ts it('builds customer simulation gallery requests with customerId', async () => { vi.mocked(http.get).mockResolvedValue({ items: [] }); await listCustomerSimulationGallery('5'); expect(http.get).toHaveBeenCalledWith('/simulations/customer/5/gallery'); }); ``` - [ ] **Step 2: 跑前端 API 测试确认失败** Run: `npm test -- src/services/api/__tests__/simulations.test.ts` Expected: FAIL,提示 `listCustomerSimulationGallery` 未导出 - [ ] **Step 3: 补齐契约类型和 API 方法** ```ts export type CustomerSimulationGalleryItem = { simulationId: string taskId: string imageUrl: string templateName: string createdAt: string taskStatus: 'SUCCESSES' } ``` ```ts export async function listCustomerSimulationGallery( customerId: string, ): Promise<{ items: CustomerSimulationGalleryItem[] }> { return http.get(`/simulations/customer/${customerId}/gallery`) } ``` - [ ] **Step 4: 跑测试确认通过** Run: `npm test -- src/services/api/__tests__/simulations.test.ts` Expected: PASS - [ ] **Step 5: Commit** ```bash git add frontend/src/services/api/contracts.ts \ frontend/src/services/api/simulations.ts \ frontend/src/services/api/__tests__/simulations.test.ts git commit -m "feat: add customer simulation gallery api" ``` ### Task 4: 顾客详情页真实卡片流替换 **Files:** - Modify: `/Users/yuchen/Documents/hairstylist/frontend/src/pages/dashboard/customers/CustomerDetailPage.tsx` - Modify: `/Users/yuchen/Documents/hairstylist/frontend/public/css/aitori.css` - Test: `/Users/yuchen/Documents/hairstylist/frontend/src/pages/dashboard/customers/__tests__/CustomerDetailPage.test.tsx` **Interfaces:** - Consumes: `getCustomerById(customerId: string)`, `listCustomerSimulationGallery(customerId: string)` - Produces: 顾客详情页中真实 gallery 卡片、空状态、卡片跳转 - [ ] **Step 1: 写失败页面测试,约束真实卡片渲染和跳转** ```tsx it('renders gallery cards and links each image card to simulation detail', async () => { getCustomerById.mockResolvedValue({ id: '5', name: 'Mika', phone: '090-1111-2222', nextFollowDate: '2026-06-30', recommendVisitDate: '2026-07-10', followContent: 'follow', visitFrequencyLabel: '未记录', }); listCustomerSimulationGallery.mockResolvedValue({ items: [ { simulationId: '91', taskId: 'task-11', imageUrl: 'https://cdn.example.com/result-1.png', templateName: 'Layer Bob', createdAt: '2026-06-28T09:00:00.000Z', taskStatus: 'SUCCESSES', }, ], }); renderCustomerDetail(); const card = await screen.findByRole('link', { name: /Layer Bob/i }); expect(card).toHaveAttribute('href', '/zh/simulation/result/91'); }); ``` - [ ] **Step 2: 跑页面测试确认失败** Run: `npm test -- src/pages/dashboard/customers/__tests__/CustomerDetailPage.test.tsx` Expected: FAIL,提示 gallery API 未调用或真实卡片未出现 - [ ] **Step 3: 最小实现并行请求、真实卡片、空状态** ```tsx const [customer, setCustomer] = useState(null) const [galleryItems, setGalleryItems] = useState([]) useEffect(() => { if (!customerId) return Promise.all([ getCustomerById(customerId), listCustomerSimulationGallery(customerId), ]) .then(([customerData, galleryData]) => { setCustomer(customerData) setGalleryItems(galleryData.items) }) .catch(() => { setCustomer(null) setGalleryItems([]) }) }, [customerId]) ``` ```tsx
{galleryItems.length ? ( galleryItems.map((item) => ( {item.templateName}
{formatDisplayDateTime(item.createdAt)} {item.templateName}
)) ) : (
{pageCopy.states.empty || '暂无结果'}
)}
``` - [ ] **Step 4: 跑页面测试确认通过** Run: `npm test -- src/pages/dashboard/customers/__tests__/CustomerDetailPage.test.tsx` Expected: PASS - [ ] **Step 5: Commit** ```bash git add frontend/src/pages/dashboard/customers/CustomerDetailPage.tsx \ frontend/src/pages/dashboard/customers/__tests__/CustomerDetailPage.test.tsx \ frontend/public/css/aitori.css git commit -m "feat: show simulation gallery on customer detail page" ``` ### Task 5: 回归验证 **Files:** - Modify: `/Users/yuchen/Documents/hairstylist/frontend/src/i18n/locales/zh/translation.json` - Modify: `/Users/yuchen/Documents/hairstylist/frontend/src/i18n/locales/ja/translation.json` **Interfaces:** - Consumes: 顾客详情页空状态、图片区块文案 - Produces: 中日文一致的 gallery 文案和通过的定向验证 - [ ] **Step 1: 如需空状态文案,先补翻译键** ```json "photos": { "badge": "照片与模拟", "title": "过往照片和AI发型模拟", "empty": "暂无 AI 发型模拟结果" } ``` - [ ] **Step 2: 跑前后端定向验证** Run: `npm test -- src/services/api/__tests__/simulations.test.ts src/pages/dashboard/customers/__tests__/CustomerDetailPage.test.tsx` Expected: PASS Run: `npm test -- src/modules/simulations/simulations.service.spec.ts --runInBand` Expected: PASS Run: `npm run test:e2e -- simulations.e2e-spec.ts` Expected: PASS - [ ] **Step 3: 跑类型检查和诊断** Run: `npm run typecheck` Expected: PASS Run: `npm run lint` Expected: PASS in `backend`; `frontend` 仅在本次触达文件范围内不新增错误 - [ ] **Step 4: Commit** ```bash git add frontend/src/i18n/locales/zh/translation.json \ frontend/src/i18n/locales/ja/translation.json git commit -m "test: verify customer simulation gallery flow" ```