2026-06-28-customer-detail-simulation-gallery.md 16 KB

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 只返回成功图片并按最新在前展开

    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 并实现最小查询逻辑

    export type CustomerSimulationGalleryItemDto = {
    simulationId: string;
    taskId: string;
    imageUrl: string;
    templateName: string;
    createdAt: string;
    taskStatus: 'SUCCESSES';
    };
    
    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

    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 用例

    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 路由

    @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

    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 请求路径

    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 方法

    export type CustomerSimulationGalleryItem = {
    simulationId: string
    taskId: string
    imageUrl: string
    templateName: string
    createdAt: string
    taskStatus: 'SUCCESSES'
    }
    
    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

    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: 写失败页面测试,约束真实卡片渲染和跳转

    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: 最小实现并行请求、真实卡片、空状态

    const [customer, setCustomer] = useState<CustomerDetailEntity | null>(null)
    const [galleryItems, setGalleryItems] = useState<CustomerSimulationGalleryItem[]>([])
    
    useEffect(() => {
    if (!customerId) return
    
    Promise.all([
    getCustomerById(customerId),
    listCustomerSimulationGallery(customerId),
    ])
    .then(([customerData, galleryData]) => {
      setCustomer(customerData)
      setGalleryItems(galleryData.items)
    })
    .catch(() => {
      setCustomer(null)
      setGalleryItems([])
    })
    }, [customerId])
    
    <div className="customer-photo-grid">
    {galleryItems.length ? (
    galleryItems.map((item) => (
      <Link
        key={`${item.simulationId}-${item.taskId}-${item.imageUrl}`}
        to={path(`/simulation/result/${item.simulationId}`)}
        className="customer-photo-card customer-photo-card-link"
      >
        <img src={item.imageUrl} alt={item.templateName} className="customer-photo-image" />
        <div>
          <span>{formatDisplayDateTime(item.createdAt)}</span>
          <strong>{item.templateName}</strong>
        </div>
      </Link>
    ))
    ) : (
    <div className="customer-photo-empty">
      <span>{pageCopy.states.empty || '暂无结果'}</span>
    </div>
    )}
    </div>
    
  • [ ] Step 4: 跑页面测试确认通过

Run: npm test -- src/pages/dashboard/customers/__tests__/CustomerDetailPage.test.tsx

Expected: PASS

  • [ ] Step 5: Commit

    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: 如需空状态文案,先补翻译键

    "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

    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"