Prechádzať zdrojové kódy

merge: feature/ai-hair-simulation

ChenYu 4 mesiacov pred
rodič
commit
3d77d33244
36 zmenil súbory, kde vykonal 3107 pridanie a 900 odobranie
  1. 36 19
      backend/docs/数据库设计.md
  2. 39 15
      backend/prisma/schema.prisma
  3. 14 0
      backend/src/config/app.config.spec.ts
  4. 7 0
      backend/src/config/app.config.ts
  5. 3 0
      backend/src/config/env.schema.ts
  6. 65 0
      backend/src/modules/simulations/ai-simulation.client.ts
  7. 7 20
      backend/src/modules/simulations/dto/create-simulation.dto.ts
  8. 8 0
      backend/src/modules/simulations/dto/customer-simulation-gallery.dto.ts
  9. 25 0
      backend/src/modules/simulations/dto/list-simulation-templates-query.dto.ts
  10. 34 0
      backend/src/modules/simulations/dto/simulation-detail.dto.ts
  11. 10 0
      backend/src/modules/simulations/dto/simulation-template-option.dto.ts
  12. 58 3
      backend/src/modules/simulations/simulations.controller.ts
  13. 6 1
      backend/src/modules/simulations/simulations.module.ts
  14. 385 0
      backend/src/modules/simulations/simulations.service.spec.ts
  15. 451 4
      backend/src/modules/simulations/simulations.service.ts
  16. 5 2
      backend/test/app.e2e-spec.ts
  17. 264 0
      backend/test/simulations.e2e-spec.ts
  18. 198 0
      docs/superpowers/specs/2026-06-28-customer-detail-simulation-gallery-design.md
  19. 132 0
      frontend/public/css/aitori.css
  20. 1 0
      frontend/src/App.tsx
  21. 10 0
      frontend/src/__tests__/App.test.tsx
  22. 1 0
      frontend/src/config/routes.ts
  23. 64 10
      frontend/src/i18n/locales/ja/translation.json
  24. 64 10
      frontend/src/i18n/locales/zh/translation.json
  25. 53 17
      frontend/src/pages/dashboard/customers/CustomerDetailPage.tsx
  26. 84 1
      frontend/src/pages/dashboard/customers/__tests__/CustomerDetailPage.test.tsx
  27. 20 205
      frontend/src/pages/dashboard/simulation/AftercareResultPage.tsx
  28. 20 202
      frontend/src/pages/dashboard/simulation/SimulationEditPage.tsx
  29. 157 204
      frontend/src/pages/dashboard/simulation/SimulationResultPage.tsx
  30. 262 184
      frontend/src/pages/dashboard/simulation/StylistSimulationPage.tsx
  31. 219 0
      frontend/src/pages/dashboard/simulation/__tests__/SimulationResultPage.test.tsx
  32. 66 0
      frontend/src/pages/dashboard/simulation/__tests__/SimulationShellPages.test.tsx
  33. 156 0
      frontend/src/pages/dashboard/simulation/__tests__/StylistSimulationPage.test.tsx
  34. 81 0
      frontend/src/services/api/__tests__/simulations.test.ts
  35. 51 0
      frontend/src/services/api/contracts.ts
  36. 51 3
      frontend/src/services/api/simulations.ts

+ 36 - 19
backend/docs/数据库设计.md

@@ -98,26 +98,43 @@ CREATE TABLE `ai_hair_simulation_task` (
   KEY `idx_customer_id` (`customer_id`,`is_deleted`) COMMENT '顾客维度筛选模拟任务',
   KEY `idx_task_status` (`task_status`,`is_deleted`) COMMENT '筛选待处理/失败任务重试'
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='AI发型模拟异步任务表';
+-- auto-generated definition
+create table hair_template
+(
+    id                 bigint unsigned auto_increment comment '模板主键ID'
+        primary key,
+    template_name      varchar(100)                                                      not null comment '模板名称(必填)',
+    cover_url          varchar(500)                            default ''                null comment '模板封面图URL',
+    status             enum ('DRAFT', 'PUBLIC')                default 'DRAFT'           not null comment '模板状态:DRAFT草稿 / PUBLIC公开',
+    category           enum ('LONG', 'MEDIUM', 'SHORT', 'DYE') default 'LONG'            not null comment '分类:长发/中长发/短发/染发',
+    tags               varchar(500)                            default ''                null comment '模板标签,逗号分隔存储',
+    description        text                                                              null comment '模板详细说明',
+    model_url          varchar(500)                            default ''                null comment 'AI模特图片URL',
+    clothes_url        varchar(500)                            default ''                null comment '服装素材URL',
+    background_url     varchar(500)                            default ''                null comment '背景图片URL',
+    ref_hair_url       varchar(500)                            default ''                null comment '参考发型图片URL',
+    hairstylist_id     bigint unsigned                                                   null comment '创建发型师ID,系统模板为空',
+    template_url       varchar(500)                            default ''                null comment '完整模板预览页面URL',
+    is_system_template enum ('YES', 'NO')                      default 'NO'              not null comment '是否系统模板:YES系统模板,NO门店自定义模板',
+    create_time        datetime                                default CURRENT_TIMESTAMP not null comment '创建时间',
+    update_time        datetime                                default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '更新时间',
+    is_deleted         tinyint unsigned                        default '0'               not null comment '逻辑删除:0正常 1删除'
+)
+    comment 'AI发型模板表';
+
+create index idx_category
+    on hair_template (category, is_deleted)
+    comment '按发型分类筛选模板';
+
+create index idx_hairstylist_id
+    on hair_template (hairstylist_id, is_deleted)
+    comment '发型师自有模板筛选索引';
+
+create index idx_status
+    on hair_template (status, is_deleted)
+    comment '按模板状态筛选';
+
 
-CREATE TABLE `hair_template` (
-  `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '模板主键ID',
-  `template_name` VARCHAR(100) NOT NULL COMMENT '模板名称(必填)',
-  `cover_url` VARCHAR(500) DEFAULT '' COMMENT '模板封面图URL',
-  `status` ENUM('DRAFT','PUBLIC') NOT NULL DEFAULT 'DRAFT' COMMENT '模板状态:DRAFT草稿 / PUBLIC公开',
-  `category` ENUM('LONG','MEDIUM','SHORT','DYE') NOT NULL DEFAULT 'LONG' COMMENT '分类:长发/中长发/短发/染发',
-  `tags` VARCHAR(500) DEFAULT '' COMMENT '模板标签,逗号分隔存储',
-  `description` TEXT NULL COMMENT '模板详细说明',
-  `model_url` VARCHAR(500) DEFAULT '' COMMENT 'AI模特图片URL',
-  `clothes_url` VARCHAR(500) DEFAULT '' COMMENT '服装素材URL',
-  `background_url` VARCHAR(500) DEFAULT '' COMMENT '背景图片URL',
-  `ref_hair_url` VARCHAR(500) DEFAULT '' COMMENT '参考发型图片URL',
-  `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
-  `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
-  `is_deleted` TINYINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '逻辑删除:0正常 1删除',
-  PRIMARY KEY (`id`),
-  KEY `idx_status` (`status`,`is_deleted`) COMMENT '按模板状态筛选',
-  KEY `idx_category` (`category`,`is_deleted`) COMMENT '按发型分类筛选模板'
-) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='AI发型模板表';
 
 CREATE TABLE `hair_template_make_task` (
   `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '本地任务自增主键',

+ 39 - 15
backend/prisma/schema.prisma

@@ -21,6 +21,23 @@ enum TemplateTaskStatus {
   FAILED
 }
 
+enum HairTemplateStatus {
+  DRAFT
+  PUBLIC
+}
+
+enum HairTemplateCategory {
+  LONG
+  MEDIUM
+  SHORT
+  DYE
+}
+
+enum HairTemplateSystemFlag {
+  YES
+  NO
+}
+
 enum CustomerExpectContact {
   LINE
   PHONE
@@ -126,6 +143,7 @@ model AiHairSimulation {
   customerId     BigInt?  @map("customer_id") @db.UnsignedBigInt
   hairstylistId  BigInt   @map("hairstylist_id") @db.UnsignedBigInt
   originImage    String   @map("origin_image") @db.VarChar(500)
+  templateIds    String   @map("template_ids") @db.Text
   simulateRemark String?  @map("simulate_remark") @db.Text
   createTime     DateTime @default(now()) @map("create_time")
   updateTime     DateTime @updatedAt @map("update_time")
@@ -153,21 +171,27 @@ model AiHairSimulationTask {
 }
 
 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        String   @db.VarChar(32)
-  category      String   @db.VarChar(32)
-  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)
-  createTime    DateTime @default(now()) @map("create_time")
-  updateTime    DateTime @updatedAt @map("update_time")
-  isDeleted     Int      @default(0) @map("is_deleted") @db.UnsignedTinyInt
-
+  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")
 }
 

+ 14 - 0
backend/src/config/app.config.spec.ts

@@ -52,4 +52,18 @@ describe('appConfig storage', () => {
       'https://storage.googleapis.com/aitori-public/style/sample/066c7ba22ea149268444bc2d919d5b97.png',
     );
   });
+
+  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,
+    });
+  });
 });

+ 7 - 0
backend/src/config/app.config.ts

@@ -6,6 +6,13 @@ export default () => ({
   port: Number(process.env.PORT ?? 3000),
   appBaseUrl: process.env.APP_BASE_URL ?? 'http://localhost:3000',
   corsOrigin: process.env.CORS_ORIGIN ?? 'http://127.0.0.1:5173',
+  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),
+  },
   jwtSecret: process.env.JWT_SECRET ?? 'replace-me',
   jwtExpiresIn: process.env.JWT_EXPIRES_IN ?? '7d',
   databaseUrl:

+ 3 - 0
backend/src/config/env.schema.ts

@@ -5,6 +5,9 @@ export const envSchema = z.object({
   APP_BASE_URL: z.string().default('http://localhost:3000'),
   DATABASE_URL: z.string().default('mysql://root:root@localhost:3306/aitori_backend'),
   REDIS_URL: z.string().default('redis://localhost:6379'),
+  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),
   JWT_SECRET: z.string().default('replace-me'),
   JWT_EXPIRES_IN: z.string().default('7d'),
   CORS_ORIGIN: z.string().default('http://127.0.0.1:5173'),

+ 65 - 0
backend/src/modules/simulations/ai-simulation.client.ts

@@ -0,0 +1,65 @@
+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 };
+  }
+
+  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?: Array<string | null | undefined> | null;
+      } | null;
+    };
+
+    return {
+      status: data.status,
+      message: data.message ?? '',
+      images: data.data?.images?.filter((item): item is string => Boolean(item)) ?? [],
+    };
+  }
+}

+ 7 - 20
backend/src/modules/simulations/dto/create-simulation.dto.ts

@@ -1,32 +1,19 @@
-import {
-  ArrayMinSize,
-  IsArray,
-  IsIn,
-  IsOptional,
-  IsString,
-} from 'class-validator';
+import { ArrayMinSize, IsArray, IsOptional, IsString, IsUrl } from 'class-validator';
 
 export class CreateSimulationDto {
-  @IsIn(['selected', 'guest'])
-  customerMode!: 'selected' | 'guest';
-
   @IsOptional()
   @IsString()
   customerId?: string;
 
-  @IsString()
-  sourcePhoto!: string;
-
-  @IsIn(['hairstyle', 'aftercare'])
-  simulationType!: 'hairstyle' | 'aftercare';
+  @IsUrl()
+  originImage!: string;
 
-  @IsOptional()
   @IsArray()
   @ArrayMinSize(1)
-  templateIds?: string[];
+  @IsString({ each: true })
+  templateIds!: string[];
 
   @IsOptional()
-  @IsArray()
-  @ArrayMinSize(1)
-  aftercareTypes?: string[];
+  @IsString()
+  simulateRemark?: string;
 }

+ 8 - 0
backend/src/modules/simulations/dto/customer-simulation-gallery.dto.ts

@@ -0,0 +1,8 @@
+export type CustomerSimulationGalleryItemDto = {
+  simulationId: string;
+  taskId: string;
+  imageUrl: string;
+  templateName: string;
+  createdAt: string;
+  taskStatus: 'SUCCESSES';
+};

+ 25 - 0
backend/src/modules/simulations/dto/list-simulation-templates-query.dto.ts

@@ -0,0 +1,25 @@
+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;
+}

+ 34 - 0
backend/src/modules/simulations/dto/simulation-detail.dto.ts

@@ -0,0 +1,34 @@
+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;
+  createdAt: 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[];
+};

+ 10 - 0
backend/src/modules/simulations/dto/simulation-template-option.dto.ts

@@ -0,0 +1,10 @@
+export type SimulationTemplateOptionDto = {
+  id: string;
+  templateName: string;
+  coverUrl: string;
+  category: 'LONG' | 'MEDIUM' | 'SHORT' | 'DYE';
+  tags: string[];
+  description: string | null;
+  templateUrl: string;
+  isSystemTemplate: boolean;
+};

+ 58 - 3
backend/src/modules/simulations/simulations.controller.ts

@@ -1,13 +1,68 @@
-import { Body, Controller, Post } from '@nestjs/common';
+import {
+  Body,
+  Controller,
+  Get,
+  Param,
+  Post,
+  Query,
+  Req,
+  UseGuards,
+} from '@nestjs/common';
+import type { Request } from 'express';
+import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
 import { CreateSimulationDto } from './dto/create-simulation.dto';
+import { ListSimulationTemplateOptionsQueryDto } from './dto/list-simulation-templates-query.dto';
 import { SimulationsService } from './simulations.service';
 
+type AuthenticatedRequest = Request & {
+  user: {
+    stylistId: string | number;
+  };
+};
+
+@UseGuards(JwtAuthGuard)
 @Controller('simulations')
 export class SimulationsController {
   constructor(private readonly simulationsService: SimulationsService) {}
 
+  @Get('template-options')
+  listTemplateOptions(
+    @Req() request: AuthenticatedRequest,
+    @Query() query: ListSimulationTemplateOptionsQueryDto,
+  ) {
+    return this.simulationsService.listTemplateOptions(
+      BigInt(request.user.stylistId),
+      query,
+    );
+  }
+
   @Post()
-  create(@Body() body: CreateSimulationDto) {
-    return this.simulationsService.create(body);
+  create(@Req() request: AuthenticatedRequest, @Body() body: CreateSimulationDto) {
+    return this.simulationsService.createSimulation(
+      BigInt(request.user.stylistId),
+      body,
+    );
+  }
+
+  @Get('customer/:customerId/gallery')
+  gallery(
+    @Req() request: AuthenticatedRequest,
+    @Param('customerId') customerId: string,
+  ) {
+    return this.simulationsService.listCustomerSimulationGallery(
+      BigInt(request.user.stylistId),
+      customerId,
+    );
+  }
+
+  @Get(':simulationId')
+  detail(
+    @Req() request: AuthenticatedRequest,
+    @Param('simulationId') simulationId: string,
+  ) {
+    return this.simulationsService.getSimulationDetail(
+      BigInt(request.user.stylistId),
+      simulationId,
+    );
   }
 }

+ 6 - 1
backend/src/modules/simulations/simulations.module.ts

@@ -1,9 +1,14 @@
 import { Module } from '@nestjs/common';
+import { ConfigModule } from '@nestjs/config';
+import { PrismaModule } from '../../prisma/prisma.module';
+import { AuthModule } from '../auth/auth.module';
+import { AiSimulationClient } from './ai-simulation.client';
 import { SimulationsController } from './simulations.controller';
 import { SimulationsService } from './simulations.service';
 
 @Module({
+  imports: [AuthModule, PrismaModule, ConfigModule],
   controllers: [SimulationsController],
-  providers: [SimulationsService],
+  providers: [SimulationsService, AiSimulationClient],
 })
 export class SimulationsModule {}

+ 385 - 0
backend/src/modules/simulations/simulations.service.spec.ts

@@ -0,0 +1,385 @@
+import { ConfigService } from '@nestjs/config';
+import { Test } from '@nestjs/testing';
+import { PrismaService } from '../../prisma/prisma.service';
+import { AiSimulationClient } from './ai-simulation.client';
+import { SimulationsService } from './simulations.service';
+
+describe('SimulationsService', () => {
+  let service: SimulationsService;
+  const aiClient = {
+    execute: jest.fn(),
+    getResult: jest.fn(),
+  };
+  const configService = {
+    getOrThrow: jest.fn((key: string) => {
+      if (key === 'aiSimulation.refreshIntervalMs') {
+        return 10_000;
+      }
+
+      throw new Error(`Unexpected config key: ${key}`);
+    }),
+  };
+  const prisma = {
+    customer: {
+      findFirst: jest.fn(),
+    },
+    aiHairSimulation: {
+      findFirst: jest.fn(),
+      findMany: jest.fn(),
+    },
+    aiHairSimulationTask: {
+      findMany: jest.fn(),
+      update: jest.fn(),
+    },
+    hairTemplate: {
+      findMany: jest.fn(),
+      count: jest.fn(),
+    },
+    $transaction: jest.fn(),
+  };
+
+  beforeEach(async () => {
+    jest.resetAllMocks();
+    configService.getOrThrow.mockImplementation((key: string) => {
+      if (key === 'aiSimulation.refreshIntervalMs') {
+        return 10_000;
+      }
+
+      throw new Error(`Unexpected config key: ${key}`);
+    });
+
+    const moduleRef = await Test.createTestingModule({
+      providers: [
+        SimulationsService,
+        { provide: PrismaService, useValue: prisma },
+        { provide: AiSimulationClient, useValue: aiClient },
+        { provide: ConfigService, useValue: configService },
+      ],
+    }).compile();
+
+    service = moduleRef.get(SimulationsService);
+  });
+
+  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',
+      },
+    ]);
+    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',
+      },
+    ]);
+    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');
+  });
+
+  it('creates one simulation and one task row per selected template', async () => {
+    prisma.customer.findFirst.mockResolvedValue({ id: BigInt(5) });
+    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,
+      },
+    ]);
+    aiClient.execute
+      .mockResolvedValueOnce({ taskId: 'task-11' })
+      .mockResolvedValueOnce({ taskId: 'task-12' });
+    const createSimulationRecord = jest.fn().mockResolvedValue({ id: BigInt(91) });
+    const createTaskRecords = jest.fn().mockResolvedValue({ count: 2 });
+    prisma.$transaction.mockImplementation(async (callback: (tx: {
+      aiHairSimulation: { create: jest.Mock };
+      aiHairSimulationTask: { createMany: jest.Mock };
+    }) => Promise<{ simulationId: string }>) =>
+      callback({
+        aiHairSimulation: {
+          create: createSimulationRecord,
+        },
+        aiHairSimulationTask: {
+          createMany: createTaskRecords,
+        },
+      }),
+    );
+
+    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(createSimulationRecord).toHaveBeenCalledWith({
+      data: expect.objectContaining({
+        templateIds: JSON.stringify(['11', '12']),
+      }),
+    });
+    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('顾客不存在');
+  });
+
+  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',
+      createTime: new Date('2026-06-28T09:00:00Z'),
+    });
+    prisma.customer.findFirst.mockResolvedValue({
+      id: BigInt(5),
+      name: 'Mika',
+      avatar: 'https://cdn.example.com/mika.png',
+      phone: '090',
+      expectContact: 'LINE',
+    });
+    prisma.aiHairSimulationTask.findMany
+      .mockResolvedValueOnce([
+        {
+          id: BigInt(1),
+          templateId: '11',
+          customerId: BigInt(5),
+          taskId: 'task-11',
+          taskStatus: 'PROCESSING',
+          resultImages: null,
+          errorMsg: null,
+          taskStartTime: new Date('2026-06-28T10:00:00Z'),
+          taskFinishTime: null,
+          updateTime: new Date(Date.now() - 20_000),
+          isDeleted: 0,
+        },
+        {
+          id: BigInt(2),
+          templateId: '12',
+          customerId: BigInt(5),
+          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'),
+          isDeleted: 0,
+        },
+      ])
+      .mockResolvedValueOnce([
+        {
+          id: BigInt(1),
+          templateId: '11',
+          customerId: BigInt(5),
+          taskId: 'task-11',
+          taskStatus: 'SUCCESSES',
+          resultImages: JSON.stringify(['https://cdn.example.com/result.png']),
+          errorMsg: null,
+          taskStartTime: new Date('2026-06-28T10:00:00Z'),
+          taskFinishTime: new Date('2026-06-28T10:01:00Z'),
+          updateTime: new Date('2026-06-28T10:01:00Z'),
+          isDeleted: 0,
+        },
+        {
+          id: BigInt(2),
+          templateId: '12',
+          customerId: BigInt(5),
+          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'),
+          isDeleted: 0,
+        },
+      ]);
+    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',
+      },
+    ]);
+    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.createdAt).toBe('2026-06-28T09:00:00.000Z');
+    expect(result.overallStatus).toBe('PARTIAL_SUCCESS');
+  });
+
+  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',
+      },
+    ]);
+  });
+});

+ 451 - 4
backend/src/modules/simulations/simulations.service.ts

@@ -1,11 +1,458 @@
-import { Injectable, NotImplementedException } from '@nestjs/common';
+import {
+  BadRequestException,
+  Injectable,
+  Logger,
+  NotFoundException,
+} from '@nestjs/common';
+import { ConfigService } from '@nestjs/config';
+import { PrismaService } from '../../prisma/prisma.service';
+import { AiSimulationClient } from './ai-simulation.client';
 import { CreateSimulationDto } from './dto/create-simulation.dto';
+import { CustomerSimulationGalleryItemDto } from './dto/customer-simulation-gallery.dto';
+import { ListSimulationTemplateOptionsQueryDto } from './dto/list-simulation-templates-query.dto';
+import { AiHairSimulationDetailDto } from './dto/simulation-detail.dto';
+import { SimulationTemplateOptionDto } from './dto/simulation-template-option.dto';
 
 @Injectable()
 export class SimulationsService {
-  async create(_input: CreateSimulationDto): Promise<never> {
-    throw new NotImplementedException(
-      'Simulation creation is not implemented yet',
+  private readonly logger = new Logger(SimulationsService.name);
+
+  constructor(
+    private readonly prisma: PrismaService,
+    private readonly aiSimulationClient: AiSimulationClient,
+    private readonly configService: ConfigService,
+  ) {}
+
+  async listTemplateOptions(
+    hairstylistId: bigint,
+    query: ListSimulationTemplateOptionsQueryDto,
+  ): Promise<{
+    items: SimulationTemplateOptionDto[];
+    total: number;
+    page: number;
+    pageSize: number;
+  }> {
+    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,
+    };
+  }
+
+  async createSimulation(
+    hairstylistId: bigint,
+    dto: CreateSimulationDto,
+  ): Promise<{ simulationId: string }> {
+    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: Array<{
+      template: {
+        id: bigint;
+        templateUrl: string;
+      };
+      taskId: string;
+    }> = [];
+    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,
+          templateIds: JSON.stringify(dto.templateIds),
+          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() };
+    });
+  }
+
+  private shouldRefreshTask(updateTime: Date): boolean {
+    const refreshIntervalMs = this.configService.getOrThrow<number>(
+      'aiSimulation.refreshIntervalMs',
     );
+
+    return Date.now() - updateTime.getTime() >= refreshIntervalMs;
+  }
+
+  private parseResultImages(resultImages: string | null): string[] {
+    if (!resultImages) {
+      return [];
+    }
+
+    try {
+      const parsed = JSON.parse(resultImages) as unknown;
+      return Array.isArray(parsed)
+        ? parsed.filter((item): item is string => typeof item === 'string')
+        : [];
+    } catch {
+      return [];
+    }
+  }
+
+  private getOverallStatus(counts: {
+    totalTaskCount: number;
+    processingTaskCount: number;
+    successTaskCount: number;
+    failedTaskCount: number;
+  }): 'PROCESSING' | 'PARTIAL_SUCCESS' | 'SUCCESSES' | 'FAILED' {
+    if (counts.processingTaskCount > 0) {
+      return 'PROCESSING';
+    }
+
+    if (counts.totalTaskCount > 0 && counts.successTaskCount === counts.totalTaskCount) {
+      return 'SUCCESSES';
+    }
+
+    if (counts.successTaskCount > 0 && counts.failedTaskCount > 0) {
+      return 'PARTIAL_SUCCESS';
+    }
+
+    return 'FAILED';
+  }
+
+  async getSimulationDetail(
+    hairstylistId: bigint,
+    simulationId: string,
+  ): Promise<AiHairSimulationDetailDto> {
+    const simulation = await this.prisma.aiHairSimulation.findFirst({
+      where: {
+        id: BigInt(simulationId),
+        hairstylistId,
+        isDeleted: 0,
+      },
+    });
+
+    if (!simulation) {
+      throw new NotFoundException('模拟任务不存在');
+    }
+
+    const customer = simulation.customerId
+      ? await this.prisma.customer.findFirst({
+          where: {
+            id: simulation.customerId,
+            hairstylistId,
+            isDeleted: 0,
+          },
+          select: {
+            id: true,
+            name: true,
+            avatar: true,
+            phone: true,
+            expectContact: true,
+          },
+        })
+      : null;
+
+    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) {
+        const reason = error instanceof Error ? error.message : 'unknown error';
+        this.logger.warn(`Failed to refresh simulation task ${task.taskId}: ${reason}`);
+      }
+    }
+
+    const freshTasks = await this.prisma.aiHairSimulationTask.findMany({
+      where: {
+        simulationId: simulation.id,
+        isDeleted: 0,
+      },
+      orderBy: { id: 'asc' },
+    });
+
+    const numericTemplateIds = freshTasks
+      .map((task) => {
+        try {
+          return BigInt(task.templateId);
+        } catch {
+          return null;
+        }
+      })
+      .filter((value): value is bigint => value !== null);
+    const templates = numericTemplateIds.length
+      ? await this.prisma.hairTemplate.findMany({
+          where: {
+            id: { in: numericTemplateIds },
+            isDeleted: 0,
+          },
+        })
+      : [];
+    const templateMap = new Map(templates.map((item) => [item.id.toString(), item]));
+
+    const counts = {
+      totalTaskCount: freshTasks.length,
+      processingTaskCount: freshTasks.filter((task) => task.taskStatus === 'PROCESSING').length,
+      successTaskCount: freshTasks.filter((task) => task.taskStatus === 'SUCCESSES').length,
+      failedTaskCount: freshTasks.filter((task) => task.taskStatus === 'FAILED').length,
+    };
+
+    return {
+      id: simulation.id.toString(),
+      createdAt: simulation.createTime.toISOString(),
+      customer: customer
+        ? {
+            id: customer.id.toString(),
+            name: customer.name,
+            avatar: customer.avatar,
+            phone: customer.phone,
+            expectContact: customer.expectContact,
+          }
+        : null,
+      originImage: simulation.originImage,
+      simulateRemark: simulation.simulateRemark,
+      totalTaskCount: counts.totalTaskCount,
+      processingTaskCount: counts.processingTaskCount,
+      successTaskCount: counts.successTaskCount,
+      failedTaskCount: counts.failedTaskCount,
+      overallStatus: this.getOverallStatus(counts),
+      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',
+          templateSource: template?.isSystemTemplate === 'YES' ? 'SYSTEM' : 'STYLIST',
+          taskId: task.taskId,
+          taskStatus: task.taskStatus,
+          resultImages: this.parseResultImages(task.resultImages),
+          errorMsg: task.errorMsg,
+          taskStartTime: task.taskStartTime.toISOString(),
+          taskFinishTime: task.taskFinishTime?.toISOString() ?? null,
+        };
+      }),
+    };
+  }
+
+  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,
+        }));
+      }),
+    };
   }
 }

+ 5 - 2
backend/test/app.e2e-spec.ts

@@ -3,12 +3,11 @@ import { JwtService } from '@nestjs/jwt';
 import { Test } from '@nestjs/testing';
 import * as request from 'supertest';
 import { configureApp } from '../src/app.setup';
+import { AppModule } from '../src/app.module';
 import { PrismaService } from '../src/prisma/prisma.service';
 import { GcsStorageDriver } from '../src/modules/storage/gcs-storage.driver';
 import { STORAGE_DRIVER } from '../src/modules/storage/storage.constants';
 
-const { AppModule } = require('../src/app.module');
-
 describe('App (e2e)', () => {
   let app: INestApplication;
   let jwtService: JwtService;
@@ -228,6 +227,7 @@ describe('App (e2e)', () => {
         customerId: customer.id,
         hairstylistId: BigInt(1),
         originImage: (customer as unknown as { avatar: string }).avatar,
+        templateIds: '[]',
         simulateRemark: `app-e2e-customer-summary-${uniqueSuffix}`,
       },
     });
@@ -323,8 +323,11 @@ describe('App (e2e)', () => {
   });
 
   it('/api/v1/simulations (POST) validates create payload', async () => {
+    const token = await signToken();
+
     await request(app.getHttpServer())
       .post('/api/v1/simulations')
+      .set('Authorization', `Bearer ${token}`)
       .send({})
       .expect(400);
   });

+ 264 - 0
backend/test/simulations.e2e-spec.ts

@@ -0,0 +1,264 @@
+import { INestApplication } from '@nestjs/common';
+import { JwtService } from '@nestjs/jwt';
+import { Test } from '@nestjs/testing';
+import * as request from 'supertest';
+import { configureApp } from '../src/app.setup';
+import { AppModule } from '../src/app.module';
+import { AiSimulationClient } from '../src/modules/simulations/ai-simulation.client';
+import { PrismaService } from '../src/prisma/prisma.service';
+
+describe('Simulations (e2e)', () => {
+  let app: INestApplication;
+  let jwtService: JwtService;
+  let prisma: PrismaService;
+  const execute = jest.fn();
+  const getResult = jest.fn();
+
+  async function signToken() {
+    return jwtService.signAsync({
+      sub: 1,
+      stylistId: '1',
+      email: 'stylist@example.com',
+      username: 'Ogawa',
+    });
+  }
+
+  beforeAll(async () => {
+    execute.mockReset();
+    getResult.mockReset();
+
+    const moduleRef = await Test.createTestingModule({
+      imports: [AppModule],
+    })
+      .overrideProvider(AiSimulationClient)
+      .useValue({ execute, getResult })
+      .compile();
+
+    app = moduleRef.createNestApplication();
+    configureApp(app);
+    await app.init();
+    jwtService = app.get(JwtService);
+    prisma = app.get(PrismaService);
+  });
+
+  afterAll(async () => {
+    await prisma.aiHairSimulationTask.deleteMany({
+      where: {
+        taskId: {
+          startsWith: 'sim-e2e-task-',
+        },
+      },
+    });
+    await prisma.aiHairSimulation.deleteMany({
+      where: {
+        simulateRemark: {
+          startsWith: 'sim-e2e-',
+        },
+      },
+    });
+    await prisma.hairTemplate.deleteMany({
+      where: {
+        templateName: {
+          startsWith: 'sim-e2e-',
+        },
+      },
+    });
+    await prisma.customer.deleteMany({
+      where: {
+        name: {
+          startsWith: 'sg-',
+        },
+      },
+    });
+    await app.close();
+  });
+
+  it('/api/v1/simulations (POST) creates an anonymous simulation', async () => {
+    const token = await signToken();
+    const uniqueSuffix = Date.now();
+    const template = await prisma.hairTemplate.create({
+      data: {
+        templateName: `sim-e2e-template-${uniqueSuffix}`,
+        coverUrl: '',
+        status: 'PUBLIC',
+        category: 'SHORT',
+        tags: '',
+        description: null,
+        modelUrl: '',
+        clothesUrl: '',
+        backgroundUrl: '',
+        refHairUrl: '',
+        hairstylistId: BigInt(1),
+        templateUrl: `https://cdn.example.com/${uniqueSuffix}.zip`,
+        isSystemTemplate: 'NO',
+      } as never,
+    });
+    execute.mockResolvedValueOnce({
+      taskId: `sim-e2e-task-${uniqueSuffix}`,
+    });
+
+    const response = await request(app.getHttpServer())
+      .post('/api/v1/simulations')
+      .set('Authorization', `Bearer ${token}`)
+      .send({
+        originImage: 'https://cdn.example.com/origin.jpg',
+        templateIds: [template.id.toString()],
+        simulateRemark: `sim-e2e-${uniqueSuffix}`,
+      });
+
+    expect(response.status).toBe(201);
+
+    expect(execute).toHaveBeenCalledWith(
+      template.templateUrl,
+      'https://cdn.example.com/origin.jpg',
+    );
+    expect(response.body.data).toEqual({
+      simulationId: expect.any(String),
+    });
+  });
+
+  it('/api/v1/simulations/:id (GET) returns task detail for the owner', async () => {
+    const token = await signToken();
+    const uniqueSuffix = `${Date.now()}-detail`;
+    const template = await prisma.hairTemplate.create({
+      data: {
+        templateName: `sim-e2e-template-${uniqueSuffix}`,
+        coverUrl: 'https://cdn.example.com/cover.png',
+        status: 'PUBLIC',
+        category: 'MEDIUM',
+        tags: 'airy',
+        description: 'detail template',
+        modelUrl: '',
+        clothesUrl: '',
+        backgroundUrl: '',
+        refHairUrl: '',
+        hairstylistId: BigInt(1),
+        templateUrl: `https://cdn.example.com/${uniqueSuffix}.zip`,
+        isSystemTemplate: 'NO',
+      } as never,
+    });
+    execute.mockResolvedValueOnce({
+      taskId: `sim-e2e-task-${uniqueSuffix}`,
+    });
+
+    const created = await request(app.getHttpServer())
+      .post('/api/v1/simulations')
+      .set('Authorization', `Bearer ${token}`)
+      .send({
+        originImage: 'https://cdn.example.com/origin-detail.jpg',
+        templateIds: [template.id.toString()],
+        simulateRemark: `sim-e2e-${uniqueSuffix}`,
+      })
+      .expect(201);
+
+    const response = await request(app.getHttpServer())
+      .get(`/api/v1/simulations/${created.body.data.simulationId}`)
+      .set('Authorization', `Bearer ${token}`)
+      .expect(200);
+
+    expect(response.body.data).toMatchObject({
+      id: created.body.data.simulationId,
+      createdAt: expect.any(String),
+      originImage: 'https://cdn.example.com/origin-detail.jpg',
+      totalTaskCount: 1,
+      processingTaskCount: 1,
+      successTaskCount: 0,
+      failedTaskCount: 0,
+      overallStatus: 'PROCESSING',
+      tasks: [
+        expect.objectContaining({
+          templateId: template.id.toString(),
+          templateName: template.templateName,
+          templateCategory: template.category,
+          templateSource: 'STYLIST',
+          taskStatus: 'PROCESSING',
+          resultImages: [],
+        }),
+      ],
+    });
+  });
+
+  it('/api/v1/simulations/customer/:customerId/gallery (GET) returns flattened result cards', async () => {
+    const token = await signToken();
+    const uniqueSuffix = `${Date.now()}-gallery`;
+    const customer = await prisma.customer.create({
+      data: {
+        hairstylistId: BigInt(1),
+        name: `sg-${Date.now()}`,
+        namePronunciation: '',
+        phone: `090-${String(Date.now()).slice(-4)}-3333`,
+        email: '',
+        avatar: 'https://cdn.example.com/customer.png',
+        expectContact: 'LINE',
+        planDate: new Date(),
+        nextFollowDate: new Date(),
+      } as never,
+    });
+    const template = await prisma.hairTemplate.create({
+      data: {
+        templateName: `sim-e2e-template-${uniqueSuffix}`,
+        coverUrl: 'https://cdn.example.com/cover.png',
+        status: 'PUBLIC',
+        category: 'SHORT',
+        tags: 'airy',
+        description: 'gallery template',
+        modelUrl: '',
+        clothesUrl: '',
+        backgroundUrl: '',
+        refHairUrl: '',
+        hairstylistId: BigInt(1),
+        templateUrl: `https://cdn.example.com/${uniqueSuffix}.zip`,
+        isSystemTemplate: 'NO',
+      } as never,
+    });
+    execute.mockResolvedValueOnce({
+      taskId: `sim-e2e-task-${uniqueSuffix}`,
+    });
+
+    const created = await request(app.getHttpServer())
+      .post('/api/v1/simulations')
+      .set('Authorization', `Bearer ${token}`)
+      .send({
+        customerId: customer.id.toString(),
+        originImage: 'https://cdn.example.com/origin-gallery.jpg',
+        templateIds: [template.id.toString()],
+        simulateRemark: `sim-e2e-${uniqueSuffix}`,
+      })
+      .expect(201);
+
+    await prisma.aiHairSimulationTask.updateMany({
+      where: {
+        simulationId: BigInt(created.body.data.simulationId),
+      },
+      data: {
+        taskStatus: 'SUCCESSES',
+        resultImages: JSON.stringify([
+          'https://cdn.example.com/gallery-2.png',
+          'https://cdn.example.com/gallery-1.png',
+        ]),
+      } as never,
+    });
+
+    const response = await request(app.getHttpServer())
+      .get(`/api/v1/simulations/customer/${customer.id}/gallery`)
+      .set('Authorization', `Bearer ${token}`);
+
+    expect(response.status).toBe(200);
+    expect(response.body.data.items).toEqual(
+      expect.arrayContaining([
+        expect.objectContaining({
+          simulationId: created.body.data.simulationId,
+          imageUrl: 'https://cdn.example.com/gallery-2.png',
+          templateName: template.templateName,
+          taskStatus: 'SUCCESSES',
+        }),
+        expect.objectContaining({
+          simulationId: created.body.data.simulationId,
+          imageUrl: 'https://cdn.example.com/gallery-1.png',
+          templateName: template.templateName,
+          taskStatus: 'SUCCESSES',
+        }),
+      ]),
+    );
+  });
+});

+ 198 - 0
docs/superpowers/specs/2026-06-28-customer-detail-simulation-gallery-design.md

@@ -0,0 +1,198 @@
+# 顾客详情页 AI 模拟结果卡片流设计
+
+## 目标
+
+在顾客详情页的“过往照片和AI发型模拟”区块中,替换当前未开发占位卡片,展示该顾客历史 AI 发型模拟生成结果。每一张生成图对应一张卡片,按创建时间倒序展示,点击卡片进入对应任务详情页。
+
+## 背景
+
+- 当前顾客详情页中,“过往照片和AI发型模拟”区块仍是静态占位,不承载真实业务结果。
+- AI 发型模拟主流程已经具备创建任务、查询任务详情、展示子任务结果图的能力。
+- 用户要求在顾客详情页直接看到“刚刚任务的结果”,并明确要求:
+  - 一个图片就是一个任务卡片
+  - 按时间顺序全部展示
+  - 点击图片卡片进入对应任务详情页
+  - 排序采用最新在前
+
+## 范围
+
+### 本次包含
+
+- 新增顾客维度的 AI 模拟结果卡片列表接口
+- 顾客详情页接入真实结果卡片流
+- 每张生成图展开为一张独立卡片
+- 点击卡片跳转到对应模拟任务详情页
+- 空状态展示
+
+### 本次不包含
+
+- 分页
+- 筛选
+- 失败任务和处理中任务的卡片展示
+- 在顾客详情页直接轮询任务状态
+- 任务详情页结构变更
+
+## 交互设计
+
+### 区块位置
+
+继续使用顾客详情页现有“过往照片和AI发型模拟”区块,不改变整体页面骨架、标题和右上角“AI发型模拟”入口按钮位置。
+
+### 卡片规则
+
+- 一张生成图展开为一张卡片
+- 一个任务下如果有多张生成图,则展开成多张卡片
+- 同一个 `simulationId` 下出现多张卡片属于预期行为
+- 卡片至少展示:
+  - 生成图
+  - 模板名称
+  - 生成时间
+
+### 点击行为
+
+- 点击任意卡片,跳转至 `/:locale/simulation/result/:simulationId`
+- 不跳到子任务维度页面,继续复用现有任务详情页
+
+### 排序
+
+- 按模拟任务创建时间倒序排列
+- 最新的结果卡片排在最前面
+
+### 空状态
+
+- 当顾客还没有任何成功生成的 AI 模拟结果时,不显示假卡片
+- 使用清晰的空状态文案提示当前暂无 AI 模拟结果
+
+## 后端设计
+
+### 新接口
+
+- 路由:`GET /api/v1/simulations/customer/:customerId/gallery`
+- 权限:仅允许当前登录发型师查看自己名下顾客的数据
+
+### 返回结构
+
+返回扁平化卡片列表,而不是主任务嵌套结构。每条记录代表一张生成图卡片。
+
+建议 DTO:
+
+```ts
+export type CustomerSimulationGalleryItemDto = {
+  simulationId: string;
+  taskId: string;
+  imageUrl: string;
+  templateName: string;
+  createdAt: string;
+  taskStatus: 'SUCCESSES';
+};
+```
+
+响应示例:
+
+```json
+{
+  "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"
+    }
+  ]
+}
+```
+
+### 查询规则
+
+- 只查询当前发型师名下的顾客
+- 只查询该顾客关联的 `ai_hair_simulation`
+- 只展开 `ai_hair_simulation_task` 中 `task_status = 'SUCCESSES'` 的任务
+- 从 `result_images` 中展开真实图片列表
+- 每张图片映射为一条扁平记录
+- 按所属模拟任务 `create_time` 倒序输出
+
+### 数据来源
+
+- 主表:`ai_hair_simulation`
+- 子表:`ai_hair_simulation_task`
+- 模板名来自 `hair_template.template_name`
+
+### 安全约束
+
+- 必须校验 `customerId` 属于当前发型师
+- 不允许跨发型师读取顾客结果
+
+## 前端设计
+
+### API 层
+
+在 `frontend/src/services/api/simulations.ts` 中新增顾客详情页所需的 gallery 查询方法,并在 `contracts.ts` 中补齐对应类型。
+
+建议签名:
+
+```ts
+export async function listCustomerSimulationGallery(
+  customerId: string,
+): Promise<{ items: CustomerSimulationGalleryItem[] }>
+```
+
+### 顾客详情页
+
+在 `CustomerDetailPage.tsx` 中替换原有 `photoPlaceholders` 占位卡片渲染逻辑:
+
+- 页面加载后并行请求:
+  - 顾客详情
+  - 顾客 AI 模拟结果卡片列表
+- 卡片区改为真实图片网格
+- 每张卡片使用 `Link`
+- 跳转目标为 `path(\`/simulation/result/${simulationId}\`)`
+
+### 展示字段
+
+每张卡片展示:
+
+- 主图:`imageUrl`
+- 标题:`templateName`
+- 辅助时间:`createdAt` 的格式化日期时间
+
+## 边界与异常
+
+- 如果接口失败:
+  - 顾客详情主体仍按现有策略处理
+  - AI 模拟图片区块显示错误或空状态,而不是整页崩溃
+- 如果某个成功任务的 `result_images` 为空数组:
+  - 该任务不生成卡片
+- 如果模板被删除:
+  - 模板名称允许回退为默认文案,但不阻塞图片展示
+
+## 测试策略
+
+### 后端
+
+- service 单测:
+  - 顾客归属校验
+  - 仅展开成功任务
+  - 一个任务多图展开为多条记录
+  - 倒序输出
+- e2e:
+  - 返回当前顾客 gallery
+  - 非本人顾客不可访问
+
+### 前端
+
+- API 测试:
+  - 请求路径正确
+- 页面测试:
+  - 成功渲染真实卡片
+  - 空状态渲染
+  - 点击卡片跳转到结果详情页
+
+## 验收标准
+
+- 顾客详情页“过往照片和AI发型模拟”区块展示真实 AI 模拟结果卡片
+- 一张生成图对应一张卡片
+- 最新结果排在最前
+- 点击卡片进入对应任务详情页
+- 页面整体 UI 骨架不被破坏

+ 132 - 0
frontend/public/css/aitori.css

@@ -617,6 +617,19 @@ button.customer-pager-link {
     color: var(--dark-gray);
     font-size: 15px;
 }
+.customer-photo-empty {
+    grid-column: 1 / -1;
+    min-height: 220px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    border: 1px dashed rgba(44, 46, 60, 0.14);
+    border-radius: 8px;
+    background-color: #fbfbfc;
+    color: var(--medium-gray);
+    font-size: 15px;
+    font-weight: 700;
+}
 .customer-detail-cta {
     display: flex;
     align-items: center;
@@ -1052,6 +1065,41 @@ textarea.customer-edit-control {
     gap: 24px;
     align-items: start;
 }
+.simulation-section-badge {
+    display: inline-flex;
+    align-items: center;
+    justify-content: center;
+    min-height: 28px;
+    padding: 0 12px;
+    border-radius: 999px;
+    background-color: rgba(184, 125, 242, 0.12);
+    color: #b87df2;
+    font-size: 12px;
+    font-weight: 800;
+    letter-spacing: 0.02em;
+}
+.simulation-section-badge-text {
+    display: inline-block;
+    min-height: 0;
+    padding: 0;
+    border-radius: 0;
+    background-color: transparent;
+    color: var(--medium-gray);
+    font-size: 14px;
+    font-weight: 700;
+    letter-spacing: 0;
+}
+.simulation-section-badge-accent {
+    color: #b87df2;
+}
+.simulation-section-title {
+    margin: 14px 0 20px;
+    color: var(--dark-gray);
+    font-size: 24px;
+    font-weight: 800;
+    line-height: 1.2;
+    letter-spacing: -0.02em;
+}
 .simulation-original-card {
     overflow: hidden;
     border-radius: 8px;
@@ -1063,11 +1111,68 @@ textarea.customer-edit-control {
     object-fit: cover;
     display: block;
 }
+.simulation-result-sidebar-body {
+    display: grid;
+    gap: 20px;
+    margin-top: 20px;
+}
+.simulation-result-meta-list {
+    display: grid;
+    gap: 0;
+}
+.simulation-result-meta-list div {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    gap: 16px;
+    min-height: 54px;
+    border-bottom: 1px solid rgba(44, 46, 60, 0.08);
+}
+.simulation-result-meta-list div:first-child {
+    border-top: 1px solid rgba(44, 46, 60, 0.08);
+}
+.simulation-result-meta-list strong {
+    color: var(--dark-gray);
+    font-size: 16px;
+    font-weight: 800;
+}
+.simulation-result-meta-list span {
+    color: var(--medium-gray);
+    font-size: 14px;
+    font-weight: 700;
+}
 .simulation-result-grid {
     display: grid;
     grid-template-columns: repeat(2, minmax(0, 1fr));
     gap: 20px;
 }
+.simulation-results-header {
+    display: flex;
+    align-items: flex-start;
+    justify-content: space-between;
+    gap: 16px;
+    min-height: 92px;
+    margin-bottom: 24px;
+}
+.simulation-results-header > div {
+    width: 100%;
+}
+.simulation-results-header-static {
+    justify-content: flex-start;
+}
+.simulation-results-count {
+    display: inline-flex;
+    align-items: center;
+    justify-content: center;
+    min-height: 32px;
+    padding: 0 14px;
+    border-radius: 999px;
+    background-color: rgba(184, 125, 242, 0.12);
+    color: #b87df2;
+    font-size: 13px;
+    font-weight: 800;
+    white-space: nowrap;
+}
 .simulation-result-card {
     position: relative;
     overflow: hidden;
@@ -1108,6 +1213,29 @@ textarea.customer-edit-control {
 .simulation-result-body {
     padding: 18px;
 }
+.simulation-result-card-title-row {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    gap: 8px;
+}
+.simulation-result-card-title-row h3 {
+    margin: 0;
+}
+.simulation-result-card-tag {
+    display: inline-flex;
+    align-items: center;
+    justify-content: center;
+    min-height: 28px;
+    padding: 0 12px;
+    border-radius: 999px;
+    background-color: rgba(245, 126, 72, 0.12);
+    color: var(--base-color);
+    font-size: 12px;
+    font-weight: 800;
+    letter-spacing: 0.02em;
+    white-space: nowrap;
+}
 .simulation-result-body h3 {
     margin: 12px 0 8px;
     color: var(--dark-gray);
@@ -1671,6 +1799,10 @@ footer ul li {
     .simulation-tune-grid {
         grid-template-columns: repeat(2, minmax(0, 1fr));
     }
+    .simulation-results-header {
+        flex-direction: column;
+        align-items: flex-start;
+    }
     .simulation-preset-grid {
         grid-template-columns: repeat(2, minmax(0, 1fr));
     }

+ 1 - 0
frontend/src/App.tsx

@@ -34,6 +34,7 @@ function App() {
         <Route path={routes.templateNew} element={<RequireAuth><TemplateFormPage /></RequireAuth>} />
         <Route path={routes.templateDetail} element={<RequireAuth><TemplateDetailPage /></RequireAuth>} />
         <Route path={routes.simulation} element={<RequireAuth><StylistSimulationPage /></RequireAuth>} />
+        <Route path={routes.simulationResultDetail} element={<RequireAuth><SimulationResultPage /></RequireAuth>} />
         <Route path={routes.simulationResult} element={<RequireAuth><SimulationResultPage /></RequireAuth>} />
         <Route path={routes.simulationEdit} element={<RequireAuth><SimulationEditPage /></RequireAuth>} />
         <Route path={routes.simulationAftercare} element={<RequireAuth><AftercareResultPage /></RequireAuth>} />

+ 10 - 0
frontend/src/__tests__/App.test.tsx

@@ -90,4 +90,14 @@ describe('App customer routes', () => {
       expect(screen.getByText('customer-form-new')).toBeInTheDocument()
     })
   })
+
+  it('renders simulation result detail route with simulationId param', async () => {
+    window.history.replaceState({}, '', '/zh/simulation/result/91')
+
+    render(<App />)
+
+    await waitFor(() => {
+      expect(screen.getByText('simulation-result-page')).toBeInTheDocument()
+    })
+  })
 })

+ 1 - 0
frontend/src/config/routes.ts

@@ -12,6 +12,7 @@ export const routes = {
   templateDetail: '/:locale/templates/:templateId',
   simulation: '/:locale/simulation',
   simulationResult: '/:locale/simulation/result',
+  simulationResultDetail: '/:locale/simulation/result/:simulationId',
   simulationEdit: '/:locale/simulation/edit',
   simulationAftercare: '/:locale/simulation/aftercare',
   profile: '/:locale/profile',

+ 64 - 10
frontend/src/i18n/locales/ja/translation.json

@@ -387,11 +387,16 @@
   },
   "simulationEditPage": {
     "title": "AIヘアシミュレーション微調整",
-    "description": "Aitori Stylist の AI ヘアシミュレーション微調整ページ。選択した結果に対してカラー、長さ、ボリューム、前髪を再調整できます。",
-    "back": "結果へ戻る",
+    "description": "Aitori Stylist の AI ヘアシミュレーション微調整ページ。現在のバージョンでは未開発シェルのみを残し、実際の微調整フローは後続 Case で実装します。",
+    "back": "AIヘアシミュレーションへ戻る",
     "generate": "生成",
     "badge": "微調整",
-    "lead": "選択した AI ヘアシミュレーション結果をもとに、カラー、長さ、ボリューム、前髪を調整します。必要に応じて写真の差し替えも行えます。",
+    "lead": "微調整機能は現在のバージョンでは未開発です。今回は AI ヘアシミュレーションの主フローのみを提供し、後続で独立した微調整機能を追加します。",
+    "state": {
+      "title": "現在のバージョンでは未開発です",
+      "description": "AI ヘアシミュレーションの主フローへ戻って、作成と結果確認を続けてください。微調整機能は後続 Case で別途実装します。",
+      "action": "AIヘアシミュレーションへ戻る"
+    },
     "current": {
       "badge": "現在の結果",
       "title": "選択中の結果",
@@ -582,6 +587,7 @@
     "photos": {
       "badge": "写真とシミュレーション",
       "title": "過去写真とAIヘアシミュレーション",
+      "empty": "AIヘアシミュレーション結果はまだありません",
       "items": [
         { "label": "来店前", "title": "来店時記録", "alt": "来店時の写真" },
         { "label": "AIヘアシミュレーション", "title": "AIヘアシミュレーション結果", "alt": "AIヘアシミュレーション結果" },
@@ -602,6 +608,23 @@
     "regenerate": "再生成",
     "badge": "生成結果",
     "lead": "選択したテンプレートを顧客写真に適用した結果を表示します。各案を微調整したり、今回の施術記録へ保存できます。",
+    "anonymousTitle": "匿名シミュレーション",
+    "anonymousLead": "このタスクは顧客カルテに紐付いていません。",
+    "counters": {
+      "total": "テンプレート総数",
+      "processing": "処理中",
+      "success": "成功",
+      "failed": "失敗"
+    },
+    "source": {
+      "stylist": "マイテンプレート",
+      "system": "システムテンプレート"
+    },
+    "card": {
+      "processing": "生成中です。しばらくしてからこの案の結果をご確認ください。",
+      "success": "生成が完了しました。この案の仕上がりを確認できます。",
+      "failed": "生成に失敗しました。戻って再試行してください。"
+    },
     "state": {
       "pending": {
         "badge": "タスク状態",
@@ -625,16 +648,16 @@
       }
     },
     "original": {
-      "badge": "元画像",
-      "title": "元画像",
+      "badge": "顧客写真",
+      "title": "今回の仕上がり",
       "customer": "顧客",
       "generatedAt": "生成日時",
       "count": "生成数",
       "countValue": "{{count}}案"
     },
     "results": {
-      "badge": "生成結果",
-      "title": "生成結果 {{count}}案",
+      "badge": "未来変化",
+      "title": "施術後変化予測結果",
       "count": "{{count}}案",
       "templatePrefix": "テンプレート",
       "adjust": "微調整",
@@ -643,12 +666,17 @@
   },
   "aftercareResultPage": {
     "title": "施術後変化予測結果",
-    "description": "Aitori Stylist の施術後変化予測結果ページ。自宅での状態、30日後、60日後の変化を確認し、次回来店提案を保存できます。",
-    "back": "条件を修正",
+    "description": "Aitori Stylist の施術後変化予測結果ページ。現在のバージョンでは未開発シェルのみを残し、実際の aftercare フローは後続 Case で実装します。",
+    "back": "AIヘアシミュレーションへ戻る",
     "regenerate": "再生成",
     "badge": "施術後変化予測",
     "heading": "次回来店提案",
-    "lead": "施術後の変化予測、次回来店の推奨日、提案メニューを確認します。必要に応じて予約状況や日付を調整できます。",
+    "lead": "aftercare 機能は現在のバージョンでは未開発です。今回は AI ヘアシミュレーションの主フローのみを提供し、後続で術後変化予測と提案機能を追加します。",
+    "state": {
+      "title": "現在のバージョンでは未開発です",
+      "description": "AI ヘアシミュレーションの主フローへ戻って操作を続けてください。aftercare 機能は後続 Case で別途実装します。",
+      "action": "AIヘアシミュレーションへ戻る"
+    },
     "original": {
       "badge": "顧客写真",
       "title": "今回の仕上がり",
@@ -684,6 +712,32 @@
       "back": "戻る"
     }
   },
+  "simulationCreatePage": {
+    "title": "AIヘアシミュレーション",
+    "description": "Aitori Stylist の AI ヘアシミュレーションページ。顧客紐付け、元画像アップロード、テンプレート選択でタスクを作成します。",
+    "badge": "AIヘアシミュレーション",
+    "lead": "顧客の元画像をアップロードし、テンプレートを選択して AI ヘアシミュレーションを開始します。",
+    "back": "ホームへ戻る",
+    "submit": "シミュレーション開始",
+    "bindCustomer": "顧客を紐付ける",
+    "noCustomer": "顧客を紐付けない",
+    "customerLabel": "顧客",
+    "customerPlaceholder": "顧客を選択してください",
+    "upload": "顧客写真をアップロード",
+    "uploadHint": "JPG / PNG に対応",
+    "remark": "シミュレーションメモ",
+    "remarkPlaceholder": "今回の要望を任意で入力",
+    "myTemplates": "マイテンプレート",
+    "systemTemplates": "システムテンプレート",
+    "selectedCount": "選択済みテンプレート {{count}} 件",
+    "emptyTemplates": "テンプレートがありません",
+    "errors": {
+      "originRequired": "先に顧客写真をアップロードしてください",
+      "templateRequired": "テンプレートを1件以上選択してください",
+      "customerRequired": "顧客を選択してください",
+      "submitFailed": "シミュレーション開始に失敗しました。時間をおいて再試行してください"
+    }
+  },
   "stylistSimulationPage": {
     "title": "AIヘアシミュレーション",
     "description": "Aitori Stylist の AI ヘアシミュレーションページ。顧客情報、写真、テンプレート、または施術後変化予測を選んで結果を生成します。",

+ 64 - 10
frontend/src/i18n/locales/zh/translation.json

@@ -387,11 +387,16 @@
   },
   "simulationEditPage": {
     "title": "AI发型模拟微调",
-    "description": "Aitori Stylist AI发型模拟微调页面。美发师可基于已选结果调整发色、长度、发量和刘海,也可重新上传顾客照片后再次生成。",
-    "back": "返回结果",
+    "description": "Aitori Stylist AI发型模拟微调页面。当前版本仅保留未开发壳页面,后续 Case 再接入真实微调流程。",
+    "back": "返回AI发型模拟",
     "generate": "生成",
     "badge": "微调",
-    "lead": "基于已选择的 AI 发型模拟结果,调整发色、长度、发量和刘海。必要时也可重新上传顾客照片后再次生成。",
+    "lead": "微调功能当前版本暂未开发。本次仅交付 AI 发型模拟主流程,后续再补充独立微调能力。",
+    "state": {
+      "title": "当前版本暂未开发",
+      "description": "请返回 AI 发型模拟主流程继续创建和查看任务结果。微调能力将在后续 Case 中单独实现。",
+      "action": "返回AI发型模拟"
+    },
     "current": {
       "badge": "当前结果",
       "title": "当前选中结果",
@@ -582,6 +587,7 @@
     "photos": {
       "badge": "照片与模拟",
       "title": "过往照片和AI发型模拟",
+      "empty": "暂无 AI 发型模拟结果",
       "items": [
         { "label": "到店前", "title": "到店时记录", "alt": "到店时的照片" },
         { "label": "AI发型模拟", "title": "AI发型模拟结果", "alt": "AI发型模拟结果" },
@@ -602,6 +608,23 @@
     "regenerate": "重新生成",
     "badge": "生成结果",
     "lead": "展示顾客照片套用所选发型模板后的生成结果。可进入各方案微调,或保存到本次施术记录。",
+    "anonymousTitle": "匿名模拟",
+    "anonymousLead": "当前任务未绑定顾客档案。",
+    "counters": {
+      "total": "总模板数",
+      "processing": "处理中",
+      "success": "成功",
+      "failed": "失败"
+    },
+    "source": {
+      "stylist": "我的模板",
+      "system": "系统模板"
+    },
+    "card": {
+      "processing": "正在生成中,请稍候查看当前方案效果。",
+      "success": "已完成生成,可查看当前方案效果。",
+      "failed": "生成失败,请返回后重试。"
+    },
     "state": {
       "pending": {
         "badge": "任务状态",
@@ -625,16 +648,16 @@
       }
     },
     "original": {
-      "badge": "原图",
-      "title": "原图",
+      "badge": "顾客照片",
+      "title": "本次完成效果",
       "customer": "顾客",
       "generatedAt": "生成时间",
       "count": "生成数量",
       "countValue": "{{count}}个方案"
     },
     "results": {
-      "badge": "生成结果",
-      "title": "生成结果 {{count}}个方案",
+      "badge": "未来变化",
+      "title": "剪发后变化预测结果",
       "count": "{{count}}个方案",
       "templatePrefix": "模板",
       "adjust": "微调",
@@ -643,12 +666,17 @@
   },
   "aftercareResultPage": {
     "title": "剪发后变化预测结果",
-    "description": "Aitori Stylist剪发后变化预测结果页面。确认顾客居家造型、30天后和60天后的发型变化,并保存下次到店建议、推荐施术和预约状态。",
-    "back": "修改条件",
+    "description": "Aitori Stylist 剪发后变化预测结果页面。当前版本仅保留未开发壳页面,后续 Case 再接入真实 aftercare 流程。",
+    "back": "返回AI发型模拟",
     "regenerate": "重新生成",
     "badge": "剪发后变化预测",
     "heading": "下次到店建议",
-    "lead": "确认剪发后的变化预测、下次到店推荐日期和建议项目。可根据顾客日程手动调整预约状态和建议日期。",
+    "lead": "aftercare 功能当前版本暂未开发。本次仅交付 AI 发型模拟主流程,后续再补充术后变化预测与建议。",
+    "state": {
+      "title": "当前版本暂未开发",
+      "description": "请返回 AI 发型模拟主流程继续操作。aftercare 能力将在后续 Case 中单独实现。",
+      "action": "返回AI发型模拟"
+    },
     "original": {
       "badge": "顾客照片",
       "title": "本次完成效果",
@@ -684,6 +712,32 @@
       "back": "返回"
     }
   },
+  "simulationCreatePage": {
+    "title": "AI发型模拟",
+    "description": "Aitori Stylist AI发型模拟页面。可选绑定顾客、上传原图并选择发型模板发起模拟任务。",
+    "badge": "AI发型模拟",
+    "lead": "上传顾客原图并选择模板后,即可创建新的 AI 发型模拟任务。",
+    "back": "返回首页",
+    "submit": "发起模拟",
+    "bindCustomer": "绑定顾客",
+    "noCustomer": "不绑定顾客",
+    "customerLabel": "顾客",
+    "customerPlaceholder": "请选择顾客",
+    "upload": "上传顾客照片",
+    "uploadHint": "支持 JPG / PNG",
+    "remark": "模拟备注",
+    "remarkPlaceholder": "可选填写本次模拟偏好",
+    "myTemplates": "我的模板",
+    "systemTemplates": "系统模板",
+    "selectedCount": "已选 {{count}} 个模板",
+    "emptyTemplates": "暂无模板",
+    "errors": {
+      "originRequired": "请先上传顾客照片",
+      "templateRequired": "请至少选择一个模板",
+      "customerRequired": "请选择顾客",
+      "submitFailed": "发起模拟失败,请稍后重试"
+    }
+  },
   "stylistSimulationPage": {
     "title": "AI发型模拟",
     "description": "Aitori Stylist AI发型模拟页面。选择顾客信息、上传顾客照片、选择发型模板或剪发后变化预测,即可生成模拟结果。",

+ 53 - 17
frontend/src/pages/dashboard/customers/CustomerDetailPage.tsx

@@ -2,6 +2,8 @@ import { useEffect, useState } from 'react'
 import { Link, useParams } from 'react-router-dom'
 import { Helmet } from 'react-helmet-async'
 import { getCustomerById, type CustomerDetailEntity } from '../../../services/api/customers'
+import type { CustomerSimulationGalleryItem } from '../../../services/api/contracts'
+import { listCustomerSimulationGallery } from '../../../services/api/simulations'
 import { usePageContext } from '../../../shared/pageContext'
 import { DashboardLayout } from '../../../layouts/DashboardLayout'
 
@@ -9,21 +11,45 @@ function getDisplayValue(value?: string) {
   return value?.trim() ? value : '—'
 }
 
-type PhotoPlaceholderItem = {
-  label: string
-  title: string
-  alt: string
+function formatDisplayDateTime(value?: string, locale?: string) {
+  if (!value) return '—'
+
+  const date = new Date(value)
+  if (Number.isNaN(date.getTime())) {
+    return value
+  }
+
+  return new Intl.DateTimeFormat(locale === 'ja' ? 'ja-JP' : 'zh-CN', {
+    year: 'numeric',
+    month: '2-digit',
+    day: '2-digit',
+    hour: '2-digit',
+    minute: '2-digit',
+  }).format(date)
 }
 
 export function CustomerDetailPage() {
   const { locale, path, copy } = usePageContext()
   const { customerId } = useParams()
   const [customer, setCustomer] = useState<CustomerDetailEntity | null>(null)
+  const [galleryItems, setGalleryItems] = useState<CustomerSimulationGalleryItem[]>([])
   const pageCopy = copy.customerDetailPage
 
   useEffect(() => {
     if (!customerId) return
-    getCustomerById(customerId).then(setCustomer).catch(() => setCustomer(null))
+
+    Promise.all([
+      getCustomerById(customerId),
+      listCustomerSimulationGallery(customerId),
+    ])
+      .then(([customerData, galleryData]) => {
+        setCustomer(customerData)
+        setGalleryItems(galleryData.items)
+      })
+      .catch(() => {
+        setCustomer(null)
+        setGalleryItems([])
+      })
   }, [customerId])
 
   if (!customer) {
@@ -40,7 +66,6 @@ export function CustomerDetailPage() {
     getDisplayValue(customer.notice) === '—' ? pageCopy.basic.fallbacks[3] : getDisplayValue(customer.notice),
   ]
   const allergyValue = getDisplayValue(customer.allergy)
-  const photoPlaceholders = (pageCopy.photos.items ?? []) as PhotoPlaceholderItem[]
   const followContentValue = customer.followContent?.trim() ? customer.followContent : (pageCopy.states.empty || '未填写')
 
   return (
@@ -61,9 +86,9 @@ export function CustomerDetailPage() {
               <Link to={path(`/customers/${customer.id}/edit`)} className="btn btn-large btn-rounded btn-transparent-light-gray border border-1 border-color-dark-gray fw-700">
                 <i className="feather icon-feather-edit-3 me-8px"></i>{pageCopy.edit}
               </Link>
-              <button type="button" disabled className="btn btn-large btn-rounded btn-base-color btn-box-shadow fw-700 customer-detail-start-btn">
+              <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}
-              </button>
+              </Link>
             </div>
           </div>
 
@@ -154,19 +179,30 @@ export function CustomerDetailPage() {
                 <span className="fs-14 fw-700 text-base-color text-uppercase">{pageCopy.photos.badge}</span>
                 <h2 className="fw-700 text-dark-gray fs-26 mb-0">{pageCopy.photos.title}</h2>
               </div>
-              <button type="button" disabled className="btn btn-small btn-rounded btn-base-color fw-700">
+              <Link to={path(`/simulation?customerId=${customer.id}`)} className="btn btn-small btn-rounded btn-base-color fw-700">
                 <i className="feather icon-feather-camera me-6px"></i>{pageCopy.simulate}
-              </button>
+              </Link>
             </div>
             <div className="customer-photo-grid">
-              {photoPlaceholders.map((item, index) => (
-                <div key={`${item.label}-${index}`} className="customer-photo-card">
-                  <div className="customer-photo-placeholder">
-                    <span>{pageCopy.states.undeveloped}</span>
-                  </div>
-                  <div><span>{item.label}</span><strong>{item.title}</strong></div>
+              {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} />
+                    <div>
+                      <span>{formatDisplayDateTime(item.createdAt, locale)}</span>
+                      <strong>{item.templateName}</strong>
+                    </div>
+                  </Link>
+                ))
+              ) : (
+                <div className="customer-photo-empty">
+                  <span>{pageCopy.photos.empty || '暂无 AI 发型模拟结果'}</span>
                 </div>
-              ))}
+              )}
             </div>
           </div>
         </div>

+ 84 - 1
frontend/src/pages/dashboard/customers/__tests__/CustomerDetailPage.test.tsx

@@ -5,11 +5,16 @@ import { vi, describe, it, expect } from 'vitest'
 import { CustomerDetailPage } from '../CustomerDetailPage'
 
 const getCustomerById = vi.fn()
+const listCustomerSimulationGallery = vi.fn()
 
 vi.mock('../../../../services/api/customers', () => ({
   getCustomerById: (...args: unknown[]) => getCustomerById(...args),
 }))
 
+vi.mock('../../../../services/api/simulations', () => ({
+  listCustomerSimulationGallery: (...args: unknown[]) => listCustomerSimulationGallery(...args),
+}))
+
 vi.mock('../../../../shared/pageContext', async () => {
   const actual = await vi.importActual<typeof import('../../../../shared/pageContext')>('../../../../shared/pageContext')
   return {
@@ -40,6 +45,7 @@ vi.mock('../../../../shared/pageContext', async () => {
           photos: {
             badge: '照片与模拟',
             title: '过往照片和AI发型模拟',
+            empty: '暂无 AI 发型模拟结果',
             items: [
               { label: '到店前', title: '到店时记录', alt: '到店时的照片' },
               { label: 'AI发型模拟', title: 'AI发型模拟结果', alt: 'AI发型模拟结果' },
@@ -84,6 +90,9 @@ describe('CustomerDetailPage', () => {
       avatar: 'https://example.com/avatar.png',
       visitFrequencyLabel: '约 2 个月',
     })
+    listCustomerSimulationGallery.mockResolvedValue({
+      items: [],
+    })
 
     render(
       <HelmetProvider>
@@ -106,7 +115,8 @@ describe('CustomerDetailPage', () => {
     expect(nextVisitCard).not.toBeNull()
     expect(within(nextVisitCard as HTMLElement).queryByText('当前版本暂未开发')).not.toBeInTheDocument()
     expect(screen.getByRole('button', { name: '发送提醒(未开发)' })).toBeDisabled()
-    expect(screen.getAllByRole('button', { name: 'AI发型模拟' }).length).toBeGreaterThan(0)
+    expect(screen.getAllByRole('link', { name: 'AI发型模拟' }).length).toBeGreaterThan(0)
+    expect(screen.getByText('暂无 AI 发型模拟结果')).toBeInTheDocument()
   })
 
   it('renders 未填写 when followContent is empty', async () => {
@@ -119,6 +129,9 @@ describe('CustomerDetailPage', () => {
       followContent: '',
       visitFrequencyLabel: '未记录',
     })
+    listCustomerSimulationGallery.mockResolvedValue({
+      items: [],
+    })
 
     render(
       <HelmetProvider>
@@ -136,4 +149,74 @@ describe('CustomerDetailPage', () => {
 
     expect(screen.getByText('未填写')).toBeInTheDocument()
   })
+
+  it('navigates to simulation create page with customerId', 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: [],
+    })
+
+    render(
+      <HelmetProvider>
+        <MemoryRouter initialEntries={['/zh/customers/5']}>
+          <Routes>
+            <Route path="/:locale/customers/:customerId" element={<CustomerDetailPage />} />
+          </Routes>
+        </MemoryRouter>
+      </HelmetProvider>,
+    )
+
+    await waitFor(() => {
+      expect(screen.getByText('Mika')).toBeInTheDocument()
+    })
+
+    expect(
+      screen.getAllByRole('link', { name: 'AI发型模拟' })[0],
+    ).toHaveAttribute('href', '/zh/simulation?customerId=5')
+  })
+
+  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',
+        },
+      ],
+    })
+
+    render(
+      <HelmetProvider>
+        <MemoryRouter initialEntries={['/zh/customers/5']}>
+          <Routes>
+            <Route path="/:locale/customers/:customerId" element={<CustomerDetailPage />} />
+          </Routes>
+        </MemoryRouter>
+      </HelmetProvider>,
+    )
+
+    const card = await screen.findByRole('link', { name: /Layer Bob/i })
+    expect(card).toHaveAttribute('href', '/zh/simulation/result/91')
+  })
 })

+ 20 - 205
frontend/src/pages/dashboard/simulation/AftercareResultPage.tsx

@@ -1,121 +1,11 @@
-import { useEffect, useState } from 'react'
-import { Link, useSearchParams } from 'react-router-dom'
+import { Link } from 'react-router-dom'
 import { Helmet } from 'react-helmet-async'
-import { getSimulationJob, getSimulationResults, saveAftercareFollow, type SimulationResult } from '../../../services/api/simulations'
 import { usePageContext } from '../../../shared/pageContext'
-import { simulationSession } from '../../../shared/simulationSession'
 import { DashboardLayout } from '../../../layouts/DashboardLayout'
 
 export function AftercareResultPage() {
-  const { locale, navigate, path, copy } = usePageContext()
-  const [searchParams] = useSearchParams()
-  const [recommendedDate, setRecommendedDate] = useState('')
-  const [selectedServices, setSelectedServices] = useState<string[]>(['maintenance-cut', 'bangs-cut'])
-  const [bookingStatus, setBookingStatus] = useState('not-booked')
-  const [results, setResults] = useState<SimulationResult[]>([])
-  const [sourcePhoto, setSourcePhoto] = useState('/images/aitori-before-photo.png')
-  const [submitting, setSubmitting] = useState(false)
-  const [error, setError] = useState('')
+  const { locale, path, copy } = usePageContext()
   const pageCopy = copy.aftercareResultPage
-  const jobIdFromQuery = Number(searchParams.get('jobId') || '')
-  const customerIdFromQuery = searchParams.get('customerId')
-  const jobId = Number.isFinite(jobIdFromQuery) && jobIdFromQuery > 0
-    ? jobIdFromQuery
-    : simulationSession.getJobId()
-  const isHistoryMode = !!searchParams.get('jobId')
-  const backTarget = path('/customers')
-  const activeJobId = Number.isFinite(jobIdFromQuery) && jobIdFromQuery > 0
-    ? jobIdFromQuery
-    : simulationSession.getJobId()
-
-  useEffect(() => {
-    let cancelled = false
-
-    const load = async () => {
-      if (!jobId) {
-        setResults([])
-        setError(copy.common.sessionExpired)
-        return
-      }
-
-      setError('')
-      setResults([])
-
-      for (let attempt = 0; attempt < 45; attempt += 1) {
-        const job = await getSimulationJob(jobId)
-        if (cancelled) return
-
-        if (isHistoryMode) {
-          simulationSession.setJob(jobId, {
-            customerId: customerIdFromQuery || undefined,
-            sourcePhoto: job.sourcePhoto || undefined,
-          })
-        }
-        if (job.sourcePhoto) setSourcePhoto(job.sourcePhoto)
-
-        if (job.status === 'failed') {
-          setResults([])
-          setError(job.errorMessage || '加载结果失败')
-          return
-        }
-
-        if (job.status === 'succeeded') {
-          const items = await getSimulationResults(jobId)
-          if (cancelled) return
-          setResults(items)
-          return
-        }
-
-        await new Promise((resolve) => setTimeout(resolve, 800))
-      }
-
-      if (!cancelled) {
-        setResults([])
-        setError('生成时间较长,请返回重试')
-      }
-    }
-
-    void load().catch((err) => {
-      if (cancelled) return
-      setResults([])
-      setError(err instanceof Error ? err.message : '加载结果失败')
-    })
-
-    return () => {
-      cancelled = true
-    }
-  }, [copy.common.sessionExpired, customerIdFromQuery, isHistoryMode, jobId])
-
-  const toggleService = (service: string) => {
-    setSelectedServices((current) => (
-      current.includes(service)
-        ? current.filter((item) => item !== service)
-        : [...current, service]
-    ))
-  }
-
-  const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
-    event.preventDefault()
-    if (!activeJobId || submitting) {
-      if (!activeJobId) setError(copy.common.sessionExpired)
-      return
-    }
-    if (isHistoryMode) return
-    setError('')
-    setSubmitting(true)
-    try {
-      await saveAftercareFollow(activeJobId, {
-        recommendedDate: recommendedDate || undefined,
-        recommendedServices: selectedServices,
-        bookingStatus,
-      })
-      navigate(path('/customers'))
-    } catch (err) {
-      setError(err instanceof Error ? err.message : '保存失败')
-    } finally {
-      setSubmitting(false)
-    }
-  }
 
   return (
     <DashboardLayout>
@@ -127,107 +17,32 @@ export function AftercareResultPage() {
       <section className="stylist-workbench-top ipad-top-space-margin mb-2">
         <div className="container">
           <div className="d-flex align-items-center justify-content-between flex-wrap gap-3 mb-4">
-            <Link to={backTarget} className="btn btn-small btn-rounded btn-transparent-light-gray border border-1 border-color-dark-gray fw-700"><i className="feather icon-feather-arrow-left me-6px"></i>{pageCopy.back}</Link>
-            <Link to={path('/simulation')} className="btn btn-large btn-rounded btn-transparent-light-gray border border-1 border-color-dark-gray fw-700">{pageCopy.regenerate}</Link>
+            <Link
+              to={path('/simulation')}
+              className="btn btn-small btn-rounded btn-transparent-light-gray border border-1 border-color-dark-gray fw-700"
+            >
+              <i className="feather icon-feather-arrow-left me-6px"></i>
+              {pageCopy.back}
+            </Link>
           </div>
           <div className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px">
-            <span className="bg-base-color d-inline-block mb-15px fw-600 text-white border-radius-30px ps-20px pe-20px fs-12">{pageCopy.badge}</span>
-            <h1 className="fw-700 text-dark-gray ls-minus-1px mb-10px">{pageCopy.heading}</h1>
+            <span className="bg-base-color d-inline-block mb-15px fw-600 text-white border-radius-30px ps-20px pe-20px fs-12">
+              {pageCopy.badge}
+            </span>
+            <h1 className="fw-700 text-dark-gray ls-minus-1px mb-10px">{pageCopy.title}</h1>
             <p className="fs-18 lh-30 mb-0">{pageCopy.lead}</p>
           </div>
         </div>
       </section>
       <section className="pt-0">
         <div className="container">
-          {error ? (
-            <div className="mb-3">
-              <p className="fs-14 text-red mb-2" role="alert">{error}</p>
-              {error === copy.common.sessionExpired ? (
-                <Link to={backTarget} className="btn btn-small btn-rounded btn-base-color fw-700">{pageCopy.back}</Link>
-              ) : null}
-            </div>
-          ) : null}
-          <form id="aftercare-result-form" onSubmit={handleSubmit}>
-            <div className="simulation-aftercare-layout">
-              <aside className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-25px">
-                <span className="fs-14 fw-700 text-base-color">{pageCopy.original.badge}</span>
-                <h2 className="fw-700 text-dark-gray fs-24 mb-20px">{pageCopy.original.title}</h2>
-                <div className="simulation-original-card">
-                  <img src={sourcePhoto} alt={pageCopy.original.title} />
-                </div>
-                <div className="template-detail-summary mt-20px">
-                  <div><span>{pageCopy.original.count}</span><strong>{pageCopy.original.countValue.replace('{{count}}', String(results.length))}</strong></div>
-                </div>
-              </aside>
-              <div className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px">
-                <div className="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-25px">
-                  <div>
-                    <span className="fs-14 fw-700 text-base-color">{pageCopy.results.badge}</span>
-                    <h2 className="fw-700 text-dark-gray fs-26 mb-0">{pageCopy.results.title}</h2>
-                  </div>
-                  <span className="simulation-count-badge">{pageCopy.results.count.replace('{{count}}', String(results.length))}</span>
-                </div>
-                <div className="simulation-result-grid gallery-box">
-                  {results.map((item) => (
-                    <article key={item.id} className="simulation-result-card">
-                      <a href={item.image || '#'} data-group="aftercare-result-gallery" title={item.title}>
-                        <img className="simulation-result-image" src={item.image || '/images/aitori-simulation-result.png'} alt={item.title} />
-                      </a>
-                      <div className="simulation-result-body">
-                        <span className="template-status-badge">{item.resultType}</span>
-                        <h3>{item.title}</h3>
-                        <p>{item.note}</p>
-                      </div>
-                    </article>
-                  ))}
-                </div>
-                {results.length === 0 ? <p className="text-medium-gray mb-0">{copy.common.empty}</p> : null}
-              </div>
-            </div>
-            {!isHistoryMode ? (
-              <>
-                <div className="simulation-recommendation-grid mt-4">
-                  <div className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px">
-                    <span className="fs-14 fw-700 text-base-color">{pageCopy.recommendDate.badge}</span>
-                    <h2 className="fw-700 text-dark-gray fs-26 mb-20px">{pageCopy.recommendDate.title}</h2>
-                    <label className="simulation-date-field" htmlFor="recommended-date">
-                      <span>{pageCopy.recommendDate.label}</span>
-                      <input id="recommended-date" type="date" name="recommended-date" value={recommendedDate} onChange={(event) => setRecommendedDate(event.target.value)} />
-                    </label>
-                    <p className="fs-14 text-medium-gray mb-0 mt-15px">{pageCopy.recommendDate.help}</p>
-                  </div>
-                  <div className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px">
-                    <span className="fs-14 fw-700 text-base-color">{pageCopy.services.badge}</span>
-                    <h2 className="fw-700 text-dark-gray fs-26 mb-20px">{pageCopy.services.title}</h2>
-                    <div className="simulation-service-list">
-                      <label><input type="checkbox" name="services" value="maintenance-cut" checked={selectedServices.includes('maintenance-cut')} onChange={() => toggleService('maintenance-cut')} /><span>{pageCopy.services.options[0]}</span></label>
-                      <label><input type="checkbox" name="services" value="bangs-cut" checked={selectedServices.includes('bangs-cut')} onChange={() => toggleService('bangs-cut')} /><span>{pageCopy.services.options[1]}</span></label>
-                      <label><input type="checkbox" name="services" value="treatment" checked={selectedServices.includes('treatment')} onChange={() => toggleService('treatment')} /><span>{pageCopy.services.options[2]}</span></label>
-                    </div>
-                  </div>
-                  <div className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px">
-                    <span className="fs-14 fw-700 text-base-color">{pageCopy.booking.badge}</span>
-                    <h2 className="fw-700 text-dark-gray fs-26 mb-20px">{pageCopy.booking.title}</h2>
-                    <div className="simulation-service-list">
-                      <label><input type="radio" name="booking-status" value="booked" checked={bookingStatus === 'booked'} onChange={() => setBookingStatus('booked')} /><span>{pageCopy.booking.options[0]}</span></label>
-                      <label><input type="radio" name="booking-status" value="not-booked" checked={bookingStatus === 'not-booked'} onChange={() => setBookingStatus('not-booked')} /><span>{pageCopy.booking.options[1]}</span></label>
-                      <label><input type="radio" name="booking-status" value="follow-up" checked={bookingStatus === 'follow-up'} onChange={() => setBookingStatus('follow-up')} /><span>{pageCopy.booking.options[2]}</span></label>
-                    </div>
-                  </div>
-                </div>
-                <div className="customer-edit-actions workbench-panel border-radius-8px p-25px mt-4">
-                  <div>
-                    <strong>{pageCopy.submit.title}</strong>
-                    <span>{pageCopy.submit.lead}</span>
-                  </div>
-                  <div className="d-flex flex-wrap gap-2">
-                    <Link to={backTarget} className="btn btn-large btn-rounded btn-transparent-light-gray border border-1 border-color-dark-gray fw-700">{pageCopy.submit.back}</Link>
-                    <button type="submit" disabled={submitting} className="btn btn-large btn-rounded btn-base-color btn-box-shadow fw-700"><i className="feather icon-feather-save me-10px"></i>{submitting ? '...' : copy.common.save}</button>
-                  </div>
-                </div>
-              </>
-            ) : null}
-          </form>
+          <div className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px">
+            <h2 className="fw-700 text-dark-gray fs-26 mb-15px">{pageCopy.state.title}</h2>
+            <p className="fs-18 lh-30 text-medium-gray mb-25px">{pageCopy.state.description}</p>
+            <Link to={path('/simulation')} className="btn btn-large btn-rounded btn-base-color btn-box-shadow fw-700">
+              {pageCopy.state.action}
+            </Link>
+          </div>
         </div>
       </section>
     </DashboardLayout>

+ 20 - 202
frontend/src/pages/dashboard/simulation/SimulationEditPage.tsx

@@ -1,114 +1,11 @@
-import { useEffect, useState, type CSSProperties } from 'react'
-import { Link, useSearchParams } from 'react-router-dom'
+import { Link } from 'react-router-dom'
 import { Helmet } from 'react-helmet-async'
-import { adjustSimulationResult, getSimulationResults, uploadMedia } from '../../../services/api/simulations'
-import { usePageContext, usePreviewUrl } from '../../../shared/pageContext'
-import { simulationSession } from '../../../shared/simulationSession'
+import { usePageContext } from '../../../shared/pageContext'
 import { DashboardLayout } from '../../../layouts/DashboardLayout'
 
-type HairColorOption = {
-  value: string
-  label: string
-  swatch: string
-}
-
-type BangOption = {
-  value: string
-  title: string
-  note: string
-}
-
 export function SimulationEditPage() {
-  const { locale, navigate, path, copy } = usePageContext()
-  const [searchParams] = useSearchParams()
+  const { locale, path, copy } = usePageContext()
   const pageCopy = copy.simulationEditPage
-  const [file, setFile] = useState<File | null>(null)
-  const [baseImage, setBaseImage] = useState('/images/aitori-simulation-result.png')
-  const [baseTitle, setBaseTitle] = useState('')
-  const [hairColor, setHairColor] = useState('none')
-  const [hairLength, setHairLength] = useState(0)
-  const [volume, setVolume] = useState(0)
-  const [bangs, setBangs] = useState('none')
-  const [submitting, setSubmitting] = useState(false)
-  const [error, setError] = useState('')
-  const previewUrl = usePreviewUrl(file, baseImage)
-  const historyJobId = Number(searchParams.get('jobId') || '')
-  const historyCustomerId = searchParams.get('customerId') || simulationSession.getCustomerId()
-  const activeJobId = Number.isFinite(historyJobId) && historyJobId > 0
-    ? historyJobId
-    : simulationSession.getJobId()
-  const backTarget = Number.isFinite(historyJobId) && historyJobId > 0
-    ? path(`/simulation/result?${new URLSearchParams({
-      jobId: String(historyJobId),
-      ...(historyCustomerId ? { customerId: historyCustomerId } : {}),
-    }).toString()}`)
-    : path('/simulation/result')
-  const hairColors: HairColorOption[] = pageCopy.controls.hairColors.map((item: { value: string; label: string }) => ({
-    ...item,
-    swatch: ({
-      none: '#5a4037',
-      beige: '#b78962',
-      greige: '#8f8172',
-      ash: '#6e7780',
-      olive: '#6f7559',
-      'pink-brown': '#9b5d5d',
-    } as Record<string, string>)[item.value],
-  }))
-  const bangsOptions: BangOption[] = pageCopy.controls.bangOptions
-
-  useEffect(() => {
-    const resultId = simulationSession.getResultId()
-    if (!resultId || !activeJobId) {
-      setError(copy.common.sessionExpired)
-      return
-    }
-    getSimulationResults(activeJobId)
-      .then((results) => {
-        const item = results.find((r) => r.id === resultId)
-        if (item?.image) setBaseImage(item.image)
-        if (item?.title) setBaseTitle(item.title)
-      })
-      .catch((err) => setError(err instanceof Error ? err.message : '加载结果失败'))
-  }, [activeJobId, copy.common.sessionExpired])
-
-  const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
-    event.preventDefault()
-    const resultId = simulationSession.getResultId()
-    if (!resultId || submitting) {
-      if (!resultId) setError(copy.common.sessionExpired)
-      return
-    }
-    setError('')
-    setSubmitting(true)
-    try {
-      let replacementPhoto: string | undefined
-      if (file) replacementPhoto = (await uploadMedia(file)).url
-      const job = await adjustSimulationResult(resultId, {
-        hairColor: hairColor === 'none' ? undefined : hairColor,
-        hairLengthDelta: hairLength,
-        volumeDelta: volume,
-        bangs: bangs === 'none' ? undefined : bangs,
-        replacementPhoto,
-      })
-      simulationSession.setJob(job.id, {
-        customerId: simulationSession.getCustomerId() || undefined,
-        sourcePhoto: replacementPhoto || job.sourcePhoto || undefined,
-      })
-      if (Number.isFinite(historyJobId) && historyJobId > 0) {
-        const params = new URLSearchParams({
-          jobId: String(job.id),
-        })
-        if (historyCustomerId) params.set('customerId', historyCustomerId)
-        navigate(path(`/simulation/result?${params.toString()}`))
-        return
-      }
-      navigate(path('/simulation/result'))
-    } catch (err) {
-      setError(err instanceof Error ? err.message : '微调失败')
-    } finally {
-      setSubmitting(false)
-    }
-  }
 
   return (
     <DashboardLayout>
@@ -120,11 +17,18 @@ export function SimulationEditPage() {
       <section className="stylist-workbench-top ipad-top-space-margin mb-2">
         <div className="container">
           <div className="d-flex align-items-center justify-content-between flex-wrap gap-3 mb-4">
-            <Link to={backTarget} className="btn btn-small btn-rounded btn-transparent-light-gray border border-1 border-color-dark-gray fw-700"><i className="feather icon-feather-arrow-left me-6px"></i>{pageCopy.back}</Link>
-            <button type="submit" form="simulation-edit-form" disabled={submitting} className="btn btn-large btn-rounded btn-base-color btn-box-shadow fw-700"><i className="feather icon-feather-cpu me-10px"></i>{submitting ? '...' : pageCopy.generate}</button>
+            <Link
+              to={path('/simulation')}
+              className="btn btn-small btn-rounded btn-transparent-light-gray border border-1 border-color-dark-gray fw-700"
+            >
+              <i className="feather icon-feather-arrow-left me-6px"></i>
+              {pageCopy.back}
+            </Link>
           </div>
           <div className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px">
-            <span className="bg-base-color d-inline-block mb-15px fw-600 text-white text-uppercase border-radius-30px ps-20px pe-20px fs-12">{pageCopy.badge}</span>
+            <span className="bg-base-color d-inline-block mb-15px fw-600 text-white text-uppercase border-radius-30px ps-20px pe-20px fs-12">
+              {pageCopy.badge}
+            </span>
             <h1 className="fw-700 text-dark-gray ls-minus-1px mb-10px">{pageCopy.title}</h1>
             <p className="fs-18 lh-30 mb-0">{pageCopy.lead}</p>
           </div>
@@ -132,99 +36,13 @@ export function SimulationEditPage() {
       </section>
       <section className="pt-0">
         <div className="container">
-          {error ? (
-            <div className="mb-3">
-              <p className="fs-14 text-red mb-2" role="alert">{error}</p>
-              {error === copy.common.sessionExpired ? (
-                <Link to={path('/customers')} className="btn btn-small btn-rounded btn-base-color fw-700">{pageCopy.back}</Link>
-              ) : null}
-            </div>
-          ) : null}
-          <form id="simulation-edit-form" onSubmit={handleSubmit}>
-            <div className="simulation-edit-layout">
-              <aside className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-25px">
-                <span className="fs-14 fw-700 text-base-color text-uppercase">{pageCopy.current.badge}</span>
-                <h2 className="fw-700 text-dark-gray fs-24 mb-20px">{pageCopy.current.title}</h2>
-                <div className="simulation-original-card">
-                  <img id="simulation-edit-preview" src={previewUrl} alt={pageCopy.current.imageAlt} />
-                </div>
-                <div className="template-detail-summary mt-20px">
-                  <div><span>{pageCopy.current.template}</span><strong>{baseTitle || '—'}</strong></div>
-                  <div><span>{pageCopy.current.status}</span><strong>{pageCopy.current.statusValue}</strong></div>
-                </div>
-                <div className="mt-25px">
-                  <span className="fs-14 fw-700 text-base-color text-uppercase">{pageCopy.current.changePhoto}</span>
-                  <h3 className="fw-700 text-dark-gray fs-20 mb-15px">{pageCopy.current.changePhotoTitle}</h3>
-                  <label htmlFor="simulation-edit-upload" className="simulation-edit-upload-button">
-                    <input id="simulation-edit-upload" type="file" accept="image/*" onChange={(event) => setFile(event.target.files?.[0] || null)} />
-                    <i className="feather icon-feather-upload"></i>
-                    <span>{pageCopy.current.reupload}</span>
-                  </label>
-                </div>
-              </aside>
-              <div className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px">
-                <div className="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-25px">
-                  <div>
-                    <span className="fs-14 fw-700 text-base-color text-uppercase">{pageCopy.controls.badge}</span>
-                    <h2 className="fw-700 text-dark-gray fs-26 mb-0">{pageCopy.controls.title}</h2>
-                  </div>
-                  <span className="simulation-count-badge">{pageCopy.controls.count}</span>
-                </div>
-                <div className="simulation-edit-grid">
-                  <div className="simulation-edit-control is-wide">
-                    <label>{pageCopy.controls.hairColor}</label>
-                    <div className="simulation-preset-grid color">
-                      {hairColors.map((item) => (
-                        <label key={item.value}>
-                          <input type="radio" name="hair-color" value={item.value} checked={hairColor === item.value} onChange={() => setHairColor(item.value)} />
-                          <span style={{ '--swatch': item.swatch } as CSSProperties}></span>
-                          <strong>{item.label}</strong>
-                        </label>
-                      ))}
-                    </div>
-                  </div>
-                  <div className="simulation-edit-control">
-                    <label htmlFor="hair-length">{pageCopy.controls.hairLength}</label>
-                    <div className="simulation-slider-field">
-                      <span>{pageCopy.controls.shorter}</span>
-                      <input id="hair-length" type="range" name="hair-length" min="-3" max="3" value={hairLength} step="1" onChange={(event) => setHairLength(Number(event.target.value))} />
-                      <span>{pageCopy.controls.longer}</span>
-                    </div>
-                  </div>
-                  <div className="simulation-edit-control">
-                    <label htmlFor="volume">{pageCopy.controls.volume}</label>
-                    <div className="simulation-slider-field">
-                      <span>{pageCopy.controls.lighter}</span>
-                      <input id="volume" type="range" name="volume" min="-3" max="3" value={volume} step="1" onChange={(event) => setVolume(Number(event.target.value))} />
-                      <span>{pageCopy.controls.heavier}</span>
-                    </div>
-                  </div>
-                  <div className="simulation-edit-control is-wide">
-                    <label>{pageCopy.controls.bangs}</label>
-                    <div className="simulation-preset-grid">
-                      {bangsOptions.map((item) => (
-                        <label key={item.value}>
-                          <input type="radio" name="bangs" value={item.value} checked={bangs === item.value} onChange={() => setBangs(item.value)} />
-                          <strong>{item.title}</strong>
-                          <small>{item.note}</small>
-                        </label>
-                      ))}
-                    </div>
-                  </div>
-                </div>
-              </div>
-            </div>
-            <div className="customer-edit-actions workbench-panel border-radius-8px p-25px mt-4">
-              <div>
-                <strong>{pageCopy.submitTitle}</strong>
-                <span>{pageCopy.submitLead}</span>
-              </div>
-              <div className="d-flex flex-wrap gap-2">
-                <Link to={backTarget} className="btn btn-large btn-rounded btn-transparent-light-gray border border-1 border-color-dark-gray fw-700">{copy.common.cancel}</Link>
-                <button type="submit" disabled={submitting} className="btn btn-large btn-rounded btn-base-color btn-box-shadow fw-700"><i className="feather icon-feather-cpu me-10px"></i>{submitting ? '...' : pageCopy.generate}</button>
-              </div>
-            </div>
-          </form>
+          <div className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px">
+            <h2 className="fw-700 text-dark-gray fs-26 mb-15px">{pageCopy.state.title}</h2>
+            <p className="fs-18 lh-30 text-medium-gray mb-25px">{pageCopy.state.description}</p>
+            <Link to={path('/simulation')} className="btn btn-large btn-rounded btn-base-color btn-box-shadow fw-700">
+              {pageCopy.state.action}
+            </Link>
+          </div>
         </div>
       </section>
     </DashboardLayout>

+ 157 - 204
frontend/src/pages/dashboard/simulation/SimulationResultPage.tsx

@@ -1,151 +1,84 @@
 import { useEffect, useState } from 'react'
-import { Link, useSearchParams } from 'react-router-dom'
+import { Link, useParams } from 'react-router-dom'
 import { Helmet } from 'react-helmet-async'
-import {
-  getSimulationJob,
-  getSimulationResults,
-  saveSimulationResult,
-  type SimulationResult,
-} from '../../../services/api/simulations'
+import type { AiHairSimulationDetail } from '../../../services/api/contracts'
+import { getSimulationById } from '../../../services/api/simulations'
 import { usePageContext } from '../../../shared/pageContext'
-import { simulationSession } from '../../../shared/simulationSession'
 import { DashboardLayout } from '../../../layouts/DashboardLayout'
 
-type SimulationResultPageState =
-  | 'no_job'
-  | 'pending'
-  | 'processing'
-  | 'failed'
-  | 'succeeded'
+function formatDisplayDate(input: string) {
+  const date = new Date(input)
+  if (Number.isNaN(date.getTime())) {
+    return input
+  }
+
+  const year = date.getFullYear()
+  const month = String(date.getMonth() + 1).padStart(2, '0')
+  const day = String(date.getDate()).padStart(2, '0')
+  return `${year}/${month}/${day}`
+}
 
 export function SimulationResultPage() {
-  const { locale, navigate, path, copy } = usePageContext()
-  const [searchParams] = useSearchParams()
-  const [items, setItems] = useState<SimulationResult[]>([])
-  const [sourcePhoto, setSourcePhoto] = useState('/images/aitori-before-photo.png')
+  const { simulationId = '' } = useParams()
+  const { locale, path, copy } = usePageContext()
+  const pageCopy = copy.simulationResultPage
+
+  const [detail, setDetail] = useState<AiHairSimulationDetail | null>(null)
   const [loading, setLoading] = useState(true)
   const [error, setError] = useState('')
-  const [pageState, setPageState] = useState<SimulationResultPageState>('no_job')
-  const pageCopy = copy.simulationResultPage
-  const jobIdFromQuery = Number(searchParams.get('jobId') || '')
-  const customerIdFromQuery = searchParams.get('customerId')
-  const jobId = Number.isFinite(jobIdFromQuery) && jobIdFromQuery > 0
-    ? jobIdFromQuery
-    : simulationSession.getJobId()
-  const isHistoryMode = !!searchParams.get('jobId')
-  const customerId = customerIdFromQuery || simulationSession.getCustomerId()
-  const backTarget = path('/customers')
+
+  const getTaskSummary = (
+    task: AiHairSimulationDetail['tasks'][number],
+  ) => {
+    if (task.taskStatus === 'FAILED') {
+      return task.errorMsg || pageCopy.card.failed
+    }
+
+    if (task.taskStatus === 'PROCESSING') {
+      return pageCopy.card.processing
+    }
+
+    return pageCopy.card.success
+  }
 
   useEffect(() => {
     let cancelled = false
+    let timer: number | undefined
 
     const load = async () => {
-      if (!jobId) {
-        setItems([])
-        setError(copy.common.sessionExpired)
-        setPageState('no_job')
-        setLoading(false)
-        return
-      }
-
-      setLoading(true)
-      setError('')
-      setItems([])
-
-      for (let attempt = 0; attempt < 45; attempt += 1) {
-        const job = await getSimulationJob(jobId)
+      try {
+        const data = await getSimulationById(simulationId)
         if (cancelled) return
 
-        if (isHistoryMode) {
-          simulationSession.setJob(jobId, {
-            customerId: customerIdFromQuery || undefined,
-            sourcePhoto: job.sourcePhoto || undefined,
-          })
-        }
-        if (job.sourcePhoto) setSourcePhoto(job.sourcePhoto)
-
-        if (job.status === 'pending') {
-          setPageState('pending')
-          await new Promise((resolve) => setTimeout(resolve, 800))
-          continue
-        }
-
-        if (job.status === 'processing') {
-          setPageState('processing')
-          await new Promise((resolve) => setTimeout(resolve, 800))
-          continue
-        }
-
-        if (job.status === 'failed') {
-          setItems([])
-          setError(job.errorMessage || '加载结果失败')
-          setPageState('failed')
-          setLoading(false)
-          return
-        }
+        setDetail(data)
+        setError('')
+        setLoading(false)
 
-        if (job.status === 'succeeded') {
-          const results = await getSimulationResults(jobId)
-          if (cancelled) return
-          setItems(results)
-          setPageState('succeeded')
-          setLoading(false)
-          return
+        if (data.overallStatus === 'PROCESSING') {
+          timer = window.setTimeout(() => {
+            void load()
+          }, 5000)
         }
-      }
-
-      if (!cancelled) {
-        setItems([])
-        setError(pageCopy.state.timeout.title)
-        setPageState('failed')
+      } catch (loadError) {
+        if (cancelled) return
+        setError(loadError instanceof Error ? loadError.message : '加载失败')
         setLoading(false)
       }
     }
 
-    void load().catch((err) => {
-      if (cancelled) return
-      setItems([])
-      setError(err instanceof Error ? err.message : '加载结果失败')
-      setPageState('failed')
-      setLoading(false)
-    })
+    void load()
 
     return () => {
       cancelled = true
+      if (timer) {
+        window.clearTimeout(timer)
+      }
     }
-  }, [
-    copy.common.sessionExpired,
-    customerIdFromQuery,
-    isHistoryMode,
-    jobId,
-    pageCopy.state.timeout.title,
-  ])
-
-  const handleSave = async (resultId: string) => {
-    if (isHistoryMode) return
-    try {
-      await saveSimulationResult(resultId)
-      navigate(path('/customers'))
-    } catch (err) {
-      setError(err instanceof Error ? err.message : '保存失败')
-    }
-  }
-
-  const handleAdjust = (resultId: string) => {
-    simulationSession.setResultId(resultId)
-    if (isHistoryMode && jobId) {
-      const params = new URLSearchParams({
-        jobId: String(jobId),
-      })
-      if (customerId) params.set('customerId', customerId)
-      navigate(path(`/simulation/edit?${params.toString()}`))
-      return
-    }
-    navigate(path('/simulation/edit'))
-  }
+  }, [simulationId])
 
-  const showResults = pageState === 'succeeded' && !error
-  const visibleResults = showResults ? items.slice(0, 5) : []
+  const backTarget = detail?.customer
+    ? path(`/customers/${detail.customer.id}`)
+    : path('/simulation')
 
   return (
     <DashboardLayout>
@@ -156,104 +89,124 @@ export function SimulationResultPage() {
       </Helmet>
       <section className="stylist-workbench-top ipad-top-space-margin mb-2">
         <div className="container">
-          <div className="d-flex align-items-center justify-content-between flex-wrap gap-3 mb-4">
-            <Link to={backTarget} className="btn btn-small btn-rounded btn-transparent-light-gray border border-1 border-color-dark-gray fw-700"><i className="feather icon-feather-arrow-left me-6px"></i>{pageCopy.back}</Link>
-            <Link to={path('/simulation')} className="btn btn-large btn-rounded btn-transparent-light-gray border border-1 border-color-dark-gray fw-700">{pageCopy.regenerate}</Link>
-          </div>
-          <div className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px">
-            <span className="bg-base-color d-inline-block mb-15px fw-600 text-white text-uppercase border-radius-30px ps-20px pe-20px fs-12">{pageCopy.badge}</span>
-            <h1 className="fw-700 text-dark-gray ls-minus-1px mb-10px">{pageCopy.title}</h1>
-            <p className="fs-18 lh-30 mb-0">{pageCopy.lead}</p>
+          <div className="d-flex align-items-center flex-wrap gap-3 mb-4">
+            <Link
+              to={backTarget}
+              className="btn btn-small btn-rounded btn-transparent-light-gray border border-1 border-color-dark-gray fw-700"
+            >
+              <i className="feather icon-feather-arrow-left me-6px"></i>
+              {pageCopy.back}
+            </Link>
           </div>
         </div>
       </section>
       <section className="pt-0">
         <div className="container">
-          {pageState === 'failed' || pageState === 'no_job' ? (
-            <div className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px mb-3">
-              {pageState === 'failed' ? (
-                <>
-                  <span className="fs-14 fw-700 text-red text-uppercase">{pageCopy.state.failed.badge}</span>
-                  <h2 className="fw-700 text-dark-gray fs-26 mb-10px">{pageCopy.state.failed.title}</h2>
-                  <p className="mb-2">{pageCopy.state.failed.description}</p>
-                </>
-              ) : null}
-              <p className="fs-14 text-red mb-3" role="alert">{error}</p>
-              <div className="d-flex gap-2 flex-wrap">
-                <Link to={backTarget} className="btn btn-small btn-rounded btn-transparent-light-gray border border-1 border-color-dark-gray fw-700">{pageCopy.back}</Link>
-                {pageState === 'failed' ? (
-                  <Link to={path('/simulation')} className="btn btn-small btn-rounded btn-base-color fw-700">{pageCopy.regenerate}</Link>
-                ) : null}
-              </div>
-            </div>
-          ) : null}
-          {pageState === 'pending' || pageState === 'processing' ? (
+          {error ? (
             <div className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px mb-3">
-              <span className="fs-14 fw-700 text-base-color text-uppercase">
-                {pageState === 'pending' ? pageCopy.state.pending.badge : pageCopy.state.processing.badge}
-              </span>
-              <h2 className="fw-700 text-dark-gray fs-26 mb-10px">
-                {pageState === 'pending' ? pageCopy.state.pending.title : pageCopy.state.processing.title}
-              </h2>
-              <p className="mb-2">
-                {pageState === 'pending'
-                  ? pageCopy.state.pending.description
-                  : pageCopy.state.processing.description}
-              </p>
-              <p className="text-medium-gray mb-0">
-                {pageState === 'pending'
-                  ? pageCopy.state.pending.hint
-                  : pageCopy.state.processing.hint}
+              <p role="alert" className="text-red mb-0">
+                {error}
               </p>
             </div>
           ) : null}
-          {loading && pageState === 'succeeded' ? <p className="text-medium-gray">{pageCopy.results.title.replace('{{count}}', '...')}</p> : null}
-          {!loading && showResults && visibleResults.length === 0 ? (
-            <p className="text-medium-gray mb-3">{copy.common.empty}</p>
+
+          {loading ? (
+            <div className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px">
+              <p className="mb-0 text-medium-gray">Loading...</p>
+            </div>
           ) : null}
-          <form id="simulation-result-form">
-            <div className="simulation-result-layout">
-              <aside className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-25px">
-                <span className="fs-14 fw-700 text-base-color text-uppercase">{pageCopy.original.badge}</span>
-                <h2 className="fw-700 text-dark-gray fs-24 mb-20px">{pageCopy.original.title}</h2>
-                <div className="simulation-original-card">
-                  <img src={sourcePhoto} alt={pageCopy.original.title} />
-                </div>
-                <div className="template-detail-summary mt-20px">
-                  <div><span>{pageCopy.original.count}</span><strong>{pageCopy.original.countValue.replace('{{count}}', String(visibleResults.length))}</strong></div>
-                </div>
-              </aside>
-              <div className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px">
-                <div className="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-25px">
-                  <div>
-                    <span className="fs-14 fw-700 text-base-color text-uppercase">{pageCopy.results.badge}</span>
-                    <h2 className="fw-700 text-dark-gray fs-26 mb-0">{pageCopy.results.title.replace('{{count}}', String(visibleResults.length))}</h2>
+
+          {detail ? (
+            <>
+              <div className="simulation-result-layout">
+                <aside className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-25px simulation-result-sidebar">
+                  <div className="simulation-results-header simulation-results-header-static">
+                    <div>
+                      <span className="simulation-section-badge simulation-section-badge-text simulation-section-badge-accent">{pageCopy.original.badge}</span>
+                      <h2 className="simulation-section-title">{pageCopy.original.title}</h2>
+                    </div>
                   </div>
-                  <span className="simulation-count-badge">{pageCopy.results.count.replace('{{count}}', String(visibleResults.length))}</span>
-                </div>
-                <div className="simulation-result-grid gallery-box">
-                  {visibleResults.map((item, index) => (
-                    <article key={item.id} className="simulation-result-card">
-                      <a href={item.image || '#'} data-group="simulation-result-gallery" title={item.title}>
-                        <img className="simulation-result-image" src={item.image || '/images/aitori-simulation-result.png'} alt={`${item.title} ${pageCopy.results.badge}`} />
-                      </a>
-                      <div className="simulation-result-body">
-                        <span className="template-status-badge">{`${pageCopy.results.templatePrefix} ${String(index + 1).padStart(2, '0')}`}</span>
-                        <h3>{item.title}</h3>
-                        <p>{item.note}</p>
-                        <div className="simulation-result-actions">
-                          <button type="button" onClick={() => handleAdjust(item.id)} className="btn btn-small btn-rounded btn-transparent-light-gray border border-1 border-color-dark-gray fw-700"><i className="feather icon-feather-sliders me-6px"></i>{pageCopy.results.adjust}</button>
-                          {!isHistoryMode ? (
-                            <button type="button" onClick={() => handleSave(item.id)} className="btn btn-small btn-rounded btn-base-color fw-700"><i className="feather icon-feather-save me-6px"></i>{pageCopy.results.save}</button>
-                          ) : null}
+                  <div className="simulation-original-card">
+                    <img
+                      src={detail.originImage || '/images/aitori-before-photo.png'}
+                      alt={pageCopy.title}
+                      className="simulation-result-image"
+                    />
+                  </div>
+                  <div className="simulation-result-sidebar-body">
+                    {detail.customer ? (
+                      <div className="simulation-result-meta-list">
+                        <div>
+                          <span>{pageCopy.original.customer}</span>
+                          <strong>{detail.customer.name}</strong>
+                        </div>
+                        <div>
+                          <span>{pageCopy.original.generatedAt}</span>
+                          <strong>{formatDisplayDate(detail.createdAt)}</strong>
+                        </div>
+                        <div>
+                          <span>{pageCopy.original.count}</span>
+                          <strong>{pageCopy.original.countValue.replace('{{count}}', String(detail.totalTaskCount))}</strong>
+                        </div>
+                      </div>
+                    ) : (
+                      <div className="simulation-result-meta-list">
+                        <div>
+                          <span>{pageCopy.original.customer}</span>
+                          <strong>{pageCopy.anonymousTitle}</strong>
+                        </div>
+                        <div>
+                          <span>{pageCopy.original.generatedAt}</span>
+                          <strong>{formatDisplayDate(detail.createdAt)}</strong>
+                        </div>
+                        <div>
+                          <span>{pageCopy.original.count}</span>
+                          <strong>{pageCopy.original.countValue.replace('{{count}}', String(detail.totalTaskCount))}</strong>
                         </div>
                       </div>
-                    </article>
-                  ))}
+                    )}
+                  </div>
+                </aside>
+
+                <div className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px">
+                  <div className="simulation-results-header">
+                    <div>
+                      <span className="simulation-section-badge simulation-section-badge-text simulation-section-badge-accent">{pageCopy.results.badge}</span>
+                      <h2 className="simulation-section-title">{pageCopy.results.title}</h2>
+                    </div>
+                    <span className="simulation-results-count">{pageCopy.results.count.replace('{{count}}', String(detail.totalTaskCount))}</span>
+                  </div>
+                  <div className="simulation-result-grid">
+                    {detail.tasks.map((task) => (
+                      <article key={task.id} className="simulation-result-card">
+                        <img
+                          className="simulation-result-image"
+                          src={task.resultImages[0] || '/images/aitori-simulation-result.png'}
+                          alt={task.templateName}
+                        />
+                        <div className="simulation-result-body">
+                          <div className="simulation-result-card-title-row">
+                            <h3>{task.templateName}</h3>
+                            <span className="simulation-result-card-tag">
+                              {task.templateSource === 'SYSTEM'
+                                ? pageCopy.source.system
+                                : pageCopy.source.stylist}
+                            </span>
+                          </div>
+                          <p className="mb-0">{getTaskSummary(task)}</p>
+                          {task.errorMsg ? (
+                            <p role="alert" className="text-red mt-10px mb-0">
+                              {task.errorMsg}
+                            </p>
+                          ) : null}
+                        </div>
+                      </article>
+                    ))}
+                  </div>
                 </div>
               </div>
-            </div>
-          </form>
+            </>
+          ) : null}
         </div>
       </section>
     </DashboardLayout>

+ 262 - 184
frontend/src/pages/dashboard/simulation/StylistSimulationPage.tsx

@@ -1,66 +1,66 @@
 import React, { useEffect, useState } from 'react'
-import { Link } from 'react-router-dom'
+import { Link, useSearchParams } from 'react-router-dom'
 import { Helmet } from 'react-helmet-async'
+import { DashboardLayout } from '../../../layouts/DashboardLayout'
 import { listCustomers } from '../../../services/api/customers'
-import { listAftercareOptions, listSimulationTemplateGallery } from '../../../services/api/pageData'
-import type { AftercareOptionEntity, SimulationTemplateEntity } from '../../../services/api/contracts'
-import { createSimulation, uploadMedia } from '../../../services/api/simulations'
 import type { CustomerEntity } from '../../../services/api/customers'
-import { TEMPLATE_LIMIT, usePageContext, usePreviewUrl } from '../../../shared/pageContext'
-
-const TEMPLATE_PAGE_SIZE = 6
-const AFTERCARE_SIMULATION_TYPES = [
-  'home_styling',
-  'after_30_days',
-  'after_60_days',
-] as const
-import { simulationSession } from '../../../shared/simulationSession'
-import { DashboardLayout } from '../../../layouts/DashboardLayout'
+import type { SimulationTemplateOption } from '../../../services/api/contracts'
+import {
+  createSimulation,
+  listSimulationTemplates,
+  uploadMedia,
+} from '../../../services/api/simulations'
+import {
+  TEMPLATE_LIMIT,
+  usePageContext,
+  usePreviewUrl,
+} from '../../../shared/pageContext'
 
 export function StylistSimulationPage() {
+  const [searchParams] = useSearchParams()
   const { locale, navigate, path, copy } = usePageContext()
-  const [customerMode, setCustomerMode] = useState('selected')
-  const [simulationType, setSimulationType] = useState('hairstyle')
+  const presetCustomerId = searchParams.get('customerId') ?? ''
+  const pageCopy = copy.simulationCreatePage
+
+  const [bindCustomer, setBindCustomer] = useState(Boolean(presetCustomerId))
+  const [selectedCustomerId, setSelectedCustomerId] = useState(presetCustomerId)
+  const [templateSource, setTemplateSource] = useState<'STYLIST' | 'SYSTEM'>('STYLIST')
   const [selectedTemplates, setSelectedTemplates] = useState<string[]>([])
-  const [file, setFile] = useState<File | null>(null)
   const [customers, setCustomers] = useState<CustomerEntity[]>([])
-  const [selectedCustomerId, setSelectedCustomerId] = useState<string | null>(null)
-  const [templateGallery, setTemplateGallery] = useState<SimulationTemplateEntity[]>([])
-  const [templatePage, setTemplatePage] = useState(1)
-  const [templateTotal, setTemplateTotal] = useState(0)
-  const [aftercareOptions, setAftercareOptions] = useState<AftercareOptionEntity[]>([])
+  const [templates, setTemplates] = useState<SimulationTemplateOption[]>([])
+  const [file, setFile] = useState<File | null>(null)
+  const [originImageUrl, setOriginImageUrl] = useState('')
+  const [remark, setRemark] = useState('')
+  const [uploading, setUploading] = useState(false)
   const [submitting, setSubmitting] = useState(false)
   const [error, setError] = useState('')
-  const previewUrl = usePreviewUrl(file, '/images/aitori-before-photo.png')
+
+  const previewUrl = usePreviewUrl(file, originImageUrl || '/images/aitori-before-photo.png')
   const selectedCustomer = customers.find((item) => item.id === selectedCustomerId) || null
-  const pageCopy = copy.stylistSimulationPage
 
   useEffect(() => {
-    listCustomers().then((result) => {
+    void listCustomers().then((result) => {
       setCustomers(result.items)
-      const preset = simulationSession.getCustomerId()
-      if (preset && result.items.some((item) => item.id === preset)) {
-        setSelectedCustomerId(preset)
-      } else if (result.items.length > 0) {
-        setSelectedCustomerId(result.items[0].id)
+      if (presetCustomerId && result.items.some((item) => item.id === presetCustomerId)) {
+        setBindCustomer(true)
+        setSelectedCustomerId(presetCustomerId)
       }
     })
-    listAftercareOptions().then(setAftercareOptions)
-  }, [])
+  }, [presetCustomerId])
 
   useEffect(() => {
-    listSimulationTemplateGallery(templatePage, TEMPLATE_PAGE_SIZE)
-      .then(({ items, total }) => {
-        setTemplateGallery(items)
-        setTemplateTotal(total)
+    void listSimulationTemplates({
+      source: templateSource,
+      page: 1,
+      pageSize: 12,
+    })
+      .then((result) => {
+        setTemplates(result.items)
       })
       .catch(() => {
-        setTemplateGallery([])
-        setTemplateTotal(0)
+        setTemplates([])
       })
-  }, [templatePage])
-
-  const templateTotalPages = Math.max(1, Math.ceil(templateTotal / TEMPLATE_PAGE_SIZE))
+  }, [templateSource])
 
   const toggleTemplate = (templateId: string) => {
     setSelectedTemplates((current) => {
@@ -74,45 +74,67 @@ export function StylistSimulationPage() {
     })
   }
 
+  const handleUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
+    const nextFile = event.target.files?.[0] || null
+    setFile(nextFile)
+    setError('')
+
+    if (!nextFile) {
+      setOriginImageUrl('')
+      return
+    }
+
+    setUploading(true)
+    try {
+      const uploaded = await uploadMedia(nextFile, 'ai-user-origin-image')
+      setOriginImageUrl(uploaded.url)
+    } catch (uploadError) {
+      setOriginImageUrl('')
+      setError(
+        uploadError instanceof Error
+          ? uploadError.message
+          : pageCopy.errors.submitFailed,
+      )
+    } finally {
+      setUploading(false)
+    }
+  }
+
   const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
     event.preventDefault()
     if (submitting) return
-    if (customerMode === 'selected' && !selectedCustomer) {
-      setError(pageCopy.step1.emptyLead)
+
+    if (bindCustomer && !selectedCustomerId) {
+      setError(pageCopy.errors.customerRequired)
       return
     }
-    if (!file) {
-      setError(pageCopy.step2.requiredLead || '请先上传顾客照片')
+
+    if (!originImageUrl) {
+      setError(pageCopy.errors.originRequired)
       return
     }
-    if (simulationType === 'hairstyle' && selectedTemplates.length === 0) {
-      setError(pageCopy.step3.templateLead)
+
+    if (selectedTemplates.length === 0) {
+      setError(pageCopy.errors.templateRequired)
       return
     }
+
     setError('')
     setSubmitting(true)
     try {
-      const sourcePhoto = (await uploadMedia(file)).url
-
-      const useSelected = customerMode === 'selected' && !!selectedCustomer
-      const job = await createSimulation({
-        customerMode: useSelected ? 'selected' : 'guest',
-        customerId: useSelected ? selectedCustomer!.id : undefined,
-        sourcePhoto,
-        simulationType: simulationType === 'aftercare' ? 'aftercare' : 'hairstyle',
-        templateIds: simulationType === 'hairstyle' ? selectedTemplates : undefined,
-        aftercareTypes:
-          simulationType === 'aftercare'
-            ? [...AFTERCARE_SIMULATION_TYPES]
-            : undefined,
-      })
-      simulationSession.setJob(job.id, {
-        customerId: useSelected ? selectedCustomer!.id : undefined,
-        sourcePhoto,
+      const response = await createSimulation({
+        customerId: bindCustomer && selectedCustomerId ? selectedCustomerId : undefined,
+        originImage: originImageUrl,
+        templateIds: selectedTemplates,
+        simulateRemark: remark,
       })
-      navigate(path(simulationType === 'aftercare' ? '/simulation/aftercare' : '/simulation/result'))
-    } catch (err) {
-      setError(err instanceof Error ? err.message : '发起模拟失败')
+      navigate(path(`/simulation/result/${response.simulationId}`))
+    } catch (submitError) {
+      setError(
+        submitError instanceof Error
+          ? submitError.message
+          : pageCopy.errors.submitFailed,
+      )
     } finally {
       setSubmitting(false)
     }
@@ -128,11 +150,27 @@ export function StylistSimulationPage() {
       <section className="stylist-workbench-top ipad-top-space-margin mb-2">
         <div className="container">
           <div className="d-flex align-items-center justify-content-between flex-wrap gap-3 mb-4">
-            <Link to={path('/dashboard')} className="btn btn-small btn-rounded btn-transparent-light-gray border border-1 border-color-dark-gray fw-700"><i className="feather icon-feather-arrow-left me-6px"></i>{pageCopy.back}</Link>
-            <button type="submit" form="simulation-form" disabled={submitting} className="btn btn-large btn-rounded btn-base-color btn-box-shadow fw-700"><i className="feather icon-feather-cpu me-10px"></i>{submitting ? '...' : pageCopy.submit}</button>
+            <Link
+              to={path('/dashboard')}
+              className="btn btn-small btn-rounded btn-transparent-light-gray border border-1 border-color-dark-gray fw-700"
+            >
+              <i className="feather icon-feather-arrow-left me-6px"></i>
+              {pageCopy.back}
+            </Link>
+            <button
+              type="submit"
+              form="simulation-form"
+              disabled={submitting || uploading}
+              className="btn btn-large btn-rounded btn-base-color btn-box-shadow fw-700"
+            >
+              <i className="feather icon-feather-cpu me-10px"></i>
+              {submitting ? '...' : pageCopy.submit}
+            </button>
           </div>
           <div className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px">
-            <span className="bg-base-color d-inline-block mb-15px fw-600 text-white border-radius-30px ps-20px pe-20px fs-12">{pageCopy.badge}</span>
+            <span className="bg-base-color d-inline-block mb-15px fw-600 text-white border-radius-30px ps-20px pe-20px fs-12">
+              {pageCopy.badge}
+            </span>
             <h1 className="fw-700 text-dark-gray ls-minus-1px mb-10px">{pageCopy.title}</h1>
             <p className="fs-18 lh-30 mb-0">{pageCopy.lead}</p>
           </div>
@@ -145,50 +183,68 @@ export function StylistSimulationPage() {
               <div className="simulation-step-heading">
                 <span>1</span>
                 <div>
-                  <span className="fs-14 fw-700 text-base-color">{pageCopy.step1.badge}</span>
-                  <h2 className="fw-700 text-dark-gray fs-26 mb-0">{pageCopy.step1.title}</h2>
+                  <span className="fs-14 fw-700 text-base-color">{pageCopy.bindCustomer}</span>
+                  <h2 className="fw-700 text-dark-gray fs-26 mb-0">{pageCopy.bindCustomer}</h2>
                 </div>
               </div>
               <div className="simulation-choice-grid mt-25px">
                 <label className="simulation-choice-card">
-                  <input type="radio" name="customer-mode" value="selected" checked={customerMode === 'selected'} onChange={() => setCustomerMode('selected')} />
-                  <span className="simulation-choice-icon"><i className="feather icon-feather-user-check"></i></span>
-                  <strong>{pageCopy.step1.selectedTitle}</strong>
-                  <small>{pageCopy.step1.selectedLead}</small>
+                  <input
+                    type="radio"
+                    name="customer-mode"
+                    aria-label={pageCopy.bindCustomer}
+                    checked={bindCustomer}
+                    onChange={() => setBindCustomer(true)}
+                  />
+                  <span className="simulation-choice-icon">
+                    <i className="feather icon-feather-user-check"></i>
+                  </span>
+                  <strong>{pageCopy.bindCustomer}</strong>
+                  <small>{pageCopy.customerPlaceholder}</small>
                 </label>
                 <label className="simulation-choice-card">
-                  <input type="radio" name="customer-mode" value="guest" checked={customerMode === 'guest'} onChange={() => setCustomerMode('guest')} />
-                  <span className="simulation-choice-icon"><i className="feather icon-feather-user-x"></i></span>
-                  <strong>{pageCopy.step1.guestTitle}</strong>
-                  <small>{pageCopy.step1.guestLead}</small>
+                  <input
+                    type="radio"
+                    name="customer-mode"
+                    aria-label={pageCopy.noCustomer}
+                    checked={!bindCustomer}
+                    onChange={() => setBindCustomer(false)}
+                  />
+                  <span className="simulation-choice-icon">
+                    <i className="feather icon-feather-user-x"></i>
+                  </span>
+                  <strong>{pageCopy.noCustomer}</strong>
+                  <small>{pageCopy.noCustomer}</small>
                 </label>
               </div>
-              <div className="simulation-selected-customer mt-25px" hidden={customerMode !== 'selected'}>
-                <img src={selectedCustomer?.image || '/images/aitori-before-photo.png'} alt={selectedCustomer?.name || pageCopy.step1.currentCustomer} />
-                <div>
-                  <span className="fs-14 text-medium-gray d-block">{pageCopy.step1.currentCustomer}</span>
-                  {customers.length > 0 ? (
-                    <select
-                      className="form-select customer-edit-control mt-10px"
-                      value={selectedCustomerId || ''}
-                      onChange={(event) => setSelectedCustomerId(event.target.value)}
-                    >
-                      {customers.map((item) => (
-                        <option key={item.id} value={item.id}>{item.name}</option>
-                      ))}
-                    </select>
-                  ) : (
-                    <strong className="text-dark-gray">{pageCopy.step1.emptyCustomer}</strong>
-                  )}
-                  <span className="d-block fs-14 text-medium-gray mt-10px">
-                    {selectedCustomer ? pageCopy.step1.selectedSummary.replace('{{date}}', selectedCustomer.lastVisit || '—') : pageCopy.step1.emptyLead}
-                  </span>
+              <div className="simulation-selected-customer mt-25px">
+                <img
+                  src={selectedCustomer?.avatar || '/images/aitori-before-photo.png'}
+                  alt={selectedCustomer?.name || pageCopy.customerLabel}
+                />
+                <div className="w-100">
+                  <label
+                    htmlFor="simulation-customer-select"
+                    className="fs-14 text-medium-gray d-block"
+                  >
+                    {pageCopy.customerLabel}
+                  </label>
+                  <select
+                    id="simulation-customer-select"
+                    aria-label={pageCopy.customerLabel}
+                    className="form-select customer-edit-control mt-10px"
+                    value={selectedCustomerId}
+                    disabled={!bindCustomer}
+                    onChange={(event) => setSelectedCustomerId(event.target.value)}
+                  >
+                    <option value="">{pageCopy.customerPlaceholder}</option>
+                    {customers.map((item) => (
+                      <option key={item.id} value={item.id}>
+                        {item.name}
+                      </option>
+                    ))}
+                  </select>
                 </div>
-                <Link to={path('/customers')} className="btn btn-small btn-rounded btn-transparent-light-gray border border-1 border-color-dark-gray fw-700">{pageCopy.step1.changeCustomer}</Link>
-              </div>
-              <div className="simulation-save-warning mt-25px" hidden={customerMode !== 'guest'}>
-                <i className="feather icon-feather-alert-circle"></i>
-                <span>{pageCopy.step1.warning}</span>
               </div>
             </div>
 
@@ -196,14 +252,24 @@ export function StylistSimulationPage() {
               <div className="simulation-step-heading">
                 <span>2</span>
                 <div>
-                  <span className="fs-14 fw-700 text-base-color">{pageCopy.step2.badge}</span>
-                  <h2 className="fw-700 text-dark-gray fs-26 mb-0">{pageCopy.step2.title}</h2>
+                  <span className="fs-14 fw-700 text-base-color">{pageCopy.upload}</span>
+                  <h2 className="fw-700 text-dark-gray fs-26 mb-0">{pageCopy.upload}</h2>
                 </div>
               </div>
               <label htmlFor="simulation-photo-upload" className="simulation-photo-upload mt-25px">
-                <input id="simulation-photo-upload" type="file" accept="image/*" onChange={(event) => setFile(event.target.files?.[0] || null)} />
-                <img id="simulation-photo-preview" src={previewUrl} alt={pageCopy.step2.previewAlt} />
-                <span><i className="feather icon-feather-upload me-8px"></i>{pageCopy.step2.upload}</span>
+                <input
+                  id="simulation-photo-upload"
+                  aria-label={pageCopy.upload}
+                  type="file"
+                  accept="image/*"
+                  onChange={handleUpload}
+                />
+                <img id="simulation-photo-preview" src={previewUrl} alt={pageCopy.upload} />
+                <span>
+                  <i className="feather icon-feather-upload me-8px"></i>
+                  {uploading ? '...' : pageCopy.upload}
+                </span>
+                <small className="d-block mt-10px text-medium-gray">{pageCopy.uploadHint}</small>
               </label>
             </div>
 
@@ -211,101 +277,113 @@ export function StylistSimulationPage() {
               <div className="simulation-step-heading">
                 <span>3</span>
                 <div>
-                  <span className="fs-14 fw-700 text-base-color">{pageCopy.step3.badge}</span>
-                  <h2 className="fw-700 text-dark-gray fs-26 mb-0">{pageCopy.step3.title}</h2>
+                  <span className="fs-14 fw-700 text-base-color">{pageCopy.myTemplates}</span>
+                  <h2 className="fw-700 text-dark-gray fs-26 mb-0">{pageCopy.myTemplates}</h2>
                 </div>
               </div>
               <div className="simulation-choice-grid mt-25px mb-30px">
                 <label className="simulation-choice-card">
-                  <input type="radio" name="simulation-type" value="hairstyle" checked={simulationType === 'hairstyle'} onChange={() => setSimulationType('hairstyle')} />
-                  <span className="simulation-choice-icon"><i className="feather icon-feather-scissors"></i></span>
-                  <strong>{pageCopy.step3.hairstyleTitle}</strong>
-                  <small>{pageCopy.step3.hairstyleLead}</small>
+                  <input
+                    type="radio"
+                    name="template-source"
+                    aria-label={pageCopy.myTemplates}
+                    checked={templateSource === 'STYLIST'}
+                    onChange={() => setTemplateSource('STYLIST')}
+                  />
+                  <span className="simulation-choice-icon">
+                    <i className="feather icon-feather-scissors"></i>
+                  </span>
+                  <strong>{pageCopy.myTemplates}</strong>
+                  <small>{pageCopy.myTemplates}</small>
                 </label>
                 <label className="simulation-choice-card">
-                  <input type="radio" name="simulation-type" value="aftercare" checked={simulationType === 'aftercare'} onChange={() => setSimulationType('aftercare')} />
-                  <span className="simulation-choice-icon"><i className="feather icon-feather-calendar"></i></span>
-                  <strong>{pageCopy.step3.aftercareTitle}</strong>
-                  <small>{pageCopy.step3.aftercareLead}</small>
+                  <input
+                    type="radio"
+                    name="template-source"
+                    aria-label={pageCopy.systemTemplates}
+                    checked={templateSource === 'SYSTEM'}
+                    onChange={() => setTemplateSource('SYSTEM')}
+                  />
+                  <span className="simulation-choice-icon">
+                    <i className="feather icon-feather-layers"></i>
+                  </span>
+                  <strong>{pageCopy.systemTemplates}</strong>
+                  <small>{pageCopy.systemTemplates}</small>
                 </label>
               </div>
-              <div data-simulation-panel="hairstyle" hidden={simulationType !== 'hairstyle'}>
-                <div className="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-25px">
-                  <p className="mb-0 fs-15 text-medium-gray">{pageCopy.step3.templateLead}</p>
-                  <span className="simulation-count-badge">{pageCopy.step3.selectedCount} <strong>{selectedTemplates.length}</strong> / 5</span>
-                </div>
-                <div className="simulation-template-grid">
-                  {templateGallery.map((template) => {
+              <div className="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-25px">
+                <p className="mb-0 fs-15 text-medium-gray">
+                  {pageCopy.selectedCount.replace('{{count}}', String(selectedTemplates.length))}
+                </p>
+              </div>
+              <div className="simulation-template-grid">
+                {templates.length === 0 ? (
+                  <p className="text-medium-gray mb-0">{pageCopy.emptyTemplates}</p>
+                ) : (
+                  templates.map((template) => {
                     const active = selectedTemplates.includes(template.id)
+
                     return (
                       <label key={template.id} className="simulation-template-card">
-                        <input type="checkbox" name="templates" value={template.id} checked={active} disabled={!active && selectedTemplates.length >= TEMPLATE_LIMIT} onChange={() => toggleTemplate(template.id)} />
-                        <img src={template.image} alt={template.name} />
-                        <span>{template.name}</span>
+                        <input
+                          type="checkbox"
+                          aria-label={template.templateName}
+                          checked={active}
+                          disabled={!active && selectedTemplates.length >= TEMPLATE_LIMIT}
+                          onChange={() => toggleTemplate(template.id)}
+                        />
+                        <img
+                          src={template.coverUrl || '/images/aitori-simulation-result.png'}
+                          alt={template.templateName}
+                        />
+                        <span>{template.templateName}</span>
                       </label>
                     )
-                  })}
-                </div>
-                {templateTotalPages > 1 ? (
-                  <div className="customer-pager">
-                    <button
-                      type="button"
-                      className={`customer-pager-link${templatePage <= 1 ? ' is-disabled' : ''}`}
-                      aria-label={pageCopy.step3.pagerPrev}
-                      disabled={templatePage <= 1}
-                      onClick={() => setTemplatePage((current) => Math.max(1, current - 1))}
-                    >
-                      {pageCopy.step3.pagerPrev}
-                    </button>
-                    {Array.from({ length: templateTotalPages }, (_, index) => index + 1).map((page) => (
-                      <button
-                        key={page}
-                        type="button"
-                        className={`customer-pager-link${page === templatePage ? ' is-active' : ''}`}
-                        aria-current={page === templatePage ? 'page' : undefined}
-                        onClick={() => setTemplatePage(page)}
-                      >
-                        {page}
-                      </button>
-                    ))}
-                    <button
-                      type="button"
-                      className={`customer-pager-link${templatePage >= templateTotalPages ? ' is-disabled' : ''}`}
-                      aria-label={pageCopy.step3.pagerNext}
-                      disabled={templatePage >= templateTotalPages}
-                      onClick={() => setTemplatePage((current) => Math.min(templateTotalPages, current + 1))}
-                    >
-                      {pageCopy.step3.pagerNext}
-                    </button>
-                  </div>
-                ) : null}
+                  })
+                )}
               </div>
-              <div data-simulation-panel="aftercare" hidden={simulationType !== 'aftercare'}>
-                <div className="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-25px">
-                  <p className="mb-0 fs-15 text-medium-gray">{pageCopy.step3.aftercarePanelLead}</p>
-                  <span className="simulation-count-badge">{pageCopy.step3.aftercareCount}</span>
-                </div>
-                <div className="simulation-template-grid simulation-aftercare-grid">
-                  {aftercareOptions.map((item) => (
-                    <label key={item.id} className="simulation-template-card">
-                      <input type="checkbox" name="aftercare-templates" value={item.id} checked readOnly />
-                      <img src={item.image} alt={item.title} />
-                      <span>{pageCopy.step3.aftercareItems[item.id as keyof typeof pageCopy.step3.aftercareItems] || item.title}</span>
-                    </label>
-                  ))}
-                </div>
+              <div className="mt-25px">
+                <label
+                  htmlFor="simulation-remark"
+                  className="fs-14 fw-700 text-dark-gray d-block mb-10px"
+                >
+                  {pageCopy.remark}
+                </label>
+                <textarea
+                  id="simulation-remark"
+                  className="form-control"
+                  value={remark}
+                  placeholder={pageCopy.remarkPlaceholder}
+                  onChange={(event) => setRemark(event.target.value)}
+                />
               </div>
             </div>
 
             <div className="customer-edit-actions workbench-panel border-radius-8px p-25px mt-4">
               <div>
-                <strong>{pageCopy.submitPanel.title}</strong>
-                <span>{pageCopy.submitPanel.lead}</span>
+                <strong>{pageCopy.submit}</strong>
+                <span>{pageCopy.lead}</span>
               </div>
               <div className="d-flex flex-wrap gap-2 align-items-center">
-                {error ? <span className="fs-14 text-red me-2" role="alert">{error}</span> : null}
-                <Link to={path('/dashboard')} className="btn btn-large btn-rounded btn-transparent-light-gray border border-1 border-color-dark-gray fw-700">{copy.common.cancel}</Link>
-                <button type="submit" disabled={submitting} className="btn btn-large btn-rounded btn-base-color btn-box-shadow fw-700"><i className="feather icon-feather-cpu me-10px"></i>{submitting ? '...' : pageCopy.submit}</button>
+                {error ? (
+                  <span className="fs-14 text-red me-2" role="alert">
+                    {error}
+                  </span>
+                ) : null}
+                <Link
+                  to={path('/dashboard')}
+                  className="btn btn-large btn-rounded btn-transparent-light-gray border border-1 border-color-dark-gray fw-700"
+                >
+                  {copy.common.cancel}
+                </Link>
+                <button
+                  type="submit"
+                  disabled={submitting || uploading}
+                  className="btn btn-large btn-rounded btn-base-color btn-box-shadow fw-700"
+                >
+                  <i className="feather icon-feather-cpu me-10px"></i>
+                  {submitting ? '...' : pageCopy.submit}
+                </button>
               </div>
             </div>
           </form>

+ 219 - 0
frontend/src/pages/dashboard/simulation/__tests__/SimulationResultPage.test.tsx

@@ -0,0 +1,219 @@
+import { act, render, screen } from '@testing-library/react'
+import { HelmetProvider } from 'react-helmet-async'
+import { MemoryRouter, Route, Routes } from 'react-router-dom'
+import type { ReactNode } from 'react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { SimulationResultPage } from '../SimulationResultPage'
+
+const { getSimulationById } = vi.hoisted(() => ({
+  getSimulationById: vi.fn(),
+}))
+
+vi.mock('../../../../layouts/DashboardLayout', () => ({
+  DashboardLayout: ({ children }: { children: ReactNode }) => <>{children}</>,
+}))
+
+vi.mock('../../../../services/api/simulations', () => ({
+  getSimulationById,
+}))
+
+vi.mock('../../../../shared/pageContext', () => ({
+  usePageContext: () => ({
+    locale: 'zh',
+    navigate: vi.fn(),
+    path: (path = '') => `/zh${path}`,
+    copy: {
+      simulationResultPage: {
+        title: 'AI发型模拟结果',
+        description: 'desc',
+        back: '返回',
+        regenerate: '重新发起模拟',
+        badge: '结果',
+        lead: 'lead',
+        anonymousTitle: '匿名模拟',
+        anonymousLead: '未绑定顾客档案',
+        original: {
+          badge: '顾客照片',
+          title: '本次完成效果',
+          customer: '顾客',
+          generatedAt: '施术日',
+          count: '生成数量',
+          countValue: '{{count}}个方案',
+        },
+        results: {
+          badge: '未来变化',
+          title: '剪发后变化预测结果',
+          count: '{{count}}个方案',
+        },
+        counters: {
+          total: '总模板数',
+          processing: '处理中',
+          success: '成功',
+          failed: '失败',
+        },
+        source: {
+          stylist: '我的模板',
+          system: '系统模板',
+        },
+        card: {
+          processing: '正在生成中,请稍候查看当前方案效果。',
+          success: '已完成生成,可查看当前方案效果。',
+          failed: '生成失败,请返回后重试。',
+        },
+      },
+    },
+  }),
+}))
+
+afterEach(() => {
+  vi.useRealTimers()
+})
+
+describe('SimulationResultPage', () => {
+  it('polls while overallStatus is PROCESSING and renders child task cards', async () => {
+    vi.useFakeTimers()
+    getSimulationById
+      .mockResolvedValueOnce({
+        id: '91',
+        customer: null,
+        originImage: 'https://cdn.example.com/origin.jpg',
+        simulateRemark: 'airy',
+      createdAt: '2026-06-28T09:00:00.000Z',
+        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',
+          },
+        ],
+      })
+
+    render(
+      <HelmetProvider>
+        <MemoryRouter initialEntries={['/zh/simulation/result/91']}>
+          <Routes>
+            <Route path="/:locale/simulation/result/:simulationId" element={<SimulationResultPage />} />
+          </Routes>
+        </MemoryRouter>
+      </HelmetProvider>,
+    )
+
+    await act(async () => {
+      await Promise.resolve()
+    })
+
+    expect(screen.getByText('Layer Bob')).toBeInTheDocument()
+    expect(screen.getByText('正在生成中,请稍候查看当前方案效果。')).toBeInTheDocument()
+
+    await act(async () => {
+      await vi.advanceTimersByTimeAsync(5000)
+    })
+
+    expect(getSimulationById).toHaveBeenCalledTimes(2)
+    expect(screen.getByText('已完成生成,可查看当前方案效果。')).toBeInTheDocument()
+  })
+
+  it('keeps only the back action and moves counters into the left origin panel', async () => {
+    getSimulationById.mockResolvedValueOnce({
+      id: '91',
+      customer: {
+        id: '5',
+        name: 'Mika',
+        avatar: 'https://cdn.example.com/avatar.png',
+        phone: '090-1111-2222',
+        expectContact: 'LINE',
+      },
+      createdAt: '2026-06-28T09:00:00.000Z',
+      originImage: 'https://cdn.example.com/origin.jpg',
+      simulateRemark: 'airy',
+      totalTaskCount: 2,
+      processingTaskCount: 1,
+      successTaskCount: 1,
+      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,
+        },
+      ],
+    })
+
+    const { container } = render(
+      <HelmetProvider>
+        <MemoryRouter initialEntries={['/zh/simulation/result/91']}>
+          <Routes>
+            <Route path="/:locale/simulation/result/:simulationId" element={<SimulationResultPage />} />
+          </Routes>
+        </MemoryRouter>
+      </HelmetProvider>,
+    )
+
+    expect(await screen.findByText('Mika')).toBeInTheDocument()
+    expect(screen.queryByRole('link', { name: '重新发起模拟' })).not.toBeInTheDocument()
+    expect(container.querySelector('.customer-detail-hero')).toBeNull()
+
+    const originPanel = container.querySelector('.simulation-result-layout aside')
+    expect(originPanel).not.toBeNull()
+    expect(originPanel).toHaveTextContent('顾客')
+    expect(originPanel).toHaveTextContent('施术日')
+    expect(originPanel).toHaveTextContent('2026/06/28')
+    expect(originPanel).toHaveTextContent('生成数量')
+    expect(originPanel).toHaveTextContent('2个方案')
+    expect(originPanel).not.toHaveTextContent('总模板数')
+    expect(originPanel).not.toHaveTextContent('处理中')
+    expect(originPanel).not.toHaveTextContent('成功')
+    expect(originPanel).not.toHaveTextContent('失败')
+  })
+})

+ 66 - 0
frontend/src/pages/dashboard/simulation/__tests__/SimulationShellPages.test.tsx

@@ -0,0 +1,66 @@
+import { render, screen } from '@testing-library/react'
+import { HelmetProvider } from 'react-helmet-async'
+import { MemoryRouter, Route, Routes } from 'react-router-dom'
+import { describe, expect, it, vi } from 'vitest'
+import '../../../../i18n'
+import { SimulationEditPage } from '../SimulationEditPage'
+import { AftercareResultPage } from '../AftercareResultPage'
+
+vi.mock('../../../../layouts/DashboardLayout', () => ({
+  DashboardLayout: ({ children }: { children: React.ReactNode }) => <>{children}</>,
+}))
+
+vi.mock('../../../../services/api/simulations', () => ({
+  adjustSimulationResult: vi.fn(),
+  getSimulationResults: vi.fn(),
+  uploadMedia: vi.fn(),
+  getSimulationJob: vi.fn(),
+  saveAftercareFollow: vi.fn(),
+}))
+
+vi.mock('../../../../shared/simulationSession', () => ({
+  simulationSession: {
+    getResultId: () => undefined,
+    getJobId: () => undefined,
+    getCustomerId: () => '',
+    setJob: vi.fn(),
+  },
+}))
+
+describe('simulation shell pages', () => {
+  it('renders SimulationEditPage as an undeveloped shell with a safe back link', async () => {
+    render(
+      <HelmetProvider>
+        <MemoryRouter initialEntries={['/zh/simulation/edit']}>
+          <Routes>
+            <Route path="/:locale/simulation/edit" element={<SimulationEditPage />} />
+          </Routes>
+        </MemoryRouter>
+      </HelmetProvider>,
+    )
+
+    expect(await screen.findByText('当前版本暂未开发')).toBeInTheDocument()
+    expect(screen.getAllByRole('link', { name: '返回AI发型模拟' })).toHaveLength(2)
+    screen.getAllByRole('link', { name: '返回AI发型模拟' }).forEach((link) => {
+      expect(link).toHaveAttribute('href', '/zh/simulation')
+    })
+  })
+
+  it('renders AftercareResultPage as an undeveloped shell with a safe back link', async () => {
+    render(
+      <HelmetProvider>
+        <MemoryRouter initialEntries={['/zh/simulation/aftercare']}>
+          <Routes>
+            <Route path="/:locale/simulation/aftercare" element={<AftercareResultPage />} />
+          </Routes>
+        </MemoryRouter>
+      </HelmetProvider>,
+    )
+
+    expect(await screen.findByText('当前版本暂未开发')).toBeInTheDocument()
+    expect(screen.getAllByRole('link', { name: '返回AI发型模拟' })).toHaveLength(2)
+    screen.getAllByRole('link', { name: '返回AI发型模拟' }).forEach((link) => {
+      expect(link).toHaveAttribute('href', '/zh/simulation')
+    })
+  })
+})

+ 156 - 0
frontend/src/pages/dashboard/simulation/__tests__/StylistSimulationPage.test.tsx

@@ -0,0 +1,156 @@
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { HelmetProvider } from 'react-helmet-async'
+import { MemoryRouter, Route, Routes } from 'react-router-dom'
+import type { ReactNode } from 'react'
+import { describe, expect, it, vi } from 'vitest'
+import { StylistSimulationPage } from '../StylistSimulationPage'
+
+const { listCustomers, listSimulationTemplates, createSimulation, uploadMedia, navigate } =
+  vi.hoisted(() => ({
+    listCustomers: vi.fn(),
+    listSimulationTemplates: vi.fn(),
+    createSimulation: vi.fn(),
+    uploadMedia: vi.fn(),
+    navigate: vi.fn(),
+  }))
+
+vi.mock('../../../../layouts/DashboardLayout', () => ({
+  DashboardLayout: ({ children }: { children: ReactNode }) => <>{children}</>,
+}))
+
+vi.mock('../../../../services/api/customers', () => ({
+  listCustomers,
+}))
+
+vi.mock('../../../../services/api/simulations', () => ({
+  listSimulationTemplates,
+  createSimulation,
+  uploadMedia,
+}))
+
+vi.mock('../../../../shared/pageContext', () => ({
+  TEMPLATE_LIMIT: 5,
+  usePageContext: () => ({
+    locale: 'zh',
+    navigate,
+    path: (path = '') => `/zh${path}`,
+    copy: {
+      common: { cancel: '取消' },
+      simulationCreatePage: {
+        title: 'AI发型模拟',
+        description: 'desc',
+        badge: 'AI发型模拟',
+        lead: 'lead',
+        back: '返回首页',
+        submit: '发起模拟',
+        bindCustomer: '绑定顾客',
+        noCustomer: '不绑定顾客',
+        customerLabel: '顾客',
+        customerPlaceholder: '请选择顾客',
+        upload: '上传顾客照片',
+        uploadHint: '支持 JPG / PNG',
+        remark: '模拟备注',
+        remarkPlaceholder: '输入模拟备注',
+        myTemplates: '我的模板',
+        systemTemplates: '系统模板',
+        selectedCount: '已选 {{count}} 个模板',
+        emptyTemplates: '暂无模板',
+        errors: {
+          originRequired: '请先上传顾客照片',
+          templateRequired: '请至少选择一个模板',
+          customerRequired: '请选择顾客',
+          submitFailed: '发起模拟失败,请稍后重试',
+        },
+      },
+    },
+  }),
+  usePreviewUrl: (_file: File | null, fallback: string) => fallback,
+}))
+
+describe('StylistSimulationPage', () => {
+  it('defaults to no customer when opened from dashboard', async () => {
+    listCustomers.mockResolvedValue({ items: [], total: 0, page: 1, pageSize: 10 })
+    listSimulationTemplates.mockResolvedValue({
+      items: [],
+      total: 0,
+      page: 1,
+      pageSize: 12,
+    })
+
+    render(
+      <HelmetProvider>
+        <MemoryRouter initialEntries={['/zh/simulation']}>
+          <Routes>
+            <Route path="/:locale/simulation" element={<StylistSimulationPage />} />
+          </Routes>
+        </MemoryRouter>
+      </HelmetProvider>,
+    )
+
+    expect(await screen.findByLabelText('不绑定顾客')).toBeChecked()
+    expect(screen.getByLabelText('顾客')).toBeDisabled()
+  })
+
+  it('preselects the customer from query string and uploads origin image with ai-user-origin-image', async () => {
+    listCustomers.mockResolvedValue({
+      items: [{ id: '5', name: 'Mika', phone: '090-0000-0000' }],
+      total: 1,
+      page: 1,
+      pageSize: 10,
+    })
+    listSimulationTemplates.mockResolvedValue({
+      items: [
+        {
+          id: '11',
+          templateName: 'Layer Bob',
+          coverUrl: '',
+          category: 'SHORT',
+          tags: [],
+          description: null,
+          templateUrl: 'https://cdn.example.com/template.zip',
+          isSystemTemplate: false,
+        },
+      ],
+      total: 1,
+      page: 1,
+      pageSize: 12,
+    })
+    uploadMedia.mockResolvedValue({ url: 'https://cdn.example.com/origin.jpg' })
+    createSimulation.mockResolvedValue({ simulationId: '91' })
+
+    render(
+      <HelmetProvider>
+        <MemoryRouter initialEntries={['/zh/simulation?customerId=5']}>
+          <Routes>
+            <Route path="/:locale/simulation" element={<StylistSimulationPage />} />
+          </Routes>
+        </MemoryRouter>
+      </HelmetProvider>,
+    )
+
+    await waitFor(() => {
+      expect(screen.getByLabelText('绑定顾客')).toBeChecked()
+    })
+    expect(screen.getByLabelText('顾客')).toHaveValue('5')
+
+    const file = new File(['image'], 'origin.png', { type: 'image/png' })
+    fireEvent.change(screen.getByLabelText('上传顾客照片'), {
+      target: { files: [file] },
+    })
+    fireEvent.click(await screen.findByRole('checkbox', { name: 'Layer Bob' }))
+    fireEvent.click(screen.getAllByRole('button', { name: '发起模拟' })[0])
+
+    await waitFor(() => {
+      expect(uploadMedia).toHaveBeenCalledWith(file, 'ai-user-origin-image')
+    })
+    await waitFor(() => {
+      expect(createSimulation).toHaveBeenCalledWith({
+        customerId: '5',
+        originImage: 'https://cdn.example.com/origin.jpg',
+        templateIds: ['11'],
+        simulateRemark: '',
+      })
+    })
+    expect(navigate).toHaveBeenCalledWith('/zh/simulation/result/91')
+  })
+})

+ 81 - 0
frontend/src/services/api/__tests__/simulations.test.ts

@@ -0,0 +1,81 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import {
+  createSimulation,
+  getSimulationById,
+  listCustomerSimulationGallery,
+  listSimulationTemplates,
+} from '../simulations'
+import { http } from '../../http/client'
+
+vi.mock('../../http/client', () => ({
+  http: {
+    get: vi.fn(),
+    post: vi.fn(),
+    patch: vi.fn(),
+  },
+  ApiError: class ApiError extends Error {
+    status = 500
+  },
+}))
+
+describe('simulations api', () => {
+  beforeEach(() => {
+    vi.mocked(http.get).mockReset()
+    vi.mocked(http.post).mockReset()
+  })
+
+  it('builds simulation detail requests with path params', async () => {
+    vi.mocked(http.get).mockResolvedValue({ id: '91' })
+
+    await getSimulationById('91')
+
+    expect(http.get).toHaveBeenCalledWith('/simulations/91')
+  })
+
+  it('serializes simulation template query params', async () => {
+    vi.mocked(http.get).mockResolvedValue({
+      items: [],
+      total: 0,
+      page: 1,
+      pageSize: 12,
+    })
+
+    await listSimulationTemplates({
+      source: 'SYSTEM',
+      category: 'SHORT',
+      q: 'bob',
+      page: 1,
+      pageSize: 12,
+    })
+
+    expect(http.get).toHaveBeenCalledWith(
+      '/simulations/template-options?source=SYSTEM&category=SHORT&q=bob&page=1&pageSize=12',
+    )
+  })
+
+  it('posts the new simulation payload shape', async () => {
+    vi.mocked(http.post).mockResolvedValue({ simulationId: '91' })
+
+    await createSimulation({
+      customerId: '5',
+      originImage: 'https://cdn.example.com/origin.jpg',
+      templateIds: ['11'],
+      simulateRemark: 'airy',
+    })
+
+    expect(http.post).toHaveBeenCalledWith('/simulations', {
+      customerId: '5',
+      originImage: 'https://cdn.example.com/origin.jpg',
+      templateIds: ['11'],
+      simulateRemark: 'airy',
+    })
+  })
+
+  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')
+  })
+})

+ 51 - 0
frontend/src/services/api/contracts.ts

@@ -77,6 +77,57 @@ export type SimulationTemplateEntity = {
   image: string
   category: string
 }
+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
+  createdAt: 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[]
+}
+export type CustomerSimulationGalleryItem = {
+  simulationId: string
+  taskId: string
+  imageUrl: string
+  templateName: string
+  createdAt: string
+  taskStatus: 'SUCCESSES'
+}
 export type StylistProfileTemplateEntity = MockStylistProfileTemplate
 export type StylistReviewCardEntity = MockStylistReviewCard
 export type CustomerHistoryEntity = MockCustomerHistory

+ 51 - 3
frontend/src/services/api/simulations.ts

@@ -1,6 +1,11 @@
 import { http, type ListEnvelope } from '../http/client'
 import { mockDb } from '../../mocks/db'
-import type { SimulationResultEntity } from './contracts'
+import type {
+  AiHairSimulationDetail,
+  CustomerSimulationGalleryItem,
+  SimulationResultEntity,
+  SimulationTemplateOption,
+} from './contracts'
 
 export { uploadMedia } from './media'
 
@@ -27,6 +32,13 @@ export type SimulationJob = {
 }
 
 export type CreateSimulationInput = {
+  customerId?: string
+  originImage: string
+  templateIds: string[]
+  simulateRemark?: string
+}
+
+export type LegacyCreateSimulationInput = {
   customerMode: 'selected' | 'guest'
   customerId?: string
   sourcePhoto: string
@@ -43,8 +55,44 @@ export type AdjustmentInput = {
   replacementPhoto?: string
 }
 
-export async function createSimulation(input: CreateSimulationInput): Promise<SimulationJob> {
-  return http.post<SimulationJob>('/simulations', input)
+export type ListSimulationTemplatesParams = {
+  source: 'STYLIST' | 'SYSTEM'
+  category?: 'LONG' | 'MEDIUM' | 'SHORT' | 'DYE'
+  q?: string
+  page?: number
+  pageSize?: number
+}
+
+export async function listSimulationTemplates(
+  params: ListSimulationTemplatesParams,
+): Promise<ListEnvelope<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 function createSimulation(input: CreateSimulationInput): Promise<{ simulationId: string }>
+export function createSimulation(input: LegacyCreateSimulationInput): Promise<SimulationJob>
+export async function createSimulation(
+  input: CreateSimulationInput | LegacyCreateSimulationInput,
+): Promise<{ simulationId: string } | SimulationJob> {
+  return http.post('/simulations', input)
+}
+
+export async function getSimulationById(simulationId: string): Promise<AiHairSimulationDetail> {
+  return http.get(`/simulations/${simulationId}`)
+}
+
+export async function listCustomerSimulationGallery(
+  customerId: string,
+): Promise<{ items: CustomerSimulationGalleryItem[] }> {
+  return http.get(`/simulations/customer/${customerId}/gallery`)
 }
 
 export async function getSimulationJob(jobId: number): Promise<SimulationJob> {