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
/:locale/simulation/result/:simulationId,不新增子任务详情页。Files:
/Users/yuchen/Documents/hairstylist/backend/src/modules/simulations/dto/customer-simulation-gallery.dto.ts/Users/yuchen/Documents/hairstylist/backend/src/modules/simulations/simulations.service.ts/Users/yuchen/Documents/hairstylist/backend/src/modules/simulations/simulations.service.spec.tsInterfaces:
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"
Files:
/Users/yuchen/Documents/hairstylist/backend/src/modules/simulations/simulations.controller.ts/Users/yuchen/Documents/hairstylist/backend/test/simulations.e2e-spec.tsInterfaces:
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"
Files:
/Users/yuchen/Documents/hairstylist/frontend/src/services/api/contracts.ts/Users/yuchen/Documents/hairstylist/frontend/src/services/api/simulations.ts/Users/yuchen/Documents/hairstylist/frontend/src/services/api/__tests__/simulations.test.tsInterfaces:
GET /simulations/customer/:customerId/galleryProduces: 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"
Files:
/Users/yuchen/Documents/hairstylist/frontend/src/pages/dashboard/customers/CustomerDetailPage.tsx/Users/yuchen/Documents/hairstylist/frontend/public/css/aitori.css/Users/yuchen/Documents/hairstylist/frontend/src/pages/dashboard/customers/__tests__/CustomerDetailPage.test.tsxInterfaces:
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"
Files:
/Users/yuchen/Documents/hairstylist/frontend/src/i18n/locales/zh/translation.json/Users/yuchen/Documents/hairstylist/frontend/src/i18n/locales/ja/translation.jsonInterfaces:
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
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"