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: Build a real AI hair simulation flow that lets stylists optionally bind a customer, upload an origin image, choose stylist/system templates from hair_template, create ai_hair_simulation + ai_hair_simulation_task records, and view per-template task progress in a new detail page.
Architecture: The backend owns task orchestration: it validates customer/template access, calls the external AI service, persists one simulation row plus multiple task rows, and refreshes PROCESSING tasks when the detail endpoint is polled. The frontend only talks to backend simulation endpoints, rewrites the create/detail pages around the new DTOs, and keeps SimulationEditPage / AftercareResultPage as undeveloped shells.
Tech Stack: NestJS 11, Prisma + MySQL, React 19, React Router 7, Vitest, Jest, GCS upload API, external AI HTTP API
aftercare.SimulationEditPage and AftercareResultPage, but mark both as undeveloped and remove old business logic.ai-user-origin-image.hair_template; AI POST /execute must use hair_template.template_url.is_deleted = 0, status = 'PUBLIC', and template_url is non-empty./:locale/simulation/result/:simulationId.PROCESSING tasks from the AI service.ai_hair_simulation_task.template_id as a string in DTOs and frontend contracts, converting to BigInt only inside backend data access.backend/prisma/schema.prisma
hair_template design (templateUrl, isSystemTemplate, enum types).backend/src/config/env.schema.ts
backend/src/config/app.config.ts
aiSimulation config object.backend/src/config/app.config.spec.ts
backend/src/modules/simulations/dto/list-simulation-templates-query.dto.ts
backend/src/modules/simulations/dto/simulation-template-option.dto.ts
backend/src/modules/simulations/dto/simulation-detail.dto.ts
backend/src/modules/simulations/ai-simulation.client.ts
execute, getResult).backend/src/modules/simulations/dto/create-simulation.dto.ts
customerMode/simulationType contract with the new request shape.backend/src/modules/simulations/simulations.controller.ts
GET /simulations/template-options and GET /simulations/:id.backend/src/modules/simulations/simulations.service.ts
backend/src/modules/simulations/simulations.module.ts
backend/src/modules/simulations/simulations.service.spec.ts
backend/test/simulations.e2e-spec.ts
frontend/src/config/routes.ts
simulationResultDetail route with :simulationId.frontend/src/App.tsx
frontend/src/services/api/contracts.ts
SimulationTemplateOption, AiHairSimulationDetail, AiHairSimulationTaskDetail.frontend/src/services/api/simulations.ts
frontend/src/pages/dashboard/simulation/StylistSimulationPage.tsx
frontend/src/pages/dashboard/simulation/SimulationResultPage.tsx
frontend/src/pages/dashboard/simulation/SimulationEditPage.tsx
frontend/src/pages/dashboard/simulation/AftercareResultPage.tsx
frontend/src/pages/dashboard/customers/CustomerDetailPage.tsx
frontend/src/i18n/locales/zh/translation.json
frontend/src/i18n/locales/ja/translation.json
frontend/public/css/aitori.css
frontend/src/pages/dashboard/simulation/__tests__/StylistSimulationPage.test.tsx
frontend/src/pages/dashboard/simulation/__tests__/SimulationResultPage.test.tsx
frontend/src/pages/dashboard/simulation/__tests__/SimulationPlaceholderPages.test.tsx
frontend/src/pages/dashboard/customers/__tests__/CustomerDetailPage.test.tsx
customerId.frontend/src/__tests__/App.test.tsx
/:locale/simulation/result/:simulationId.Files:
backend/prisma/schema.prismabackend/src/config/env.schema.tsbackend/src/config/app.config.tsbackend/src/config/app.config.spec.tsInterfaces:
ConfigModule.forRoot() behavior and current Prisma models.Produces:
config.aiSimulation.baseUrl: stringconfig.aiSimulation.refreshIntervalMs: numberconfig.aiSimulation.httpTimeoutMs: numberHairTemplate.templateUrl: stringHairTemplate.isSystemTemplate: 'YES' | 'NO'[ ] Step 1: Create the feature branch
Run:
git switch -c feature/ai-hair-simulation
Expected: terminal prints Switched to a new branch 'feature/ai-hair-simulation'
Add to backend/src/config/app.config.spec.ts:
it('maps ai simulation config from env', () => {
process.env.AI_SIMULATION_BASE_URL = 'http://localhost:8084';
process.env.AI_SIMULATION_REFRESH_INTERVAL_MS = '10000';
process.env.AI_SIMULATION_HTTP_TIMEOUT_MS = '15000';
const config = appConfig();
expect(config.aiSimulation).toEqual({
baseUrl: 'http://localhost:8084',
refreshIntervalMs: 10000,
httpTimeoutMs: 15000,
});
});
Run:
cd /Users/yuchen/Documents/hairstylist/backend
npm test -- src/config/app.config.spec.ts --runInBand
Expected: FAIL because aiSimulation is missing from the config object
Update backend/src/config/env.schema.ts:
AI_SIMULATION_BASE_URL: z.string().url().default('http://localhost:8084'),
AI_SIMULATION_REFRESH_INTERVAL_MS: z.coerce.number().int().positive().default(10000),
AI_SIMULATION_HTTP_TIMEOUT_MS: z.coerce.number().int().positive().default(15000),
Update backend/src/config/app.config.ts:
aiSimulation: {
baseUrl: process.env.AI_SIMULATION_BASE_URL ?? 'http://localhost:8084',
refreshIntervalMs: Number(process.env.AI_SIMULATION_REFRESH_INTERVAL_MS ?? 10000),
httpTimeoutMs: Number(process.env.AI_SIMULATION_HTTP_TIMEOUT_MS ?? 15000),
},
Update backend/prisma/schema.prisma in HairTemplate:
enum HairTemplateStatus {
DRAFT
PUBLIC
}
enum HairTemplateCategory {
LONG
MEDIUM
SHORT
DYE
}
enum HairTemplateSystemFlag {
YES
NO
}
model HairTemplate {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
templateName String @map("template_name") @db.VarChar(100)
coverUrl String @default("") @map("cover_url") @db.VarChar(500)
status HairTemplateStatus
category HairTemplateCategory
tags String @default("") @db.VarChar(500)
description String? @db.Text
modelUrl String @default("") @map("model_url") @db.VarChar(500)
clothesUrl String @default("") @map("clothes_url") @db.VarChar(500)
backgroundUrl String @default("") @map("background_url") @db.VarChar(500)
refHairUrl String @default("") @map("ref_hair_url") @db.VarChar(500)
hairstylistId BigInt? @map("hairstylist_id") @db.UnsignedBigInt
templateUrl String @default("") @map("template_url") @db.VarChar(500)
isSystemTemplate HairTemplateSystemFlag @default(NO) @map("is_system_template")
createTime DateTime @default(now()) @map("create_time")
updateTime DateTime @updatedAt @map("update_time")
isDeleted Int @default(0) @map("is_deleted") @db.UnsignedTinyInt
@@index([category, isDeleted], map: "idx_category")
@@index([hairstylistId, isDeleted], map: "idx_hairstylist_id")
@@index([status, isDeleted], map: "idx_status")
@@map("hair_template")
}
Run:
cd /Users/yuchen/Documents/hairstylist/backend
npm test -- src/config/app.config.spec.ts --runInBand
npm run prisma:validate
Expected:
PASS src/config/app.config.spec.ts
The schema at prisma/schema.prisma is valid
[ ] Step 6: Commit
git add backend/prisma/schema.prisma backend/src/config/env.schema.ts backend/src/config/app.config.ts backend/src/config/app.config.spec.ts
git commit -m "chore: align ai simulation config and prisma schema"
Files:
backend/src/modules/simulations/dto/list-simulation-templates-query.dto.tsbackend/src/modules/simulations/dto/simulation-template-option.dto.tsbackend/src/modules/simulations/simulations.controller.tsbackend/src/modules/simulations/simulations.service.tsbackend/src/modules/simulations/simulations.service.spec.tsInterfaces:
HairTemplate.statusHairTemplate.isSystemTemplateProduces:
GET /api/v1/simulations/template-optionslistTemplateOptions(stylistId: bigint, query: ListSimulationTemplateOptionsQueryDto): Promise<{ items: SimulationTemplateOptionDto[]; total: number; page: number; pageSize: number }>[ ] Step 1: Write the failing unit tests for source filtering
Create backend/src/modules/simulations/simulations.service.spec.ts with:
it('returns only public stylist templates for the current stylist', async () => {
prisma.hairTemplate.findMany.mockResolvedValue([
{
id: BigInt(11),
templateName: 'Layer Bob',
coverUrl: 'https://cdn.example.com/bob.png',
category: 'SHORT',
tags: 'airy,bob',
description: 'short bob',
templateUrl: 'https://cdn.example.com/bob.zip',
isSystemTemplate: 'NO',
},
] as any);
prisma.hairTemplate.count.mockResolvedValue(1);
const result = await service.listTemplateOptions(BigInt(7), {
source: 'STYLIST',
page: 1,
pageSize: 12,
});
expect(prisma.hairTemplate.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
isDeleted: 0,
status: 'PUBLIC',
isSystemTemplate: 'NO',
hairstylistId: BigInt(7),
}),
}),
);
expect(result.items[0]).toMatchObject({
id: '11',
templateName: 'Layer Bob',
tags: ['airy', 'bob'],
isSystemTemplate: false,
});
});
it('returns only public system templates for the system tab', async () => {
prisma.hairTemplate.findMany.mockResolvedValue([
{
id: BigInt(21),
templateName: 'Classic Medium',
coverUrl: '',
category: 'MEDIUM',
tags: '',
description: null,
templateUrl: 'https://cdn.example.com/medium.zip',
isSystemTemplate: 'YES',
},
] as any);
prisma.hairTemplate.count.mockResolvedValue(1);
const result = await service.listTemplateOptions(BigInt(7), {
source: 'SYSTEM',
page: 1,
pageSize: 12,
});
expect(prisma.hairTemplate.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
isDeleted: 0,
status: 'PUBLIC',
isSystemTemplate: 'YES',
}),
}),
);
expect(result.items[0].id).toBe('21');
});
Run:
cd /Users/yuchen/Documents/hairstylist/backend
npm test -- src/modules/simulations/simulations.service.spec.ts --runInBand
Expected: FAIL because listTemplateOptions() and the DTOs do not exist
Create backend/src/modules/simulations/dto/list-simulation-templates-query.dto.ts:
import { Transform } from 'class-transformer';
import { IsEnum, IsInt, IsOptional, IsString, Min } from 'class-validator';
export class ListSimulationTemplateOptionsQueryDto {
@IsEnum(['STYLIST', 'SYSTEM'])
source!: 'STYLIST' | 'SYSTEM';
@IsOptional()
@IsEnum(['LONG', 'MEDIUM', 'SHORT', 'DYE'])
category?: 'LONG' | 'MEDIUM' | 'SHORT' | 'DYE';
@IsOptional()
@IsString()
q?: string;
@Transform(({ value }) => Number(value ?? 1))
@IsInt()
@Min(1)
page = 1;
@Transform(({ value }) => Number(value ?? 12))
@IsInt()
@Min(1)
pageSize = 12;
}
Create backend/src/modules/simulations/dto/simulation-template-option.dto.ts:
export type SimulationTemplateOptionDto = {
id: string;
templateName: string;
coverUrl: string;
category: 'LONG' | 'MEDIUM' | 'SHORT' | 'DYE';
tags: string[];
description: string | null;
templateUrl: string;
isSystemTemplate: boolean;
};
Add to backend/src/modules/simulations/simulations.controller.ts:
@Get('template-options')
listTemplateOptions(
@CurrentUser() user: AuthUser,
@Query() query: ListSimulationTemplateOptionsQueryDto,
) {
return this.simulationsService.listTemplateOptions(BigInt(user.userId), query);
}
Add to backend/src/modules/simulations/simulations.service.ts:
async listTemplateOptions(
hairstylistId: bigint,
query: ListSimulationTemplateOptionsQueryDto,
) {
const where = {
isDeleted: 0,
status: 'PUBLIC' as const,
templateUrl: { not: '' },
...(query.category ? { category: query.category } : {}),
...(query.q
? { templateName: { contains: query.q } }
: {}),
...(query.source === 'STYLIST'
? { isSystemTemplate: 'NO' as const, hairstylistId }
: { isSystemTemplate: 'YES' as const }),
};
const [items, total] = await Promise.all([
this.prisma.hairTemplate.findMany({
where,
orderBy: { id: 'desc' },
skip: (query.page - 1) * query.pageSize,
take: query.pageSize,
}),
this.prisma.hairTemplate.count({ where }),
]);
return {
items: items.map((item) => ({
id: item.id.toString(),
templateName: item.templateName,
coverUrl: item.coverUrl,
category: item.category,
tags: item.tags
? item.tags.split(',').map((tag) => tag.trim()).filter(Boolean)
: [],
description: item.description,
templateUrl: item.templateUrl,
isSystemTemplate: item.isSystemTemplate === 'YES',
})),
total,
page: query.page,
pageSize: query.pageSize,
};
}
Run:
cd /Users/yuchen/Documents/hairstylist/backend
npm test -- src/modules/simulations/simulations.service.spec.ts --runInBand
Expected:
PASS src/modules/simulations/simulations.service.spec.ts
[ ] Step 5: Commit
git add backend/src/modules/simulations/dto/list-simulation-templates-query.dto.ts backend/src/modules/simulations/dto/simulation-template-option.dto.ts backend/src/modules/simulations/simulations.controller.ts backend/src/modules/simulations/simulations.service.ts backend/src/modules/simulations/simulations.service.spec.ts
git commit -m "feat: add simulation template options endpoint"
Files:
backend/src/modules/simulations/ai-simulation.client.tsbackend/src/modules/simulations/dto/create-simulation.dto.tsbackend/src/modules/simulations/simulations.module.tsbackend/src/modules/simulations/simulations.controller.tsbackend/src/modules/simulations/simulations.service.tsbackend/src/modules/simulations/simulations.service.spec.tsbackend/test/simulations.e2e-spec.tsInterfaces:
listTemplateOptions task outputconfig.aiSimulationProduces:
AiSimulationClient.execute(templateUrl: string, imageUrl: string): Promise<{ taskId: string }>createSimulation(stylistId: bigint, dto: CreateSimulationDto): Promise<{ simulationId: string }>POST /api/v1/simulations[ ] Step 1: Write failing tests for create validation and persistence
Extend backend/src/modules/simulations/simulations.service.spec.ts with:
it('creates one simulation and one task row per selected template', async () => {
prisma.customer.findFirst.mockResolvedValue({ id: BigInt(5) } as any);
prisma.hairTemplate.findMany.mockResolvedValue([
{
id: BigInt(11),
templateUrl: 'https://cdn.example.com/11.zip',
isSystemTemplate: 'NO',
hairstylistId: BigInt(7),
status: 'PUBLIC',
isDeleted: 0,
},
{
id: BigInt(12),
templateUrl: 'https://cdn.example.com/12.zip',
isSystemTemplate: 'YES',
hairstylistId: null,
status: 'PUBLIC',
isDeleted: 0,
},
] as any);
aiClient.execute
.mockResolvedValueOnce({ taskId: 'task-11' })
.mockResolvedValueOnce({ taskId: 'task-12' });
prisma.$transaction.mockImplementation(async (callback: any) =>
callback({
aiHairSimulation: {
create: jest.fn().mockResolvedValue({ id: BigInt(91) }),
},
aiHairSimulationTask: {
createMany: jest.fn().mockResolvedValue({ count: 2 }),
},
}),
);
const result = await service.createSimulation(BigInt(7), {
customerId: '5',
originImage: 'https://cdn.example.com/origin.jpg',
templateIds: ['11', '12'],
simulateRemark: 'soft volume',
});
expect(aiClient.execute).toHaveBeenNthCalledWith(
1,
'https://cdn.example.com/11.zip',
'https://cdn.example.com/origin.jpg',
);
expect(result).toEqual({ simulationId: '91' });
});
it('rejects customer ids outside the current stylist scope', async () => {
prisma.customer.findFirst.mockResolvedValue(null);
await expect(
service.createSimulation(BigInt(7), {
customerId: '999',
originImage: 'https://cdn.example.com/origin.jpg',
templateIds: ['11'],
}),
).rejects.toThrow('顾客不存在');
});
Run:
cd /Users/yuchen/Documents/hairstylist/backend
npm test -- src/modules/simulations/simulations.service.spec.ts --runInBand
Expected: FAIL because the new create contract and AI client are not implemented
Rewrite backend/src/modules/simulations/dto/create-simulation.dto.ts:
import { ArrayMinSize, IsArray, IsOptional, IsString, IsUrl } from 'class-validator';
export class CreateSimulationDto {
@IsOptional()
@IsString()
customerId?: string;
@IsUrl()
originImage!: string;
@IsArray()
@ArrayMinSize(1)
@IsString({ each: true })
templateIds!: string[];
@IsOptional()
@IsString()
simulateRemark?: string;
}
Create backend/src/modules/simulations/ai-simulation.client.ts:
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class AiSimulationClient {
constructor(private readonly configService: ConfigService) {}
async execute(templateUrl: string, imageUrl: string): Promise<{ taskId: string }> {
const response = await fetch(
`${this.configService.getOrThrow<string>('aiSimulation.baseUrl')}/execute`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
template: templateUrl,
inputs: {
images: [imageUrl],
},
}),
},
);
if (!response.ok) {
throw new ServiceUnavailableException('AI 模拟服务暂时不可用');
}
const data = (await response.json()) as { taskId?: string };
if (!data.taskId) {
throw new ServiceUnavailableException('AI 模拟任务创建失败');
}
return { taskId: data.taskId };
}
}
Add to backend/src/modules/simulations/simulations.service.ts:
async createSimulation(hairstylistId: bigint, dto: CreateSimulationDto) {
if (dto.customerId) {
const customer = await this.prisma.customer.findFirst({
where: {
id: BigInt(dto.customerId),
hairstylistId,
isDeleted: 0,
},
select: { id: true },
});
if (!customer) {
throw new NotFoundException('顾客不存在');
}
}
const templateIds = dto.templateIds.map((value) => BigInt(value));
const templates = await this.prisma.hairTemplate.findMany({
where: {
id: { in: templateIds },
isDeleted: 0,
status: 'PUBLIC',
templateUrl: { not: '' },
OR: [
{ isSystemTemplate: 'YES' },
{ isSystemTemplate: 'NO', hairstylistId },
],
},
});
if (templates.length !== dto.templateIds.length) {
throw new BadRequestException('部分模板不可用');
}
const taskSeeds = [];
for (const template of templates) {
const { taskId } = await this.aiSimulationClient.execute(
template.templateUrl,
dto.originImage,
);
taskSeeds.push({
template,
taskId,
});
}
return this.prisma.$transaction(async (tx) => {
const simulation = await tx.aiHairSimulation.create({
data: {
customerId: dto.customerId ? BigInt(dto.customerId) : null,
hairstylistId,
originImage: dto.originImage,
simulateRemark: dto.simulateRemark ?? null,
},
});
await tx.aiHairSimulationTask.createMany({
data: taskSeeds.map((seed) => ({
simulationId: simulation.id,
templateId: seed.template.id.toString(),
customerId: dto.customerId ? BigInt(dto.customerId) : null,
taskId: seed.taskId,
taskStatus: 'PROCESSING',
resultImages: null,
taskStartTime: new Date(),
taskFinishTime: null,
errorMsg: null,
})),
});
return { simulationId: simulation.id.toString() };
});
}
Create backend/test/simulations.e2e-spec.ts:
it('/api/v1/simulations (POST) creates an anonymous simulation', async () => {
const response = await request(app.getHttpServer())
.post('/api/v1/simulations')
.set('Authorization', `Bearer ${token}`)
.send({
originImage: 'https://cdn.example.com/origin.jpg',
templateIds: ['11'],
simulateRemark: 'airy finish',
})
.expect(201);
expect(response.body.data).toEqual({
simulationId: expect.any(String),
});
});
Run:
cd /Users/yuchen/Documents/hairstylist/backend
npm test -- src/modules/simulations/simulations.service.spec.ts --runInBand
npm run test:e2e -- test/simulations.e2e-spec.ts --runInBand
Expected:
PASS src/modules/simulations/simulations.service.spec.ts
PASS test/simulations.e2e-spec.ts
[ ] Step 6: Commit
git add backend/src/modules/simulations/ai-simulation.client.ts backend/src/modules/simulations/dto/create-simulation.dto.ts backend/src/modules/simulations/simulations.module.ts backend/src/modules/simulations/simulations.controller.ts backend/src/modules/simulations/simulations.service.ts backend/src/modules/simulations/simulations.service.spec.ts backend/test/simulations.e2e-spec.ts
git commit -m "feat: create ai hair simulations"
Files:
backend/src/modules/simulations/dto/simulation-detail.dto.tsbackend/src/modules/simulations/ai-simulation.client.tsbackend/src/modules/simulations/simulations.controller.tsbackend/src/modules/simulations/simulations.service.tsbackend/src/modules/simulations/simulations.service.spec.tsbackend/test/simulations.e2e-spec.tsInterfaces:
AiSimulationClient.execute()config.aiSimulation.refreshIntervalMsProduces:
AiSimulationClient.getResult(taskId: string): Promise<{ status: 'processing' | 'successes' | 'failed'; message: string; images: string[] }>getSimulationDetail(stylistId: bigint, simulationId: string): Promise<AiHairSimulationDetailDto>GET /api/v1/simulations/:simulationId[ ] Step 1: Write failing tests for refresh and aggregate status
Extend backend/src/modules/simulations/simulations.service.spec.ts with:
it('refreshes stale processing tasks and returns PARTIAL_SUCCESS when mixed', async () => {
prisma.aiHairSimulation.findFirst.mockResolvedValue({
id: BigInt(91),
customerId: BigInt(5),
hairstylistId: BigInt(7),
originImage: 'https://cdn.example.com/origin.jpg',
simulateRemark: 'airy',
customer: {
id: BigInt(5),
name: 'Mika',
avatar: '',
phone: '090',
expectContact: 'LINE',
},
} as any);
prisma.aiHairSimulationTask.findMany.mockResolvedValue([
{
id: BigInt(1),
templateId: '11',
taskId: 'task-11',
taskStatus: 'PROCESSING',
resultImages: null,
errorMsg: null,
taskStartTime: new Date('2026-06-28T10:00:00Z'),
taskFinishTime: null,
updateTime: new Date('2026-06-28T09:59:00Z'),
},
{
id: BigInt(2),
templateId: '12',
taskId: 'task-12',
taskStatus: 'FAILED',
resultImages: null,
errorMsg: 'template invalid',
taskStartTime: new Date('2026-06-28T10:00:00Z'),
taskFinishTime: new Date('2026-06-28T10:02:00Z'),
updateTime: new Date('2026-06-28T10:02:00Z'),
},
] as any);
prisma.hairTemplate.findMany.mockResolvedValue([
{
id: BigInt(11),
templateName: 'Layer Bob',
coverUrl: '',
category: 'SHORT',
isSystemTemplate: 'NO',
},
{
id: BigInt(12),
templateName: 'Classic Medium',
coverUrl: '',
category: 'MEDIUM',
isSystemTemplate: 'YES',
},
] as any);
aiClient.getResult.mockResolvedValue({
status: 'successes',
message: '',
images: ['https://cdn.example.com/result.png'],
});
const result = await service.getSimulationDetail(BigInt(7), '91');
expect(aiClient.getResult).toHaveBeenCalledWith('task-11');
expect(prisma.aiHairSimulationTask.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
taskStatus: 'SUCCESSES',
resultImages: JSON.stringify(['https://cdn.example.com/result.png']),
}),
}),
);
expect(result.overallStatus).toBe('PARTIAL_SUCCESS');
});
Run:
cd /Users/yuchen/Documents/hairstylist/backend
npm test -- src/modules/simulations/simulations.service.spec.ts --runInBand
Expected: FAIL because getSimulationDetail() and getResult() do not exist
Create backend/src/modules/simulations/dto/simulation-detail.dto.ts:
export type AiHairSimulationTaskDetailDto = {
id: string;
templateId: string;
templateName: string;
templateCoverUrl: string;
templateCategory: 'LONG' | 'MEDIUM' | 'SHORT' | 'DYE';
templateSource: 'STYLIST' | 'SYSTEM';
taskId: string;
taskStatus: 'PROCESSING' | 'SUCCESSES' | 'FAILED';
resultImages: string[];
errorMsg: string | null;
taskStartTime: string;
taskFinishTime: string | null;
};
export type AiHairSimulationDetailDto = {
id: string;
customer: {
id: string;
name: string;
avatar: string;
phone: string;
expectContact: 'LINE' | 'PHONE' | 'EMAIL' | 'NONE';
} | null;
originImage: string;
simulateRemark: string | null;
totalTaskCount: number;
processingTaskCount: number;
successTaskCount: number;
failedTaskCount: number;
overallStatus: 'PROCESSING' | 'PARTIAL_SUCCESS' | 'SUCCESSES' | 'FAILED';
tasks: AiHairSimulationTaskDetailDto[];
};
Extend backend/src/modules/simulations/ai-simulation.client.ts:
async getResult(taskId: string): Promise<{
status: 'processing' | 'successes' | 'failed';
message: string;
images: string[];
}> {
const response = await fetch(
`${this.configService.getOrThrow<string>('aiSimulation.baseUrl')}/result?taskId=${encodeURIComponent(taskId)}`,
);
if (!response.ok) {
throw new ServiceUnavailableException('AI 模拟结果查询失败');
}
const data = (await response.json()) as {
status: 'processing' | 'successes' | 'failed';
message?: string;
data?: { images?: string[] | null } | null;
};
return {
status: data.status,
message: data.message ?? '',
images: data.data?.images?.filter(Boolean) ?? [],
};
}
Add to backend/src/modules/simulations/simulations.service.ts:
private shouldRefreshTask(updateTime: Date): boolean {
const refreshIntervalMs =
this.configService.getOrThrow<number>('aiSimulation.refreshIntervalMs');
return Date.now() - updateTime.getTime() >= refreshIntervalMs;
}
async getSimulationDetail(hairstylistId: bigint, simulationId: string) {
const simulation = await this.prisma.aiHairSimulation.findFirst({
where: {
id: BigInt(simulationId),
hairstylistId,
isDeleted: 0,
},
include: {
customer: {
select: {
id: true,
name: true,
avatar: true,
phone: true,
expectContact: true,
},
},
},
});
if (!simulation) {
throw new NotFoundException('模拟任务不存在');
}
const tasks = await this.prisma.aiHairSimulationTask.findMany({
where: {
simulationId: simulation.id,
isDeleted: 0,
},
orderBy: { id: 'asc' },
});
for (const task of tasks) {
if (task.taskStatus !== 'PROCESSING') continue;
if (!this.shouldRefreshTask(task.updateTime)) continue;
try {
const result = await this.aiSimulationClient.getResult(task.taskId);
if (result.status === 'processing') {
continue;
}
await this.prisma.aiHairSimulationTask.update({
where: { id: task.id },
data:
result.status === 'successes'
? {
taskStatus: 'SUCCESSES',
resultImages: JSON.stringify(result.images),
taskFinishTime: new Date(),
errorMsg: null,
}
: {
taskStatus: 'FAILED',
errorMsg: result.message || 'AI 模拟失败',
taskFinishTime: new Date(),
},
});
} catch (error) {
this.logger.warn(`Failed to refresh simulation task ${task.taskId}`);
}
}
const freshTasks = await this.prisma.aiHairSimulationTask.findMany({
where: { simulationId: simulation.id, isDeleted: 0 },
orderBy: { id: 'asc' },
});
const templateIds = freshTasks.map((task) => BigInt(task.templateId));
const templates = await this.prisma.hairTemplate.findMany({
where: { id: { in: templateIds } },
});
const templateMap = new Map(templates.map((item) => [item.id.toString(), item]));
const successTaskCount = freshTasks.filter((task) => task.taskStatus === 'SUCCESSES').length;
const failedTaskCount = freshTasks.filter((task) => task.taskStatus === 'FAILED').length;
const processingTaskCount = freshTasks.filter((task) => task.taskStatus === 'PROCESSING').length;
return {
id: simulation.id.toString(),
customer: simulation.customer
? {
id: simulation.customer.id.toString(),
name: simulation.customer.name,
avatar: simulation.customer.avatar,
phone: simulation.customer.phone,
expectContact: simulation.customer.expectContact,
}
: null,
originImage: simulation.originImage,
simulateRemark: simulation.simulateRemark,
totalTaskCount: freshTasks.length,
processingTaskCount,
successTaskCount,
failedTaskCount,
overallStatus:
processingTaskCount > 0
? 'PROCESSING'
: successTaskCount > 0 && failedTaskCount > 0
? 'PARTIAL_SUCCESS'
: successTaskCount === freshTasks.length
? 'SUCCESSES'
: 'FAILED',
tasks: freshTasks.map((task) => {
const template = templateMap.get(task.templateId);
return {
id: task.id.toString(),
templateId: task.templateId,
templateName: template?.templateName ?? 'Unknown Template',
templateCoverUrl: template?.coverUrl ?? '',
templateCategory: (template?.category ?? 'LONG') as 'LONG' | 'MEDIUM' | 'SHORT' | 'DYE',
templateSource: template?.isSystemTemplate === 'YES' ? 'SYSTEM' : 'STYLIST',
taskId: task.taskId,
taskStatus: task.taskStatus,
resultImages: task.resultImages ? JSON.parse(task.resultImages) : [],
errorMsg: task.errorMsg,
taskStartTime: task.taskStartTime.toISOString(),
taskFinishTime: task.taskFinishTime?.toISOString() ?? null,
};
}),
};
}
Extend backend/test/simulations.e2e-spec.ts:
it('/api/v1/simulations/:id (GET) returns task detail for the owner', async () => {
const response = await request(app.getHttpServer())
.get(`/api/v1/simulations/${simulationId}`)
.set('Authorization', `Bearer ${token}`)
.expect(200);
expect(response.body.data).toMatchObject({
id: simulationId,
tasks: expect.any(Array),
overallStatus: expect.any(String),
});
});
Run:
cd /Users/yuchen/Documents/hairstylist/backend
npm test -- src/modules/simulations/simulations.service.spec.ts --runInBand
npm run test:e2e -- test/simulations.e2e-spec.ts --runInBand
Expected:
PASS src/modules/simulations/simulations.service.spec.ts
PASS test/simulations.e2e-spec.ts
[ ] Step 6: Commit
git add backend/src/modules/simulations/dto/simulation-detail.dto.ts backend/src/modules/simulations/ai-simulation.client.ts backend/src/modules/simulations/simulations.controller.ts backend/src/modules/simulations/simulations.service.ts backend/src/modules/simulations/simulations.service.spec.ts backend/test/simulations.e2e-spec.ts
git commit -m "feat: add simulation detail refresh flow"
Files:
frontend/src/config/routes.tsfrontend/src/App.tsxfrontend/src/services/api/contracts.tsfrontend/src/services/api/simulations.tsfrontend/src/__tests__/App.test.tsxInterfaces:
/simulations/template-options/simulations/simulations/:idProduces:
routes.simulationResultDetail = '/:locale/simulation/result/:simulationId'listSimulationTemplates(params)createSimulation(input)getSimulationById(simulationId)[ ] Step 1: Write failing frontend API and route tests
Add to frontend/src/__tests__/App.test.tsx:
it('renders simulation result detail route with simulationId param', async () => {
window.history.pushState({}, '', '/zh/simulation/result/91');
render(<App />);
expect(await screen.findByText(/Aitori Stylist/i)).toBeInTheDocument();
});
Create or extend frontend/src/services/api/__tests__/integration-adapters.test.ts with:
it('builds simulation detail requests with path params', async () => {
vi.spyOn(http, 'get').mockResolvedValue({ id: '91' } as never);
await getSimulationById('91');
expect(http.get).toHaveBeenCalledWith('/simulations/91');
});
Run:
cd /Users/yuchen/Documents/hairstylist/frontend
npm test -- src/__tests__/App.test.tsx src/services/api/__tests__/integration-adapters.test.ts
Expected: FAIL because the detail route and API helper do not exist
Update frontend/src/config/routes.ts:
simulationResultDetail: '/:locale/simulation/result/:simulationId',
Update frontend/src/App.tsx:
<Route
path={routes.simulationResultDetail}
element={
<RequireAuth>
<SimulationResultPage />
</RequireAuth>
}
/>
Update frontend/src/services/api/contracts.ts:
export type SimulationTemplateOption = {
id: string;
templateName: string;
coverUrl: string;
category: 'LONG' | 'MEDIUM' | 'SHORT' | 'DYE';
tags: string[];
description: string | null;
templateUrl: string;
isSystemTemplate: boolean;
};
export type AiHairSimulationTaskDetail = {
id: string;
templateId: string;
templateName: string;
templateCoverUrl: string;
templateCategory: 'LONG' | 'MEDIUM' | 'SHORT' | 'DYE';
templateSource: 'STYLIST' | 'SYSTEM';
taskId: string;
taskStatus: 'PROCESSING' | 'SUCCESSES' | 'FAILED';
resultImages: string[];
errorMsg: string | null;
taskStartTime: string;
taskFinishTime: string | null;
};
export type AiHairSimulationDetail = {
id: string;
customer: {
id: string;
name: string;
avatar: string;
phone: string;
expectContact: 'LINE' | 'PHONE' | 'EMAIL' | 'NONE';
} | null;
originImage: string;
simulateRemark: string | null;
totalTaskCount: number;
processingTaskCount: number;
successTaskCount: number;
failedTaskCount: number;
overallStatus: 'PROCESSING' | 'PARTIAL_SUCCESS' | 'SUCCESSES' | 'FAILED';
tasks: AiHairSimulationTaskDetail[];
};
Rewrite frontend/src/services/api/simulations.ts to:
import { http } from '../http/client';
import type { AiHairSimulationDetail, ListResponse, SimulationTemplateOption } from './contracts';
export async function listSimulationTemplates(params: {
source: 'STYLIST' | 'SYSTEM';
category?: string;
q?: string;
page?: number;
pageSize?: number;
}): Promise<ListResponse<SimulationTemplateOption> & { page: number; pageSize: number }> {
const query = new URLSearchParams();
query.set('source', params.source);
if (params.category) query.set('category', params.category);
if (params.q) query.set('q', params.q);
if (params.page) query.set('page', String(params.page));
if (params.pageSize) query.set('pageSize', String(params.pageSize));
return http.get(`/simulations/template-options?${query.toString()}`);
}
export async function createSimulation(input: {
customerId?: string;
originImage: string;
templateIds: string[];
simulateRemark?: string;
}): Promise<{ simulationId: string }> {
return http.post('/simulations', input);
}
export async function getSimulationById(simulationId: string): Promise<AiHairSimulationDetail> {
return http.get(`/simulations/${simulationId}`);
}
Remove obsolete exports in that file:
// delete:
// getSimulationJob
// getSimulationResults
// waitForSimulationJob
// saveSimulationResult
// adjustSimulationResult
// saveAftercareFollow
Run:
cd /Users/yuchen/Documents/hairstylist/frontend
npm test -- src/__tests__/App.test.tsx src/services/api/__tests__/integration-adapters.test.ts
npm run typecheck
Expected:
PASS src/__tests__/App.test.tsx
PASS src/services/api/__tests__/integration-adapters.test.ts
[ ] Step 5: Commit
git add frontend/src/config/routes.ts frontend/src/App.tsx frontend/src/services/api/contracts.ts frontend/src/services/api/simulations.ts frontend/src/__tests__/App.test.tsx frontend/src/services/api/__tests__/integration-adapters.test.ts
git commit -m "refactor: replace simulation api contracts"
Files:
frontend/src/pages/dashboard/simulation/StylistSimulationPage.tsxfrontend/src/i18n/locales/zh/translation.jsonfrontend/src/i18n/locales/ja/translation.jsonfrontend/public/css/aitori.cssfrontend/src/pages/dashboard/simulation/__tests__/StylistSimulationPage.test.tsxInterfaces:
listCustomers()uploadMedia(file, 'ai-user-origin-image')listSimulationTemplates()createSimulation()Produces:
STYLIST / SYSTEM)/simulation/result/:simulationId[ ] Step 1: Write failing create-page tests
Create frontend/src/pages/dashboard/simulation/__tests__/StylistSimulationPage.test.tsx:
it('defaults to no customer when opened from dashboard', async () => {
vi.mocked(listCustomers).mockResolvedValue({ items: [], total: 0, page: 1, pageSize: 10 });
vi.mocked(listSimulationTemplates).mockResolvedValue({
items: [],
total: 0,
page: 1,
pageSize: 12,
});
renderWithRouter('/zh/simulation');
expect(await screen.findByLabelText(/不绑定顾客/i)).toBeChecked();
});
it('preselects the customer from query string and uploads origin image with ai-user-origin-image', async () => {
vi.mocked(listCustomers).mockResolvedValue({
items: [{ id: '5', name: 'Mika', phone: '090' }],
total: 1,
page: 1,
pageSize: 10,
} as never);
vi.mocked(listSimulationTemplates).mockResolvedValue({
items: [{ id: '11', templateName: 'Layer Bob', coverUrl: '', category: 'SHORT', tags: [], description: null, templateUrl: 'x', isSystemTemplate: false }],
total: 1,
page: 1,
pageSize: 12,
});
vi.mocked(uploadMedia).mockResolvedValue({ url: 'https://cdn.example.com/origin.jpg' } as never);
vi.mocked(createSimulation).mockResolvedValue({ simulationId: '91' });
renderWithRouter('/zh/simulation?customerId=5');
expect(await screen.findByDisplayValue('5')).toBeInTheDocument();
await userEvent.upload(screen.getByLabelText(/上传顾客照片/i), file);
await userEvent.click(screen.getByRole('button', { name: /发起模拟/i }));
expect(uploadMedia).toHaveBeenCalledWith(expect.any(File), 'ai-user-origin-image');
expect(createSimulation).toHaveBeenCalledWith(
expect.objectContaining({
customerId: '5',
originImage: 'https://cdn.example.com/origin.jpg',
templateIds: ['11'],
}),
);
});
Run:
cd /Users/yuchen/Documents/hairstylist/frontend
npm test -- src/pages/dashboard/simulation/__tests__/StylistSimulationPage.test.tsx
Expected: FAIL because the page still uses selected/guest and the old API surface
Update frontend/src/pages/dashboard/simulation/StylistSimulationPage.tsx around the form state:
const [bindCustomer, setBindCustomer] = useState(Boolean(searchParams.get('customerId')));
const [selectedCustomerId, setSelectedCustomerId] = useState<string>(searchParams.get('customerId') ?? '');
const [templateSource, setTemplateSource] = useState<'STYLIST' | 'SYSTEM'>('STYLIST');
const [selectedTemplates, setSelectedTemplates] = useState<string[]>([]);
const [originImageUrl, setOriginImageUrl] = useState('');
const handleUpload = async (file: File) => {
const uploaded = await uploadMedia(file, 'ai-user-origin-image');
setOriginImageUrl(uploaded.url);
};
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
if (!originImageUrl) {
setError(copy.simulationCreatePage.errors.originRequired);
return;
}
if (selectedTemplates.length === 0) {
setError(copy.simulationCreatePage.errors.templateRequired);
return;
}
const response = await createSimulation({
customerId: bindCustomer && selectedCustomerId ? selectedCustomerId : undefined,
originImage: originImageUrl,
templateIds: selectedTemplates,
simulateRemark,
});
navigate(path(`/simulation/result/${response.simulationId}`));
};
Use source-tab fetching:
useEffect(() => {
void listSimulationTemplates({
source: templateSource,
page: templatePage,
pageSize: 12,
category: selectedCategory || undefined,
q: keyword || undefined,
}).then((result) => {
setTemplates(result.items);
setTemplateTotal(result.total);
});
}, [templateSource, templatePage, selectedCategory, keyword]);
Required translation keys in frontend/src/i18n/locales/zh/translation.json:
"simulationCreatePage": {
"title": "AI发型模拟",
"bindCustomer": "绑定顾客",
"noCustomer": "不绑定顾客",
"myTemplates": "我的模板",
"systemTemplates": "系统模板",
"upload": "上传顾客照片",
"submit": "发起模拟",
"errors": {
"originRequired": "请先上传顾客照片",
"templateRequired": "请至少选择一个模板",
"customerRequired": "请选择顾客"
}
}
Add the corresponding Japanese keys in frontend/src/i18n/locales/ja/translation.json.
Add CSS in frontend/public/css/aitori.css:
.simulation-source-tabs {
display: flex;
gap: 12px;
margin-bottom: 24px;
}
.simulation-source-tab.is-active {
background: #232323;
color: #fff;
}
.simulation-task-card {
border: 1px solid #e5e5e5;
border-radius: 12px;
padding: 20px;
}
Run:
cd /Users/yuchen/Documents/hairstylist/frontend
npm test -- src/pages/dashboard/simulation/__tests__/StylistSimulationPage.test.tsx
npm run typecheck
Expected:
PASS src/pages/dashboard/simulation/__tests__/StylistSimulationPage.test.tsx
[ ] Step 5: Commit
git add frontend/src/pages/dashboard/simulation/StylistSimulationPage.tsx frontend/src/i18n/locales/zh/translation.json frontend/src/i18n/locales/ja/translation.json frontend/public/css/aitori.css frontend/src/pages/dashboard/simulation/__tests__/StylistSimulationPage.test.tsx
git commit -m "feat: rewrite ai simulation create page"
Files:
frontend/src/pages/dashboard/simulation/SimulationResultPage.tsxfrontend/src/pages/dashboard/customers/CustomerDetailPage.tsxfrontend/src/pages/dashboard/simulation/__tests__/SimulationResultPage.test.tsxfrontend/src/pages/dashboard/customers/__tests__/CustomerDetailPage.test.tsxfrontend/src/i18n/locales/zh/translation.jsonfrontend/src/i18n/locales/ja/translation.jsonfrontend/public/css/aitori.cssInterfaces:
getSimulationById(simulationId)routes.simulationResultDetailProduces:
simulationId[ ] Step 1: Write failing detail and CTA tests
Create frontend/src/pages/dashboard/simulation/__tests__/SimulationResultPage.test.tsx:
it('polls while overallStatus is PROCESSING and renders child task cards', async () => {
vi.useFakeTimers();
vi.mocked(getSimulationById)
.mockResolvedValueOnce({
id: '91',
customer: null,
originImage: 'https://cdn.example.com/origin.jpg',
simulateRemark: 'airy',
totalTaskCount: 1,
processingTaskCount: 1,
successTaskCount: 0,
failedTaskCount: 0,
overallStatus: 'PROCESSING',
tasks: [
{
id: '1',
templateId: '11',
templateName: 'Layer Bob',
templateCoverUrl: '',
templateCategory: 'SHORT',
templateSource: 'STYLIST',
taskId: 'task-11',
taskStatus: 'PROCESSING',
resultImages: [],
errorMsg: null,
taskStartTime: '2026-06-28T10:00:00.000Z',
taskFinishTime: null,
},
],
})
.mockResolvedValueOnce({
id: '91',
customer: null,
originImage: 'https://cdn.example.com/origin.jpg',
simulateRemark: 'airy',
totalTaskCount: 1,
processingTaskCount: 0,
successTaskCount: 1,
failedTaskCount: 0,
overallStatus: 'SUCCESSES',
tasks: [
{
id: '1',
templateId: '11',
templateName: 'Layer Bob',
templateCoverUrl: '',
templateCategory: 'SHORT',
templateSource: 'STYLIST',
taskId: 'task-11',
taskStatus: 'SUCCESSES',
resultImages: ['https://cdn.example.com/result.png'],
errorMsg: null,
taskStartTime: '2026-06-28T10:00:00.000Z',
taskFinishTime: '2026-06-28T10:01:00.000Z',
},
],
});
renderWithRouter('/zh/simulation/result/91');
expect(await screen.findByText('Layer Bob')).toBeInTheDocument();
await vi.advanceTimersByTimeAsync(5000);
expect(getSimulationById).toHaveBeenCalledTimes(2);
});
Extend frontend/src/pages/dashboard/customers/__tests__/CustomerDetailPage.test.tsx:
it('navigates to simulation create page with customerId', async () => {
renderWithRouter('/zh/customers/5');
expect(await screen.findByRole('link', { name: /开始模拟|シミュレーション/i }))
.toHaveAttribute('href', '/zh/simulation?customerId=5');
});
Run:
cd /Users/yuchen/Documents/hairstylist/frontend
npm test -- src/pages/dashboard/simulation/__tests__/SimulationResultPage.test.tsx src/pages/dashboard/customers/__tests__/CustomerDetailPage.test.tsx
Expected: FAIL because the detail page still expects the old session/job model and the customer CTA is disabled
Rewrite frontend/src/pages/dashboard/simulation/SimulationResultPage.tsx:
const { simulationId = '' } = useParams();
const [detail, setDetail] = useState<AiHairSimulationDetail | null>(null);
useEffect(() => {
let cancelled = false;
let timer: number | undefined;
const load = async () => {
const data = await getSimulationById(simulationId);
if (cancelled) return;
setDetail(data);
if (data.overallStatus === 'PROCESSING') {
timer = window.setTimeout(load, 5000);
}
};
void load();
return () => {
cancelled = true;
if (timer) window.clearTimeout(timer);
};
}, [simulationId]);
Render task cards:
{detail?.tasks.map((task) => (
<article key={task.id} className="simulation-task-card">
<div className="simulation-task-header">
<img src={task.templateCoverUrl || '/images/aitori-simulation-result.png'} alt={task.templateName} />
<div>
<strong>{task.templateName}</strong>
<span>{task.templateSource === 'SYSTEM' ? copy.simulationResultPage.systemSource : copy.simulationResultPage.stylistSource}</span>
</div>
<span className={`template-status-badge is-${task.taskStatus.toLowerCase()}`}>
{task.taskStatus}
</span>
</div>
{task.errorMsg ? <p role="alert">{task.errorMsg}</p> : null}
<div className="simulation-task-images">
{task.resultImages.map((url) => (
<img key={url} src={url} alt={task.templateName} />
))}
</div>
</article>
))}
Update frontend/src/pages/dashboard/customers/CustomerDetailPage.tsx:
<Link
to={path(`/simulation?customerId=${customer.id}`)}
className="btn btn-large btn-rounded btn-base-color btn-box-shadow fw-700 customer-detail-start-btn"
>
<i className="feather icon-feather-camera"></i>
{pageCopy.simulate}
</Link>
Add copy keys for task counters / source labels in both locale files:
"simulationResultPage": {
"systemSource": "系统模板",
"stylistSource": "我的模板",
"processing": "处理中",
"success": "成功",
"failed": "失败"
}
Run:
cd /Users/yuchen/Documents/hairstylist/frontend
npm test -- src/pages/dashboard/simulation/__tests__/SimulationResultPage.test.tsx src/pages/dashboard/customers/__tests__/CustomerDetailPage.test.tsx
npm run typecheck
npm run lint
Expected:
PASS src/pages/dashboard/simulation/__tests__/SimulationResultPage.test.tsx
PASS src/pages/dashboard/customers/__tests__/CustomerDetailPage.test.tsx
[ ] Step 5: Commit
git add frontend/src/pages/dashboard/simulation/SimulationResultPage.tsx frontend/src/pages/dashboard/customers/CustomerDetailPage.tsx frontend/src/pages/dashboard/simulation/__tests__/SimulationResultPage.test.tsx frontend/src/pages/dashboard/customers/__tests__/CustomerDetailPage.test.tsx frontend/src/i18n/locales/zh/translation.json frontend/src/i18n/locales/ja/translation.json frontend/public/css/aitori.css
git commit -m "feat: add simulation detail page"
Files:
frontend/src/pages/dashboard/simulation/SimulationEditPage.tsxfrontend/src/pages/dashboard/simulation/AftercareResultPage.tsxfrontend/src/pages/dashboard/simulation/__tests__/SimulationPlaceholderPages.test.tsxInterfaces:
App.tsxProduces:
[ ] Step 1: Write failing tests for undeveloped shells
Create frontend/src/pages/dashboard/simulation/__tests__/SimulationPlaceholderPages.test.tsx:
it('renders simulation edit page as undeveloped', async () => {
renderWithRouter('/zh/simulation/edit');
expect(await screen.findByText(/未开发|未実装/i)).toBeInTheDocument();
});
it('renders aftercare page as undeveloped', async () => {
renderWithRouter('/zh/simulation/aftercare');
expect(await screen.findByText(/未开发|未実装/i)).toBeInTheDocument();
});
Run:
cd /Users/yuchen/Documents/hairstylist/frontend
npm test -- src/pages/dashboard/simulation/__tests__/SimulationPlaceholderPages.test.tsx
Expected: FAIL because both pages still render old flow-specific UI
Update frontend/src/pages/dashboard/simulation/SimulationEditPage.tsx:
export function SimulationEditPage() {
const { locale, path, copy } = usePageContext();
return (
<DashboardLayout>
<Helmet>
<html lang={locale === 'ja' ? 'ja' : 'zh-CN'} />
<title>{`Aitori Stylist | ${copy.simulationEditPage.title}`}</title>
</Helmet>
<section className="pt-0">
<div className="container">
<div className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px">
<h1>{copy.simulationEditPage.title}</h1>
<p>{copy.common.undeveloped}</p>
<Link to={path('/simulation')} className="btn btn-base-color fw-700">
{copy.common.back}
</Link>
</div>
</div>
</section>
</DashboardLayout>
);
}
Update frontend/src/pages/dashboard/simulation/AftercareResultPage.tsx:
export function AftercareResultPage() {
const { locale, path, copy } = usePageContext();
return (
<DashboardLayout>
<Helmet>
<html lang={locale === 'ja' ? 'ja' : 'zh-CN'} />
<title>{`Aitori Stylist | ${copy.aftercareResultPage.title}`}</title>
</Helmet>
<section className="pt-0">
<div className="container">
<div className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px">
<h1>{copy.aftercareResultPage.title}</h1>
<p>{copy.common.undeveloped}</p>
<Link to={path('/simulation')} className="btn btn-base-color fw-700">
{copy.common.back}
</Link>
</div>
</div>
</section>
</DashboardLayout>
);
}
Run:
cd /Users/yuchen/Documents/hairstylist/frontend
npm test -- src/pages/dashboard/simulation/__tests__/SimulationPlaceholderPages.test.tsx
npm run typecheck
npm run lint
Expected:
PASS src/pages/dashboard/simulation/__tests__/SimulationPlaceholderPages.test.tsx
[ ] Step 5: Commit
git add frontend/src/pages/dashboard/simulation/SimulationEditPage.tsx frontend/src/pages/dashboard/simulation/AftercareResultPage.tsx frontend/src/pages/dashboard/simulation/__tests__/SimulationPlaceholderPages.test.tsx
git commit -m "refactor: mark simulation side pages undeveloped"
[ ] Step 1: Run backend targeted tests
cd /Users/yuchen/Documents/hairstylist/backend
npm test -- src/config/app.config.spec.ts src/modules/simulations/simulations.service.spec.ts --runInBand
npm run test:e2e -- test/simulations.e2e-spec.ts --runInBand
[ ] Step 2: Run frontend targeted tests
cd /Users/yuchen/Documents/hairstylist/frontend
npm test -- src/__tests__/App.test.tsx src/pages/dashboard/simulation/__tests__/StylistSimulationPage.test.tsx src/pages/dashboard/simulation/__tests__/SimulationResultPage.test.tsx src/pages/dashboard/simulation/__tests__/SimulationPlaceholderPages.test.tsx src/pages/dashboard/customers/__tests__/CustomerDetailPage.test.tsx
npm run typecheck
npm run lint
[ ] Step 3: Run backend lint and Prisma validation
cd /Users/yuchen/Documents/hairstylist/backend
npm run lint
npm run prisma:validate
[ ] Step 4: Manual smoke check
Run the backend and frontend locally, then verify:
1. Open /zh/simulation from dashboard -> no customer selected by default
2. Open /zh/customers/:id -> click simulate -> /zh/simulation?customerId=:id preselects the customer
3. Upload an image -> upload request sends type=ai-user-origin-image
4. Switch between 我的模板 / 系统模板 -> list requests send source=STYLIST / SYSTEM
5. Submit a simulation -> navigate to /zh/simulation/result/:simulationId
6. While tasks are processing -> detail page polls backend and eventually updates counts/results
7. Open /zh/simulation/edit and /zh/simulation/aftercare -> both show undeveloped state
TODO, TBD, “implement later”, or vague “add tests” instructions without concrete test code. Every code-changing task includes concrete file paths, snippets, and commands.CreateSimulationDtoSimulationTemplateOptionAiHairSimulationDetailAiHairSimulationTaskDetail/simulations/template-options/simulations/:simulationId/:locale/simulation/result/:simulationId