Browse Source

feat: complete ai hair simulation flow

ChenYu 4 tháng trước cách đây
mục cha
commit
511fe9f5f8

+ 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 '本地任务自增主键',

+ 1 - 0
backend/prisma/schema.prisma

@@ -143,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")

+ 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;
 }

+ 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[];
+};

+ 4 - 2
backend/src/modules/simulations/simulations.module.ts

@@ -1,12 +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],
+  imports: [AuthModule, PrismaModule, ConfigModule],
   controllers: [SimulationsController],
-  providers: [SimulationsService],
+  providers: [SimulationsService, AiSimulationClient],
 })
 export class SimulationsModule {}

+ 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);
   });

+ 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',

+ 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')
+  })
+})