فهرست منبع

feat: complete stylist simulation experience updates

chenyu 2 ماه پیش
والد
کامیت
55611da28d
46فایلهای تغییر یافته به همراه1242 افزوده شده و 521 حذف شده
  1. 5 0
      backend/.env.example
  2. 9 0
      backend/prisma/migrations/20260806_add_simulation_invite_code/migration.sql
  3. 3 0
      backend/prisma/migrations/20260806_backfill_simulation_invite_codes/migration.sql
  4. 3 0
      backend/prisma/migrations/20260806_randomize_simulation_invite_codes/migration.sql
  5. 8 7
      backend/prisma/schema.prisma
  6. 7 0
      backend/src/config/app.config.ts
  7. 3 0
      backend/src/config/env.schema.ts
  8. 2 1
      backend/src/modules/beta-applications/beta-applications.module.ts
  9. 74 2
      backend/src/modules/beta-applications/beta-applications.service.ts
  10. 31 8
      backend/src/modules/simulations/ai-simulation.client.ts
  11. 1 1
      backend/src/modules/simulations/dto/customer-simulation-gallery.dto.ts
  12. 45 0
      backend/src/modules/simulations/dto/public-simulation.dto.ts
  13. 32 0
      backend/src/modules/simulations/public-simulations.controller.ts
  14. 7 0
      backend/src/modules/simulations/simulations.controller.ts
  15. 2 1
      backend/src/modules/simulations/simulations.module.ts
  16. 5 2
      backend/src/modules/simulations/simulations.service.spec.ts
  17. 151 79
      backend/src/modules/simulations/simulations.service.ts
  18. 0 0
      backend/src/modules/templates/step2-assets.ts
  19. 74 7
      backend/src/modules/templates/template-task-files.ts
  20. 8 4
      backend/src/modules/templates/templates.service.ts
  21. 77 0
      frontend/public/css/aitori.css
  22. 1 0
      frontend/src/App.tsx
  23. 1 0
      frontend/src/config/routes.ts
  24. 66 6
      frontend/src/i18n/locales/ja/translation.json
  25. 61 2
      frontend/src/i18n/locales/zh/translation.json
  26. 20 2
      frontend/src/index.css
  27. 3 1
      frontend/src/layouts/DashboardLayout.tsx
  28. 4 0
      frontend/src/layouts/PublicLayout.tsx
  29. 73 27
      frontend/src/pages/customer/CustomerSimulationPage.tsx
  30. 72 23
      frontend/src/pages/customer/CustomerSimulationResultPage.tsx
  31. 12 11
      frontend/src/pages/customer/StylistProfilePage.tsx
  32. 34 8
      frontend/src/pages/dashboard/DashboardPage.tsx
  33. 11 2
      frontend/src/pages/dashboard/StylistProfileManagePage.tsx
  34. 34 1
      frontend/src/pages/dashboard/__tests__/DashboardPage.test.tsx
  35. 13 24
      frontend/src/pages/dashboard/customers/CustomerDetailPage.tsx
  36. 1 1
      frontend/src/pages/dashboard/customers/CustomerListPage.tsx
  37. 4 3
      frontend/src/pages/dashboard/customers/__tests__/CustomerDetailPage.test.tsx
  38. 12 11
      frontend/src/pages/dashboard/simulation/SimulationResultPage.tsx
  39. 1 1
      frontend/src/pages/dashboard/templates/TemplateListPage.tsx
  40. 156 217
      frontend/src/pages/dashboard/templates/TemplateMakeTaskPage.tsx
  41. 22 6
      frontend/src/pages/dashboard/templates/__tests__/TemplateMakeTaskPage.test.tsx
  42. 35 32
      frontend/src/pages/public/BetaApplyPage.tsx
  43. 21 27
      frontend/src/pages/public/LandingPage.tsx
  44. 5 3
      frontend/src/pages/public/LoginPage.tsx
  45. 1 1
      frontend/src/services/api/contracts.ts
  46. 32 0
      frontend/src/services/api/simulations.ts

+ 5 - 0
backend/.env.example

@@ -12,3 +12,8 @@ GCS_PROJECT_ID=app-aitori-jp
 GCS_CLIENT_EMAIL=oss-227@app-aitori-jp.iam.gserviceaccount.com
 GCS_PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\nreplace-me\n-----END PRIVATE KEY-----\n
 CUSTOMER_DEFAULT_AVATAR_URL=https://storage.googleapis.com/aitori-public/style/sample/066c7ba22ea149268444bc2d919d5b97.png
+
+# 内测申请通知邮件(Resend)
+RESEND_API_KEY=
+BETA_APPLICATION_EMAIL_FROM=Aitori Stylist <noreply@aitori.jp>
+BETA_APPLICATION_EMAIL_TO=info@aitori.jp

+ 9 - 0
backend/prisma/migrations/20260806_add_simulation_invite_code/migration.sql

@@ -0,0 +1,9 @@
+ALTER TABLE `hairstylist_user`
+  ADD COLUMN `simulation_invite_code` VARCHAR(8) NULL AFTER `password`;
+
+UPDATE `hairstylist_user`
+SET `simulation_invite_code` = 'K8HUY7SS'
+WHERE `email` = 'stylist@aitori.jp' AND `is_deleted` = 0;
+
+CREATE UNIQUE INDEX `hairstylist_user_simulation_invite_code_key`
+  ON `hairstylist_user`(`simulation_invite_code`);

+ 3 - 0
backend/prisma/migrations/20260806_backfill_simulation_invite_codes/migration.sql

@@ -0,0 +1,3 @@
+UPDATE `hairstylist_user`
+SET `simulation_invite_code` = UPPER(LPAD(HEX(`id`), 8, '0'))
+WHERE `simulation_invite_code` IS NULL AND `is_deleted` = 0;

+ 3 - 0
backend/prisma/migrations/20260806_randomize_simulation_invite_codes/migration.sql

@@ -0,0 +1,3 @@
+UPDATE `hairstylist_user`
+SET `simulation_invite_code` = UPPER(SUBSTRING(REPLACE(UUID(), '-', ''), 1, 8))
+WHERE `simulation_invite_code` REGEXP '^0{6}' AND `is_deleted` = 0;

+ 8 - 7
backend/prisma/schema.prisma

@@ -82,13 +82,14 @@ enum CustomerVisitPurpose {
 }
 
 model HairstylistUser {
-  id         BigInt   @id @default(autoincrement()) @db.UnsignedBigInt
-  username   String   @db.VarChar(50)
-  email      String   @db.VarChar(100)
-  password   String   @db.VarChar(100)
-  createTime DateTime @default(now()) @map("create_time")
-  updateTime DateTime @updatedAt @map("update_time")
-  isDeleted  Int      @default(0) @map("is_deleted") @db.UnsignedTinyInt
+  id                   BigInt   @id @default(autoincrement()) @db.UnsignedBigInt
+  username             String   @db.VarChar(50)
+  email                String   @db.VarChar(100)
+  password             String   @db.VarChar(100)
+  simulationInviteCode String?  @unique @map("simulation_invite_code") @db.VarChar(8)
+  createTime           DateTime @default(now()) @map("create_time")
+  updateTime           DateTime @updatedAt @map("update_time")
+  isDeleted            Int      @default(0) @map("is_deleted") @db.UnsignedTinyInt
 
   @@map("hairstylist_user")
 }

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

@@ -34,4 +34,11 @@ export default () => ({
       process.env.CUSTOMER_DEFAULT_AVATAR_URL ??
       'https://storage.googleapis.com/aitori-public/style/sample/066c7ba22ea149268444bc2d919d5b97.png',
   },
+  betaApplicationEmail: {
+    apiKey: process.env.RESEND_API_KEY ?? '',
+    from:
+      process.env.BETA_APPLICATION_EMAIL_FROM ??
+      'Aitori Stylist <noreply@aitori.jp>',
+    to: process.env.BETA_APPLICATION_EMAIL_TO ?? 'info@aitori.jp',
+  },
 });

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

@@ -23,6 +23,9 @@ export const envSchema = z.object({
     .default(
       'https://storage.googleapis.com/aitori-public/style/sample/066c7ba22ea149268444bc2d919d5b97.png',
     ),
+  RESEND_API_KEY: z.string().optional(),
+  BETA_APPLICATION_EMAIL_FROM: z.string().default('Aitori Stylist <noreply@aitori.jp>'),
+  BETA_APPLICATION_EMAIL_TO: z.string().email().default('info@aitori.jp'),
 });
 
 export function validateEnv(config: Record<string, unknown>) {

+ 2 - 1
backend/src/modules/beta-applications/beta-applications.module.ts

@@ -1,10 +1,11 @@
 import { Module } from '@nestjs/common';
 import { PrismaModule } from '../../prisma/prisma.module';
+import { ConfigModule } from '@nestjs/config';
 import { BetaApplicationsController } from './beta-applications.controller';
 import { BetaApplicationsService } from './beta-applications.service';
 
 @Module({
-  imports: [PrismaModule],
+  imports: [PrismaModule, ConfigModule],
   controllers: [BetaApplicationsController],
   providers: [BetaApplicationsService],
 })

+ 74 - 2
backend/src/modules/beta-applications/beta-applications.service.ts

@@ -1,10 +1,75 @@
-import { Injectable } from '@nestjs/common';
+import { Injectable, Logger } from '@nestjs/common';
+import { ConfigService } from '@nestjs/config';
 import { PrismaService } from '../../prisma/prisma.service';
 import { CreateBetaApplicationDto } from './dto/create-beta-application.dto';
 
 @Injectable()
 export class BetaApplicationsService {
-  constructor(private readonly prisma: PrismaService) {}
+  private readonly logger = new Logger(BetaApplicationsService.name);
+
+  constructor(
+    private readonly prisma: PrismaService,
+    private readonly configService: ConfigService,
+  ) {}
+
+  private escapeHtml(value: string): string {
+    return value
+      .replaceAll('&', '&amp;')
+      .replaceAll('<', '&lt;')
+      .replaceAll('>', '&gt;')
+      .replaceAll('"', '&quot;')
+      .replaceAll("'", '&#039;');
+  }
+
+  private async sendApplicationEmail(input: CreateBetaApplicationDto) {
+    const apiKey = this.configService.get<string>('betaApplicationEmail.apiKey');
+    const to = this.configService.getOrThrow<string>('betaApplicationEmail.to');
+    const from = this.configService.getOrThrow<string>('betaApplicationEmail.from');
+
+    if (!apiKey) {
+      this.logger.warn('RESEND_API_KEY is not configured; beta application email was not sent');
+      return false;
+    }
+
+    const fields = [
+      ['姓名', input.name],
+      ['邮箱', input.email],
+      ['沙龙', input.salonName],
+      ['地区', input.area],
+      ['身份', input.role],
+      ['联系方式', input.contact],
+      ['作品链接', input.portfolioUrl || '未填写'],
+      ['月顾客数', input.monthlyCustomers || '未填写'],
+      ['语言', input.language || '未填写'],
+      ['关注功能', input.features?.join('、') || '未填写'],
+      ['当前痛点', input.painPoint || '未填写'],
+    ];
+    const html = `<h2>Aitori Stylist 新的内测申请</h2><table cellpadding="8" cellspacing="0" border="1" style="border-collapse:collapse">${fields
+      .map(([label, value]) => `<tr><th align="left">${label}</th><td>${this.escapeHtml(value)}</td></tr>`)
+      .join('')}</table>`;
+
+    const response = await fetch('https://api.resend.com/emails', {
+      method: 'POST',
+      headers: {
+        Authorization: `Bearer ${apiKey}`,
+        'Content-Type': 'application/json',
+      },
+      body: JSON.stringify({
+        from,
+        to: [to],
+        reply_to: input.email.trim().toLowerCase(),
+        subject: `Aitori Stylist 内测申请:${input.name.trim()}`,
+        html,
+      }),
+      signal: AbortSignal.timeout(10000),
+    });
+
+    if (!response.ok) {
+      const detail = await response.text();
+      throw new Error(`Resend returned ${response.status}: ${detail}`);
+    }
+    return true;
+  }
 
   async create(input: CreateBetaApplicationDto) {
     const application = await this.prisma.betaApplication.create({
@@ -24,6 +89,13 @@ export class BetaApplicationsService {
       select: { id: true, createTime: true },
     });
 
+    try {
+      await this.sendApplicationEmail(input);
+    } catch (error) {
+      const reason = error instanceof Error ? error.message : 'unknown error';
+      this.logger.error(`Failed to send beta application ${application.id}: ${reason}`);
+    }
+
     return {
       id: application.id.toString(),
       submittedAt: application.createTime.toISOString(),

+ 31 - 8
backend/src/modules/simulations/ai-simulation.client.ts

@@ -1,17 +1,25 @@
-import { Injectable, ServiceUnavailableException } from '@nestjs/common';
+import {
+  Injectable,
+  Logger,
+  ServiceUnavailableException,
+} from '@nestjs/common';
 import { ConfigService } from '@nestjs/config';
 
 @Injectable()
 export class AiSimulationClient {
+  private readonly logger = new Logger(AiSimulationClient.name);
+
   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`,
-      {
+    const endpoint = `${this.configService.getOrThrow<string>('aiSimulation.baseUrl')}/execute`;
+    let response: Response;
+
+    try {
+      response = await fetch(endpoint, {
         method: 'POST',
         headers: { 'Content-Type': 'application/json' },
         body: JSON.stringify({
@@ -20,14 +28,29 @@ export class AiSimulationClient {
             images: [imageUrl],
           },
         }),
-      },
-    );
+      });
+    } catch (error) {
+      const message = error instanceof Error ? error.message : String(error);
+      this.logger.error(`Unable to reach AI simulation service: ${message}`);
+      throw new ServiceUnavailableException('AI 模拟服务暂时不可用,请稍后重试');
+    }
 
     if (!response.ok) {
-      throw new ServiceUnavailableException('AI 模拟服务暂时不可用');
+      const detail = await response.text().catch(() => '');
+      this.logger.warn(
+        `AI simulation execute request failed (${response.status}): ${detail.slice(0, 500)}`,
+      );
+      throw new ServiceUnavailableException('AI 模拟服务暂时不可用,请稍后重试');
+    }
+
+    let data: { taskId?: string };
+    try {
+      data = (await response.json()) as { taskId?: string };
+    } catch {
+      this.logger.warn('AI simulation execute response was not valid JSON');
+      throw new ServiceUnavailableException('AI 模拟任务创建失败,请稍后重试');
     }
 
-    const data = (await response.json()) as { taskId?: string };
     if (!data.taskId) {
       throw new ServiceUnavailableException('AI 模拟任务创建失败');
     }

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

@@ -4,5 +4,5 @@ export type CustomerSimulationGalleryItemDto = {
   imageUrl: string;
   templateName: string;
   createdAt: string;
-  taskStatus: 'SUCCESSES';
+  taskStatus: 'WAIT' | 'PROCESSING' | 'SUCCESSES' | 'FAILED';
 };

+ 45 - 0
backend/src/modules/simulations/dto/public-simulation.dto.ts

@@ -0,0 +1,45 @@
+import { Transform } from 'class-transformer';
+import {
+  ArrayMinSize,
+  IsArray,
+  IsOptional,
+  IsString,
+  IsUrl,
+  Length,
+  Matches,
+} from 'class-validator';
+
+const CODE_PATTERN = /^[A-Z0-9]{8}$/;
+
+export class PublicSimulationCodeQueryDto {
+  @IsOptional()
+  @Transform(({ value }) =>
+    typeof value === 'string' ? value.trim().toUpperCase() : value,
+  )
+  @IsString()
+  @Length(8, 8)
+  @Matches(CODE_PATTERN)
+  code?: string;
+}
+
+export class CreatePublicSimulationDto {
+  @Transform(({ value }) =>
+    typeof value === 'string' ? value.trim().toUpperCase() : value,
+  )
+  @IsString()
+  @Length(8, 8)
+  @Matches(CODE_PATTERN)
+  inviteCode!: string;
+
+  @IsUrl()
+  originImage!: string;
+
+  @IsArray()
+  @ArrayMinSize(1)
+  @IsString({ each: true })
+  templateIds!: string[];
+
+  @IsOptional()
+  @IsString()
+  simulateRemark?: string;
+}

+ 32 - 0
backend/src/modules/simulations/public-simulations.controller.ts

@@ -0,0 +1,32 @@
+import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
+import {
+  CreatePublicSimulationDto,
+  PublicSimulationCodeQueryDto,
+} from './dto/public-simulation.dto';
+import { SimulationsService } from './simulations.service';
+
+@Controller('public-simulations')
+export class PublicSimulationsController {
+  constructor(private readonly simulationsService: SimulationsService) {}
+
+  @Get('templates')
+  templates(@Query() query: PublicSimulationCodeQueryDto) {
+    return this.simulationsService.listPublicTemplateOptions(query.code);
+  }
+
+  @Post()
+  create(@Body() body: CreatePublicSimulationDto) {
+    return this.simulationsService.createPublicSimulation(body);
+  }
+
+  @Get(':simulationId')
+  detail(
+    @Param('simulationId') simulationId: string,
+    @Query() query: PublicSimulationCodeQueryDto,
+  ) {
+    return this.simulationsService.getPublicSimulationDetail(
+      simulationId,
+      query.code,
+    );
+  }
+}

+ 7 - 0
backend/src/modules/simulations/simulations.controller.ts

@@ -25,6 +25,13 @@ type AuthenticatedRequest = Request & {
 export class SimulationsController {
   constructor(private readonly simulationsService: SimulationsService) {}
 
+  @Get('invite-code')
+  inviteCode(@Req() request: AuthenticatedRequest) {
+    return this.simulationsService.getOrCreateInviteCode(
+      BigInt(request.user.stylistId),
+    );
+  }
+
   @Get('template-options')
   listTemplateOptions(
     @Req() request: AuthenticatedRequest,

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

@@ -5,10 +5,11 @@ import { AuthModule } from '../auth/auth.module';
 import { AiSimulationClient } from './ai-simulation.client';
 import { SimulationsController } from './simulations.controller';
 import { SimulationsService } from './simulations.service';
+import { PublicSimulationsController } from './public-simulations.controller';
 
 @Module({
   imports: [AuthModule, PrismaModule, ConfigModule],
-  controllers: [SimulationsController],
+  controllers: [SimulationsController, PublicSimulationsController],
   providers: [SimulationsService, AiSimulationClient],
 })
 export class SimulationsModule {}

+ 5 - 2
backend/src/modules/simulations/simulations.service.spec.ts

@@ -104,8 +104,8 @@ describe('SimulationsService', () => {
     prisma.hairTemplate.findMany.mockResolvedValue([
       {
         id: BigInt(21),
-        templateName: 'Classic Medium',
-        coverUrl: '',
+        templateName: '居家造型',
+        coverUrl: 'https://cdn.example.com/home-styling.zip',
         category: 'MEDIUM',
         tags: '',
         description: null,
@@ -131,6 +131,9 @@ describe('SimulationsService', () => {
       }),
     );
     expect(result.items[0].id).toBe('21');
+    expect(result.items[0].coverUrl).toBe(
+      '/images/mvp-simulation-result-10/home-styling.jpg',
+    );
   });
 
   it('creates one simulation and one task row per selected template', async () => {

+ 151 - 79
backend/src/modules/simulations/simulations.service.ts

@@ -12,27 +12,17 @@ import { CustomerSimulationGalleryItemDto } from './dto/customer-simulation-gall
 import { ListSimulationTemplateOptionsQueryDto } from './dto/list-simulation-templates-query.dto';
 import { AiHairSimulationDetailDto } from './dto/simulation-detail.dto';
 import { SimulationTemplateOptionDto } from './dto/simulation-template-option.dto';
-
-const SYSTEM_TEMPLATE_PRESETS = {
-  'system-after-30-days': {
-    name: '居家造型30天后造型',
-    image: '/images/mvp-simulation-result-10/after-30-days.jpg',
-  },
-  'system-home-styling': {
-    name: '居家造型',
-    image: '/images/mvp-simulation-result-10/home-styling.jpg',
-  },
-} as const;
-
-type SystemTemplatePresetId = keyof typeof SYSTEM_TEMPLATE_PRESETS;
-
-function isSystemTemplatePresetId(value: string): value is SystemTemplatePresetId {
-  return value in SYSTEM_TEMPLATE_PRESETS;
-}
+import { CreatePublicSimulationDto } from './dto/public-simulation.dto';
+import { randomBytes } from 'node:crypto';
 
 @Injectable()
 export class SimulationsService {
   private readonly logger = new Logger(SimulationsService.name);
+  private readonly systemTemplatePreviewUrls: Record<string, string> = {
+    居家造型: '/images/mvp-simulation-result-10/home-styling.jpg',
+    居家造型30天后造型: '/images/mvp-simulation-result-10/after-30-days.jpg',
+    居家造型60天后造型: '/images/mvp-simulation-result-10/after-60-days.jpg',
+  };
 
   constructor(
     private readonly prisma: PrismaService,
@@ -40,6 +30,121 @@ export class SimulationsService {
     private readonly configService: ConfigService,
   ) {}
 
+  private generateInviteCode(): string {
+    const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
+    const bytes = randomBytes(8);
+    return Array.from(bytes, (value) => alphabet[value % alphabet.length]).join('');
+  }
+
+  private resolveTemplateCoverUrl(template: {
+    templateName: string;
+    coverUrl: string;
+    isSystemTemplate: string;
+  }): string {
+    const fallback = this.systemTemplatePreviewUrls[template.templateName];
+    const isArchive = /\.zip(?:\?|$)/i.test(template.coverUrl);
+
+    if (template.isSystemTemplate === 'YES' && fallback && (!template.coverUrl || isArchive)) {
+      return fallback;
+    }
+
+    return template.coverUrl;
+  }
+
+  async getOrCreateInviteCode(
+    hairstylistId: bigint,
+  ): Promise<{ inviteCode: string }> {
+    const stylist = await this.prisma.hairstylistUser.findFirst({
+      where: { id: hairstylistId, isDeleted: 0 },
+      select: { id: true, simulationInviteCode: true },
+    });
+
+    if (!stylist) {
+      throw new NotFoundException('发型师账号不存在');
+    }
+
+    if (stylist.simulationInviteCode) {
+      return { inviteCode: stylist.simulationInviteCode };
+    }
+
+    for (let attempt = 0; attempt < 10; attempt += 1) {
+      const inviteCode = this.generateInviteCode();
+      try {
+        await this.prisma.hairstylistUser.update({
+          where: { id: stylist.id },
+          data: { simulationInviteCode: inviteCode },
+        });
+        return { inviteCode };
+      } catch (error) {
+        const reason = error instanceof Error ? error.message : '';
+        if (!reason.includes('Unique constraint')) throw error;
+      }
+    }
+
+    throw new BadRequestException('暂时无法生成模拟代码,请稍后重试');
+  }
+
+  private async resolvePublicStylist(code?: string) {
+    if (code) {
+      const stylist = await this.prisma.hairstylistUser.findFirst({
+        where: { simulationInviteCode: code, isDeleted: 0 },
+        select: { id: true, username: true, email: true, simulationInviteCode: true },
+      });
+      if (!stylist) throw new NotFoundException('模拟代码无效,请向发型师确认');
+      return stylist;
+    }
+
+    const preferred = await this.prisma.hairstylistUser.findFirst({
+      where: { email: 'stylist@aitori.jp', isDeleted: 0 },
+      select: { id: true, username: true, email: true, simulationInviteCode: true },
+    });
+    if (preferred) return preferred;
+
+    const fallback = await this.prisma.hairstylistUser.findFirst({
+      where: { isDeleted: 0 },
+      orderBy: { id: 'asc' },
+      select: { id: true, username: true, email: true, simulationInviteCode: true },
+    });
+    if (!fallback) throw new NotFoundException('暂时没有可用的发型师模板');
+    return fallback;
+  }
+
+  async listPublicTemplateOptions(code?: string) {
+    const stylist = await this.resolvePublicStylist(code);
+    const [stylistTemplates, systemTemplates] = await Promise.all([
+      this.listTemplateOptions(stylist.id, {
+        source: 'STYLIST', page: 1, pageSize: 100,
+      }),
+      this.listTemplateOptions(stylist.id, {
+        source: 'SYSTEM', page: 1, pageSize: 12,
+      }),
+    ]);
+
+    return {
+      stylist: {
+        name: stylist.username,
+        email: stylist.email,
+      },
+      stylistTemplates: stylistTemplates.items,
+      systemTemplates: systemTemplates.items,
+    };
+  }
+
+  async createPublicSimulation(dto: CreatePublicSimulationDto) {
+    const stylist = await this.resolvePublicStylist(dto.inviteCode);
+    return this.createSimulation(stylist.id, {
+      originImage: dto.originImage,
+      templateIds: dto.templateIds,
+      simulateRemark: dto.simulateRemark,
+    });
+  }
+
+  async getPublicSimulationDetail(simulationId: string, code?: string) {
+    if (!code) throw new BadRequestException('请提供模拟代码');
+    const stylist = await this.resolvePublicStylist(code);
+    return this.getSimulationDetail(stylist.id, simulationId);
+  }
+
   async listTemplateOptions(
     hairstylistId: bigint,
     query: ListSimulationTemplateOptionsQueryDto,
@@ -73,7 +178,7 @@ export class SimulationsService {
     const mappedItems = items.map((item) => ({
         id: item.id.toString(),
         templateName: item.templateName,
-        coverUrl: item.coverUrl,
+        coverUrl: this.resolveTemplateCoverUrl(item),
         category: item.category,
         tags: item.tags
           ? item.tags.split(',').map((tag) => tag.trim()).filter(Boolean)
@@ -82,27 +187,9 @@ export class SimulationsService {
         templateUrl: item.templateUrl,
         isSystemTemplate: item.isSystemTemplate === 'YES',
       }));
-    const systemFallbackItems = Object.entries(SYSTEM_TEMPLATE_PRESETS).map(
-      ([id, preset]) => ({
-        id,
-        templateName: preset.name,
-        coverUrl: preset.image,
-        category: 'LONG' as const,
-        tags: [],
-        description: null,
-        templateUrl: '',
-        isSystemTemplate: true,
-      }),
-    );
-    const systemItems = mappedItems.slice(0, 2);
-    if (systemItems.length < 2) {
-      systemItems.push(...systemFallbackItems.slice(0, 2 - systemItems.length));
-    }
-    const responseItems = query.source === 'SYSTEM' ? systemItems : mappedItems;
-
     return {
-      items: responseItems,
-      total: query.source === 'SYSTEM' ? responseItems.length : total,
+      items: mappedItems,
+      total,
       page: query.page,
       pageSize: query.pageSize,
     };
@@ -127,13 +214,9 @@ export class SimulationsService {
       }
     }
 
-    const presetIds = dto.templateIds.filter(isSystemTemplatePresetId);
-    const databaseTemplateIdValues = dto.templateIds.filter(
-      (value) => !isSystemTemplatePresetId(value),
-    );
     let templateIds: bigint[];
     try {
-      templateIds = databaseTemplateIdValues.map((value) => BigInt(value));
+      templateIds = dto.templateIds.map((value) => BigInt(value));
     } catch {
       throw new BadRequestException('部分模板不可用');
     }
@@ -150,15 +233,11 @@ export class SimulationsService {
       },
     });
 
-    if (templates.length + presetIds.length !== dto.templateIds.length) {
+    if (templates.length !== dto.templateIds.length) {
       throw new BadRequestException('部分模板不可用');
     }
 
-    const taskSeeds: Array<{
-      templateId: string;
-      taskId: string;
-      presetImage?: string;
-    }> = [];
+    const taskSeeds: Array<{ templateId: string; taskId: string }> = [];
     for (const template of templates) {
       const { taskId } = await this.aiSimulationClient.execute(
         template.templateUrl,
@@ -169,14 +248,6 @@ export class SimulationsService {
         taskId,
       });
     }
-    presetIds.forEach((presetId) => {
-      taskSeeds.push({
-        templateId: presetId,
-        taskId: `preset-${presetId}-${Date.now()}`,
-        presetImage: SYSTEM_TEMPLATE_PRESETS[presetId].image,
-      });
-    });
-
     return this.prisma.$transaction(async (tx) => {
       const simulation = await tx.aiHairSimulation.create({
         data: {
@@ -194,10 +265,10 @@ export class SimulationsService {
           templateId: seed.templateId,
           customerId: dto.customerId ? BigInt(dto.customerId) : null,
           taskId: seed.taskId,
-          taskStatus: seed.presetImage ? 'SUCCESSES' : 'PROCESSING',
-          resultImages: seed.presetImage ? JSON.stringify([seed.presetImage]) : null,
+          taskStatus: 'PROCESSING',
+          resultImages: null,
           taskStartTime: new Date(),
-          taskFinishTime: seed.presetImage ? new Date() : null,
+          taskFinishTime: null,
           errorMsg: null,
         })),
       });
@@ -407,17 +478,13 @@ export class SimulationsService {
       overallStatus: this.getOverallStatus(counts),
       tasks: freshTasks.map((task) => {
         const template = templateMap.get(task.templateId);
-        const preset = isSystemTemplatePresetId(task.templateId)
-          ? SYSTEM_TEMPLATE_PRESETS[task.templateId]
-          : null;
-
         return {
           id: task.id.toString(),
           templateId: task.templateId,
-          templateName: preset?.name ?? template?.templateName ?? 'Unknown Template',
-          templateCoverUrl: preset?.image ?? template?.coverUrl ?? '',
+          templateName: template?.templateName ?? 'Unknown Template',
+          templateCoverUrl: template?.coverUrl ?? '',
           templateCategory: template?.category ?? 'LONG',
-          templateSource: preset || template?.isSystemTemplate === 'YES' ? 'SYSTEM' : 'STYLIST',
+          templateSource: template?.isSystemTemplate === 'YES' ? 'SYSTEM' : 'STYLIST',
           taskId: task.taskId,
           taskStatus: task.taskStatus,
           resultImages: this.parseResultImages(task.resultImages),
@@ -456,6 +523,7 @@ export class SimulationsService {
       select: {
         id: true,
         createTime: true,
+        originImage: true,
       },
     });
 
@@ -468,7 +536,6 @@ export class SimulationsService {
         simulationId: {
           in: simulations.map((item) => item.id),
         },
-        taskStatus: 'SUCCESSES',
         isDeleted: 0,
       },
       select: {
@@ -501,6 +568,7 @@ export class SimulationsService {
           select: {
             id: true,
             templateName: true,
+            coverUrl: true,
           },
         })
       : [];
@@ -508,29 +576,33 @@ export class SimulationsService {
     const templateMap = new Map(
       templates.map((item) => [item.id.toString(), item.templateName]),
     );
-    const simulationMap = new Map(
-      simulations.map((item) => [item.id.toString(), item.createTime]),
+    const templateCoverMap = new Map(
+      templates.map((item) => [item.id.toString(), item.coverUrl]),
     );
+    const simulationMap = new Map(simulations.map((item) => [item.id.toString(), item]));
 
     return {
       items: tasks.flatMap((task) => {
-        const createdAt = simulationMap.get(task.simulationId.toString());
+        const simulation = simulationMap.get(task.simulationId.toString());
 
-        if (!createdAt) {
+        if (!simulation) {
           return [];
         }
 
-        return this.parseResultImages(task.resultImages).map((imageUrl) => ({
+        const imageUrls = this.parseResultImages(task.resultImages);
+        const previewUrls = imageUrls.length
+          ? imageUrls
+          : [templateCoverMap.get(task.templateId) || simulation.originImage];
+
+        return previewUrls.filter(Boolean).map((imageUrl) => ({
           simulationId: task.simulationId.toString(),
           taskId: task.taskId,
           imageUrl,
-          templateName: isSystemTemplatePresetId(task.templateId)
-            ? SYSTEM_TEMPLATE_PRESETS[task.templateId].name
-            : templateMap.get(task.templateId) ?? 'Unknown Template',
-          createdAt: createdAt.toISOString(),
-          taskStatus: 'SUCCESSES' as const,
+          templateName: templateMap.get(task.templateId) ?? 'Unknown Template',
+          createdAt: simulation.createTime.toISOString(),
+          taskStatus: task.taskStatus,
         }));
-      }),
+      }).sort((a, b) => b.createdAt.localeCompare(a.createdAt)),
     };
   }
 }

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
backend/src/modules/templates/step2-assets.ts


+ 74 - 7
backend/src/modules/templates/template-task-files.ts

@@ -14,6 +14,26 @@ type PrepareStep2BundleInput = {
   backgroundUrl: string;
 };
 
+type DownloadedImage = {
+  buffer: Buffer;
+  mimeType: string;
+  extension: string;
+};
+
+type MutableStep2Content = Record<string, unknown> & {
+  s21: { inputs: { files: string[] } };
+  s3: {
+    inputs: {
+      contents: Array<{
+        parts: Array<{
+          inline_data?: { mime_type: string; data: string };
+          text?: string;
+        }>;
+      }>;
+    };
+  };
+};
+
 @Injectable()
 export class TemplateTaskFiles {
   constructor(private readonly storageService: StorageService) {}
@@ -27,6 +47,30 @@ export class TemplateTaskFiles {
     return Buffer.from(await response.arrayBuffer());
   }
 
+  private async fetchImage(url: string): Promise<DownloadedImage> {
+    const response = await fetch(url);
+    if (!response.ok) {
+      throw new Error(`下载文件失败: ${url}`);
+    }
+
+    const declaredMime = response.headers?.get?.('content-type')?.split(';')[0] ?? '';
+    const mimeType = ['image/png', 'image/webp', 'image/avif'].includes(declaredMime)
+      ? declaredMime
+      : 'image/jpeg';
+    const extension = {
+      'image/png': 'png',
+      'image/webp': 'webp',
+      'image/avif': 'avif',
+      'image/jpeg': 'jpg',
+    }[mimeType] ?? 'jpg';
+
+    return {
+      buffer: Buffer.from(await response.arrayBuffer()),
+      mimeType,
+      extension,
+    };
+  }
+
   private async createWorkDir(totalTaskId: string): Promise<string> {
     const rootDir = join(tmpdir(), 'template-make');
     await mkdir(rootDir, { recursive: true });
@@ -72,22 +116,45 @@ export class TemplateTaskFiles {
     const workDir = await this.createWorkDir(input.totalTaskId);
     const copiedConfigPath = join(workDir, 'config.json');
     const copiedContentPath = join(workDir, 'content.json');
-    const hairstylePath = join(workDir, 'hairstyle.jpg');
-    const outfitPath = join(workDir, 'input-outfit.jpg');
-    const backgroundPath = join(workDir, 'input-background.jpg');
+    const [hairstyle, outfit, background] = await Promise.all([
+      this.fetchImage(input.step1ResultImg),
+      this.fetchImage(input.clothesUrl),
+      this.fetchImage(input.backgroundUrl),
+    ]);
+    const hairstylePath = join(workDir, `hairstyle.${hairstyle.extension}`);
+    const outfitPath = join(workDir, `input-outfit.${outfit.extension}`);
+    const backgroundPath = join(workDir, `input-background.${background.extension}`);
     const zipPath = join(workDir, 'template.zip');
 
+    const content = JSON.parse(JSON.stringify(STEP2_CONTENT)) as MutableStep2Content;
+    content.s21.inputs.files = [
+      `./${basename(hairstylePath)}`,
+      `./${basename(outfitPath)}`,
+      `./${basename(backgroundPath)}`,
+    ];
+    const imageParts = content.s3.inputs.contents[0]?.parts.filter(
+      (part) => part.inline_data,
+    ) ?? [];
+    [imageParts[1], imageParts[2], imageParts[3]].forEach((part, index) => {
+      if (!part?.inline_data) return;
+      part.inline_data.mime_type = [
+        hairstyle.mimeType,
+        outfit.mimeType,
+        background.mimeType,
+      ][index];
+    });
+
     await writeFile(
       copiedConfigPath,
       `${JSON.stringify(STEP2_CONFIG, null, 2)}\n`,
     );
     await writeFile(
       copiedContentPath,
-      `${JSON.stringify(STEP2_CONTENT, null, 2)}\n`,
+      `${JSON.stringify(content, null, 2)}\n`,
     );
-    await writeFile(hairstylePath, await this.fetchBuffer(input.step1ResultImg));
-    await writeFile(outfitPath, await this.fetchBuffer(input.clothesUrl));
-    await writeFile(backgroundPath, await this.fetchBuffer(input.backgroundUrl));
+    await writeFile(hairstylePath, hairstyle.buffer);
+    await writeFile(outfitPath, outfit.buffer);
+    await writeFile(backgroundPath, background.buffer);
     await this.zipFiles(
       [
         copiedConfigPath,

+ 8 - 4
backend/src/modules/templates/templates.service.ts

@@ -107,7 +107,7 @@ export class TemplatesService {
       coverUrl: string;
       usedCount: number;
     },
-    taskMap: Map<string, { id: bigint; taskStatus: string }>,
+    taskMap: Map<string, { id: bigint; taskStatus: string; refHairUrl: string }>,
   ) {
     const task = taskMap.get(template.id.toString());
 
@@ -123,7 +123,7 @@ export class TemplatesService {
             .map((tag) => tag.trim())
             .filter(Boolean)
         : [],
-      image: template.coverUrl,
+      image: template.coverUrl || task?.refHairUrl || '',
       makeTaskId: task?.id.toString(),
       makeTaskStatus: task?.taskStatus,
       usedCount: template.usedCount ?? 0,
@@ -545,14 +545,18 @@ export class TemplatesService {
         })
       : [];
 
-    const taskMap = new Map<string, { id: bigint; taskStatus: string }>();
+    const taskMap = new Map<string, { id: bigint; taskStatus: string; refHairUrl: string }>();
     for (const task of tasks) {
       if (!task.templateId) {
         continue;
       }
       const key = task.templateId.toString();
       if (!taskMap.has(key)) {
-        taskMap.set(key, { id: task.id, taskStatus: task.taskStatus });
+        taskMap.set(key, {
+          id: task.id,
+          taskStatus: task.taskStatus,
+          refHairUrl: task.refHairUrl,
+        });
       }
     }
 

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

@@ -349,6 +349,35 @@ header.sticky.sticky-active .disable-fixed[data-header-hover=light] {
     padding-top: 120px;
     padding-bottom: 0;
 }
+.dashboard-invite-code {
+    display: inline-flex;
+    align-items: center;
+    gap: 14px;
+    padding: 12px 14px;
+    border: 1px solid rgba(44, 46, 60, 0.08);
+    border-radius: 8px;
+    background: rgba(255, 255, 255, 0.78);
+    box-shadow: 0 8px 22px rgba(44, 46, 60, 0.05);
+}
+.dashboard-invite-code > div {
+    min-width: 0;
+}
+.dashboard-invite-code span {
+    display: block;
+    margin-bottom: 2px;
+    color: #82828f;
+    font-size: 12px;
+    font-weight: 600;
+}
+.dashboard-invite-code strong {
+    display: block;
+    color: #2c2e3c;
+    font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+    font-size: 20px;
+    font-weight: 800;
+    letter-spacing: 0.1em;
+    line-height: 1.1;
+}
 .workbench-action-card {
     display: block;
     color: inherit;
@@ -1049,6 +1078,47 @@ textarea.customer-edit-control {
     object-fit: cover;
     display: block;
 }
+.template-card-shell {
+    display: block;
+}
+.template-card-image {
+    position: relative;
+    overflow: hidden;
+}
+.template-card-image-link {
+    display: block;
+    color: inherit;
+}
+.template-card-image-selection {
+    position: absolute;
+    top: 14px;
+    right: 14px;
+    z-index: 2;
+    display: inline-flex;
+    align-items: center;
+    justify-content: center;
+    width: 30px;
+    height: 30px;
+    border-radius: 50%;
+    background: rgba(255, 255, 255, 0.92);
+    box-shadow: 0 2px 8px rgba(44, 46, 60, 0.16);
+}
+.template-card-checkbox {
+    width: 18px;
+    height: 18px;
+    margin: 0;
+    accent-color: var(--base-color);
+    cursor: pointer;
+}
+.template-card-image-overlay {
+    position: absolute;
+    left: 14px;
+    bottom: 14px;
+    z-index: 1;
+}
+.template-card-content {
+    padding: 18px;
+}
 .template-card-body {
     padding: 18px;
 }
@@ -1456,6 +1526,13 @@ textarea.customer-edit-control {
     font-size: 14px;
     line-height: 1.7;
 }
+.simulation-result-card-loading {
+    display: block;
+    width: 36px;
+    height: 36px;
+    margin: 12px 0 10px;
+    object-fit: contain;
+}
 .simulation-result-actions {
     display: flex;
     align-items: center;

+ 1 - 0
frontend/src/App.tsx

@@ -49,6 +49,7 @@ function App() {
         <Route path={routes.profileManage} element={<RequireAuth><StylistProfileManagePage /></RequireAuth>} />
         <Route path={routes.customerSimulation} element={<CustomerSimulationPage />} />
         <Route path={routes.customerSimulationResult} element={<CustomerSimulationResultPage />} />
+        <Route path={routes.customerSimulationResultDetail} element={<CustomerSimulationResultPage />} />
         <Route path={routes.betaApply} element={<BetaApplyPage />} />
         <Route path="*" element={<Navigate to={`/${DEFAULT_LOCALE}`} replace />} />
       </Routes>

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

@@ -20,6 +20,7 @@ export const routes = {
   profile: '/:locale/profile',
   customerSimulation: '/:locale/customer-simulation',
   customerSimulationResult: '/:locale/customer-simulation/result',
+  customerSimulationResultDetail: '/:locale/customer-simulation/result/:simulationId',
   betaApply: '/:locale/beta-apply',
   profileManage: '/:locale/profile/manage',
 }

+ 66 - 6
frontend/src/i18n/locales/ja/translation.json

@@ -167,6 +167,17 @@
     "title": "顧客向け AI シミュレーション",
     "subtitle": "登録またはログイン後、写真をアップロードしてテンプレートを選択します。"
   },
+  "customerSimulationFlow": {
+    "title": "写真をアップロードして、似合う髪型を試す", "description": "スタイリストの招待コードで公開テンプレートを選び、実際の AI ヘアシミュレーションを開始します。", "back": "スタイリストページへ戻る", "badge": "予約前にお試し", "lead": "スタイリストが公開したヘアスタイルを選び、AI でご自身の写真に適用します。来店前に希望の方向性を確認できます。", "viewResult": "シミュレーションを見る", "submitting": "シミュレーションを作成中...",
+    "step1": { "kicker": "コードを使用", "title": "スタイリストから受け取ったコードを入力", "cardTitle": "招待テストコード", "cardBody": "8 桁のコードはスタイリストのアカウントごとにシステムが発行します。適用すると公開テンプレートが読み込まれます。", "label": "シミュレーションコード", "placeholder": "例 K8HUY7SS", "apply": "コードを適用", "applying": "確認中...", "applied": "確認済み", "noCode": "コードがない場合は", "contact": "Aitori にお問い合わせください" },
+    "step2": { "title": "写真をアップロード", "alt": "アップロードした写真", "support": "JPG / PNG / WebP 対応" },
+    "step3": { "title": "試したいヘアスタイルを選択", "stylist": "現在のスタイリスト:{{name}}", "stylistTab": "スタイリストのデザイン", "systemTab": "Aitori システムテンプレート", "selected": "選択済み", "empty": "公開中のテンプレートはまだありません。", "codeRequired": "先にスタイリストから受け取ったシミュレーションコードを入力・適用してください。公開テンプレートが読み込まれます。" },
+    "action": { "title": "AI ヘアスタイルを確認", "body": "写真と選択したスタイルから実際の AI タスクを作成し、結果を表示します。" },
+    "errors": { "loadTemplates": "公開テンプレートを読み込めませんでした。", "codeLength": "8 桁のコードを入力してください。", "invalidCode": "コードが無効です。", "applyCode": "先にコードを適用してください。", "photo": "写真をアップロードしてください。", "template": "ヘアスタイルを 1 件以上選択してください。", "submit": "タスクを作成できませんでした。しばらくしてからお試しください。" }
+  },
+  "customerSimulationResultFlow": {
+    "title": "AI ヘアシミュレーション結果", "reselect": "選び直す", "profile": "スタイリストページへ", "startAgain": "最初からやり直す", "sessionExpired": "コードの情報が失われました。もう一度入力してください。", "loadFailed": "結果を読み込めませんでした。", "loading": "タスクを読み込み中", "loadingBody": "生成状況をまもなく表示します。", "yourPhoto": "あなたの写真", "generatedAt": "生成日時", "resultCount": "結果数", "status": "タスク状態", "count": "{{count}} 件の結果", "notice": "画像は予約前の参考です。最終的な髪型は髪質や長さ、カウンセリングにより調整されます。", "generating": "AI がヘアスタイルを生成中", "progress": "{{done}} / {{total}} 件完了。自動で更新します。", "systemTemplate": "Aitori システム", "stylistTemplate": "スタイリスト公開テンプレート", "resultNote": "来店時に長さや顔まわり、スタイリングの詳細をご相談ください。", "noResult": "利用できる結果がありません。もう一度お試しください。", "likeTitle": "気に入った方向はありますか?", "likeBody": "結果を参考に、来店時にスタイリストと具体的にご相談ください。", "tryAgain": "もう一度試す", "statusMap": { "PROCESSING": "生成中", "PARTIAL_SUCCESS": "一部完了", "SUCCESSES": "完了", "FAILED": "失敗" }
+  },
   "publicLayout": {
     "nav": {
       "profile": "プロフィール",
@@ -188,9 +199,14 @@
     "actions": {
       "newCustomer": { "title": "新規顧客", "body": "初回来店の顧客を登録" },
       "simulation": { "title": "AIヘアシミュレーション", "body": "写真撮影から提案まで開始" },
-      "templates": { "title": "マイテンプレート", "body": "登録済みスタイルを確認" }
+      "templates": { "title": "マイテンプレート", "body": "登録済みスタイルを確認" },
+      "profile": { "title": "プロフィール", "body": "公開ページを確認・編集" }
     },
+    "invite": { "label": "お客様シミュレーションコード", "copy": "コピー", "copied": "コピー済み" },
     "stats": {
+      "total": "登録数",
+      "simulated": "シミュレーション済み",
+      "published": "公開中",
       "todayCustomers": "本日の顧客",
       "followUp": "要フォロー",
       "weeklyRecords": "今週の記録",
@@ -310,7 +326,7 @@
       "uploadLabel": "モデル画像をアップロード"
     },
     "outfit": {
-      "badge": "服装",
+      "badge": "衣装",
       "title": "服装を選択",
       "preset": "プリセット服装を使う",
       "upload": "カスタム服装をアップロード",
@@ -400,6 +416,8 @@
     "resourceFallback": "プレビューはまだありません",
     "bundleReady": "テンプレートパックが生成されました",
     "bundlePending": "テンプレートパックは生成後に表示されます",
+    "history": { "closedTitle": "生成タスク情報を見る", "stepsTitle": "生成ステップ", "assetsTitle": "入力素材" },
+    "delete": { "button": "テンプレートを削除", "title": "テンプレートを削除しますか?", "body": "削除するとテンプレートと生成タスクは表示されなくなり、元に戻せません。", "confirm": "削除する", "failed": "削除に失敗しました" },
     "actions": {
       "editTemplate": "テンプレート情報を編集",
       "backToList": "テンプレート一覧へ戻る"
@@ -683,9 +701,12 @@
       "fallbacks": ["1994/08/18", "4〜6週間", "細毛 / 軽いうねり / 乾燥しやすい", "8Lvベージュ、赤み抑制、低刺激優先"]
     },
     "history": {
-      "badge": "施術履歴",
-      "title": "施術履歴",
-      "action": "記録を追加"
+      "badge": "シミュレーション履歴",
+      "title": "ヘアシミュレーション履歴",
+      "action": "結果を見る",
+      "templateSimulation": "ヘアスタイルテンプレートシミュレーション",
+      "aftercareSimulation": "施術後変化予測",
+      "status": { "WAIT": "待機中", "PROCESSING": "生成中", "SUCCESSES": "完了", "FAILED": "生成失敗" }
     },
     "photos": {
       "badge": "写真とシミュレーション",
@@ -765,7 +786,9 @@
       "templatePrefix": "テンプレート",
       "adjust": "微調整",
       "save": "保存"
-    }
+    },
+    "loading": "シミュレーション結果を読み込んでいます…",
+    "loadFailed": "読み込みに失敗しました"
   },
   "aftercareResultPage": {
     "title": "施術後変化予測結果",
@@ -1015,6 +1038,43 @@
     }
   },
   "footer": "Aitori Stylist React Refactor Demo",
+  "inviteLanding": {
+    "title": "Aitori Stylist | 美容師・サロン向けベータテスト募集中",
+    "description": "Aitori Stylist は美容師・サロンの皆さまと、予約前 AI ヘアシミュレーション、公開プロフィール、テンプレート、カウンセリング体験をテストしています。",
+    "menu": ["ホームページ例", "ヘアスタイル体験", "テスト管理画面", "ベータテストに申し込む"],
+    "menuOpen": "メニューを開く", "menuClose": "メニューを閉じる",
+    "meta": [{"label":"Status","title":"少数のテスト参加枠を受付中","body":"一緒にサービスを作り上げてくださる美容師・サロンを募集しています。"},{"label":"For","title":"美容師 / サロンオーナー","body":"カウンセリング体験、提案の成約率、長期的な顧客関係を大切にするチーム向けです。"},{"label":"Core","title":"来店前の見える提案","body":"来店前に方向性を確認できるため、カウンセリングを最初から具体的にできます。"},{"label":"Contact","title":"ご利用シーンを教えてください","body":"テスト期間中は無料です。正式リリース後は参加者向け割引をご用意します。"}],
+    "badge": "Aitori Stylist Beta", "headline": "あなたがデザインしたヘアスタイルを、", "headlineEmphasis": "お客様が AI で先に試せます。", "body": "Aitori Stylist は現在ベータテスト中です。お客様が来店前に自分の写真でヘアスタイルを試し、不安を減らし、予約数と満足度の向上を目指します。テスト期間中は無料です。", "apply": "テストに申し込む", "sample": "スタイリストページ例を見る", "noteLabel": "一緒に試す内容", "note": "予約前ヘア体験 · スタイリストページ · ヘアテンプレート · 顧客カウンセリング記録"
+  },
+  "publicStylistProfileDemo": {
+    "title": "Aitori Stylist | ヘアスタイルテンプレート", "description": "スタイリストがデザインしたヘアスタイルテンプレートを選び、ご自身の写真で AI ヘアシミュレーションを試せます。", "photoAlt": "スタイリストのプロフィール写真", "templateSuffix": "のヘアスタイルテンプレート", "heroTitle": "あなたの写真で、このスタイリストのヘアスタイルを試してみましょう。", "heroLead": "{{name}} がカウンセリングでよく提案するヘアスタイルを掲載しています。好きなスタイルを選んだ後、ご自身の写真をアップロードして AI で仕上がりイメージを確認できます。", "heroBody": "予約前に長さ、顔まわりのレイヤー、前髪、全体の雰囲気を確認するのに役立ちます。シミュレーション結果は来店時のカウンセリングの参考になります。", "try": "自分の写真で試す", "browse": "ヘアスタイルを見る", "stats": ["経験", "公開テンプレート", "評価"],
+    "actions": [{"title":"ヘアスタイルを選ぶ","body":"このスタイリストが公開しているおすすめのスタイルを見られます。"},{"title":"写真をアップロードして試す","body":"あなたの写真で AI シミュレーションを作成します。"},{"title":"スタイリストについて","body":"得意なスタイルやカウンセリングの特徴を確認できます。"}],
+    "templates": {"badge":"Style Templates","title":"試したいヘアスタイルを選ぶ","lead":"画像をクリックすると拡大できます。試したいスタイルを決めたら、AI ヘアシミュレーションへ進んで写真をアップロードしてください。","filters":["すべて","ロング","ミディアム","ショート"]},
+    "stylist": {"badge":"Stylist","lead":"長さやスタイルで迷っている場合は、まずテンプレートで試してから、結果をカウンセリングにお持ちください。","info":["サロン","営業時間","得意な提案"],"specialty":"顔まわりのレイヤー、ナチュラルロング、すっきりしたショート、扱いやすいスタイリング"},
+    "reviews": {"badge":"Voice","title":"お客様の声","items":[{"title":"来店前から安心できた","body":"シミュレーション画像を見てから美容師と相談できたので、希望のイメージを伝えやすかったです。"},{"title":"長さを比べやすい","body":"少し長い場合と短い場合の違いを先に確認でき、迷わずスタイルを決められました。"},{"title":"カウンセリングが具体的に","body":"結果を見ながら相談することで、変えたい部分をスタイリストにも伝えやすくなりました。"}]},
+    "cta": {"badge":"AI Try-on","title":"スタイルを選んだら、あなたの写真で試してみましょう。","body":"シミュレーション結果は最終的な仕上がりを保証するものではなく、スタイリストと方向性を合わせるための参考です。","button":"AI ヘアシミュレーションを始める","lightbox":"このスタイルを自分の写真で試す"}
+  },
+  "betaApplyPage": {
+    "title": "Aitori Stylist | ベータテストに申し込む",
+    "description": "Aitori Stylist のベータテスト申込ページです。美容師・サロン向けに、テスト期間中は無料でご利用いただけます。",
+    "menu": ["ベータテスト紹介", "ホームページ例", "ヘアスタイル体験", "テスト管理画面", "ベータテストに申し込む"],
+    "heroBadge": "Aitori Stylist Beta",
+    "heroTitle": "Aitori Stylist のベータテストに申し込む",
+    "lead": "予約前の AI ヘアシミュレーション、公開プロフィール、テンプレート、カウンセリング体験を一緒にテストしてくださる美容師・サロンを募集しています。",
+    "free": "テスト期間中は無料です。正式リリース後は、ベータテスト参加者向けの割引をご用意します。",
+    "points": ["予約前にお客様がヘアスタイルを試せる", "デザインしたヘアスタイルテンプレートを紹介できる", "カウンセリングをより具体的にできる"],
+    "formBadge": "Beta Application",
+    "formTitle": "ご利用シーンを教えてください",
+    "success": "お申し込みを受け付けました。ありがとうございます。テスト段階と地域に応じて順次ご連絡します。",
+    "error": "送信に失敗しました。時間をおいてもう一度お試しください。",
+    "fields": {"name": "お名前", "namePlaceholder": "お名前を入力", "email": "メールアドレス", "salon": "サロン名 / 店舗名", "salonPlaceholder": "例:Aitori Demo Salon", "area": "地域", "areaPlaceholder": "例:東京 / 大阪 / 上海", "role": "役割", "rolePlaceholder": "選択してください", "contact": "普段の連絡先", "contactPlaceholder": "LINE / Instagram / 電話番号", "url": "作品または予約ページの URL", "urlPlaceholder": "Instagram、Hot Pepper、公式サイト、予約ページなど", "monthly": "月間の接客人数", "language": "連絡言語", "features": "試したい機能", "painPoint": "今もっとも解決したいこと", "painPlaceholder": "例:来店前に希望を確認しにくい、カウンセリングに時間がかかる、新規予約を増やしたい"},
+    "roles": ["個人美容師", "サロンオーナー / 主宰者", "サロンスタッフ", "その他"],
+    "languages": ["中国語", "日本語", "英語"],
+    "features": ["予約前の AI ヘアシミュレーション", "美容師の公開プロフィール", "自分のヘアスタイルテンプレート", "顧客管理 / フォローアップ通知"],
+    "submitting": "送信中…",
+    "submit": "ベータテストに申し込む",
+    "submitNote": "お申し込み後、テスト段階と地域に応じて順次ご連絡します。"
+  },
   "marketing": {
     "htmlLang": "ja",
     "title": "Aitori Stylist | 美容師・美容室向けAIヘアシミュレーション&顧客管理",

+ 61 - 2
frontend/src/i18n/locales/zh/translation.json

@@ -167,6 +167,17 @@
     "title": "顾客自助模拟",
     "subtitle": "顾客注册或登录后,自助上传照片并选择模板体验。"
   },
+  "customerSimulationFlow": {
+    "title": "上传照片,先看看适不适合", "description": "使用发型师的邀请码,选择公开模板并发起真实 AI 发型模拟。", "back": "返回美发师主页", "badge": "预约前试试看", "lead": "选择这位发型师公开的发型参考,AI 会把发型套用到你的照片上。预约或到店咨询前,可以先确认想尝试的方向。", "viewResult": "查看模拟效果", "submitting": "正在创建模拟...",
+    "step1": { "kicker": "使用代码", "title": "输入美发师给你的模拟代码", "cardTitle": "邀请测试代码", "cardBody": "模拟代码由系统为每个发型师账号生成,共 8 位。使用后会载入该发型师公开发布的模板。", "label": "模拟代码", "placeholder": "例如 K8HUY7SS", "apply": "使用代码", "applying": "正在验证...", "applied": "代码已确认", "noCode": "没有代码?请", "contact": "联系 Aitori" },
+    "step2": { "title": "上传你的照片", "alt": "上传你的照片", "support": "支持 JPG / PNG / WebP" },
+    "step3": { "title": "选择想尝试的发型", "stylist": "当前发型师:{{name}}", "stylistTab": "美发师设计的发型", "systemTab": "Aitori 系统模板", "selected": "已选", "empty": "这位发型师暂时没有公开模板。", "codeRequired": "请先输入并使用美发师提供的模拟代码,系统会载入该美发师公开的发型模板。" },
+    "action": { "title": "查看 AI 发型效果", "body": "根据你的照片和选择的发型,创建真实 AI 任务并显示生成结果。" },
+    "errors": { "loadTemplates": "暂时无法载入公开模板。", "codeLength": "请输入 8 位模拟代码。", "invalidCode": "模拟代码无效。", "applyCode": "请先点击“使用代码”完成验证。", "photo": "请上传你的照片。", "template": "请至少选择一个想尝试的发型。", "submit": "模拟任务创建失败,请稍后重试。" }
+  },
+  "customerSimulationResultFlow": {
+    "title": "AI 试发型结果", "reselect": "重新选择", "profile": "返回美发师主页", "startAgain": "重新开始", "sessionExpired": "模拟代码已失效,请重新输入代码。", "loadFailed": "模拟结果载入失败。", "loading": "正在载入模拟任务", "loadingBody": "请稍候,马上显示生成进度。", "yourPhoto": "你的照片", "generatedAt": "生成时间", "resultCount": "生成数量", "status": "任务状态", "count": "{{count}} 个结果", "notice": "模拟图用于预约前参考。最终发型会根据发质、长度和现场咨询调整。", "generating": "AI 正在生成发型效果", "progress": "已完成 {{done}} / {{total}},页面会自动更新。", "systemTemplate": "Aitori 系统模板", "stylistTemplate": "发型师公开模板", "resultNote": "可保存此方向,到店时与发型师讨论长度、脸周和造型细节。", "noResult": "本次任务没有生成可用结果,请重新尝试。", "likeTitle": "喜欢这个方向吗?", "likeBody": "把模拟结果作为参考,到店时可以和发型师更具体地讨论。", "tryAgain": "再试一次", "statusMap": { "PROCESSING": "生成中", "PARTIAL_SUCCESS": "部分完成", "SUCCESSES": "已完成", "FAILED": "生成失败" }
+  },
   "publicLayout": {
     "nav": {
       "profile": "美发师主页",
@@ -191,7 +202,11 @@
       "templates": { "title": "我的发型模板", "body": "查看已登记的发型" },
       "profile": { "title": "个人主页", "body": "查看和编辑公开主页" }
     },
+    "invite": { "label": "顾客模拟邀请码", "copy": "复制", "copied": "已复制" },
     "stats": {
+      "total": "顾客总数",
+      "simulated": "已模拟",
+      "published": "公开中",
       "todayCustomers": "今日顾客",
       "followUp": "待跟进",
       "weeklyRecords": "本周记录",
@@ -403,6 +418,8 @@
     "resourceFallback": "暂无预览",
     "bundleReady": "模板包已生成",
     "bundlePending": "模板包生成后会显示",
+    "history": { "closedTitle": "查看生成任务信息", "stepsTitle": "生成步骤", "assetsTitle": "输入素材" },
+    "delete": { "button": "删除模板", "title": "确认删除模板?", "body": "删除后模板和生成任务将不再显示,且无法恢复。", "confirm": "确认删除", "failed": "删除失败" },
     "actions": {
       "editTemplate": "编辑模板信息",
       "backToList": "返回模板列表"
@@ -688,7 +705,10 @@
     "history": {
       "badge": "模拟历史",
       "title": "发型模拟历史",
-      "action": "添加记录"
+      "action": "查看结果",
+      "templateSimulation": "发型模板模拟",
+      "aftercareSimulation": "剪后变化预测",
+      "status": { "WAIT": "等待中", "PROCESSING": "生成中", "SUCCESSES": "已完成", "FAILED": "生成失败" }
     },
     "photos": {
       "badge": "照片与模拟",
@@ -768,7 +788,9 @@
       "templatePrefix": "模板",
       "adjust": "微调",
       "save": "保存"
-    }
+    },
+    "loading": "正在加载模拟结果…",
+    "loadFailed": "加载失败"
   },
   "aftercareResultPage": {
     "title": "剪发后变化预测结果",
@@ -1018,6 +1040,43 @@
     }
   },
   "footer": "Aitori Stylist React Refactor Demo",
+  "inviteLanding": {
+    "title": "Aitori Stylist | 发型设计师与沙龙内测招募中",
+    "description": "Aitori Stylist 正在邀请发型设计师与沙龙参加内测。一起打磨预约前 AI 试发型、公开主页、发型模板和顾客咨询体验。",
+    "menu": ["主页示例", "试发型体验", "测试后台", "申请内测"],
+    "menuOpen": "打开菜单", "menuClose": "关闭菜单",
+    "meta": [{"label":"Status","title":"少量名额开放中","body":"我们正在邀请愿意共创测试的发型设计师与沙龙。"},{"label":"For","title":"发型设计师 / 沙龙主理人","body":"适合重视咨询体验、提案转化和长期顾客关系的团队。"},{"label":"Core","title":"预约前可视化提案","body":"让顾客在到店前先看到方向,也让咨询从一开始更具体。"},{"label":"Contact","title":"告诉我们你的场景","body":"测试期间免费使用;正式上线后,内测参与者也会获得折扣价格。"}],
+    "badge": "Aitori Stylist Beta", "headline": "你设计的发型,", "headlineEmphasis": "让顾客先用 AI 试试看。", "body": "Aitori Stylist 正在内测。让顾客在预约前用自己的照片尝试你设计的发型,减少不安,提高预约数和客户满意度。测试期间免费,正式上线后内测参与者也会获得折扣价格。", "apply": "申请参与测试", "sample": "查看设计师主页示例", "noteLabel": "一起测试的内容", "note": "预约前试发型 · 设计师主页 · 发型模板 · 顾客咨询记录"
+  },
+  "publicStylistProfileDemo": {
+    "title": "Aitori Stylist | 发型模板", "description": "选择发型师设计的发型模板,并用自己的照片进行 AI 试发型。", "photoAlt": "发型师头像", "templateSuffix": "的发型模板", "heroTitle": "用你的照片,试试这位发型师设计的发型。", "heroLead": "这里收录了 {{name}} 常用于咨询的发型模板。选择喜欢的发型后,可以上传自己的照片,用 AI 先看看大致效果。", "heroBody": "适合在预约前确认长度、脸周层次、刘海和整体氛围。模拟结果可作为到店咨询时的参考。", "try": "用我的照片试发型", "browse": "先看发型模板", "stats": ["经验", "公开模板", "评分"],
+    "actions": [{"title":"选择发型模板","body":"浏览这位发型师公开的推荐发型。"},{"title":"上传照片试发型","body":"用你的照片生成 AI 模拟效果。"},{"title":"了解发型师","body":"查看擅长风格和咨询特点。"}],
+    "templates": {"badge":"Style Templates","title":"选择你想尝试的发型","lead":"点击图片可以放大查看。决定想试哪一款后,进入 AI 试发型流程,上传自己的照片进行模拟。","filters":["全部","长发","中长发","短发"]},
+    "stylist": {"badge":"Stylist","lead":"如果你还不确定适合什么长度或风格,可以先用模板模拟,再把结果带到咨询里一起讨论。","info":["沙龙","营业时间","擅长"],"specialty":"脸周层次、自然长发、清爽短发、易打理造型"},
+    "reviews": {"badge":"Voice","title":"顾客反馈","items":[{"title":"到店前就比较安心","body":"先看过模拟图,再和发型师讨论,比较容易说出自己想要的感觉。"},{"title":"更容易比较长度","body":"长一点、短一点的差别可以先看效果,决定发型时不会那么犹豫。"},{"title":"咨询更具体","body":"拿着模拟结果沟通,发型师也更容易理解我想改变的地方。"}]},
+    "cta": {"badge":"AI Try-on","title":"选好发型后,用你的照片试试看。","body":"模拟结果不是最终承诺,而是帮助你和发型师更快对齐方向的参考。","button":"开始 AI 试发型","lightbox":"用我的照片试这款"}
+  },
+  "betaApplyPage": {
+    "title": "Aitori Stylist | 申请参与内测",
+    "description": "申请参与 Aitori Stylist 内测。面向发型设计师与沙龙,测试期间免费,正式上线后内测参与者可获得折扣价格。",
+    "menu": ["内测介绍", "主页示例", "试发型体验", "测试后台", "申请内测"],
+    "heroBadge": "Aitori Stylist Beta",
+    "heroTitle": "申请参与 Aitori Stylist 内测",
+    "lead": "我们正在邀请发型设计师与沙龙一起测试预约前 AI 试发型、公开主页、发型模板和顾客咨询体验。",
+    "free": "测试期间免费使用。正式上线后,内测参与者也会获得折扣价格。",
+    "points": ["让顾客在预约前先试发型", "展示你设计的发型模板", "让咨询沟通更具体"],
+    "formBadge": "Beta Application",
+    "formTitle": "告诉我们你的场景",
+    "success": "申请已提交,感谢参与!我们会根据测试阶段和地区陆续联系。",
+    "error": "提交失败,请稍后重试",
+    "fields": {"name": "姓名", "namePlaceholder": "你的姓名", "email": "邮箱", "salon": "沙龙名 / 店铺名", "salonPlaceholder": "例:Aitori Demo Salon", "area": "所在地区", "areaPlaceholder": "例:东京 / 大阪 / 上海", "role": "你的角色", "rolePlaceholder": "请选择", "contact": "常用联系方式", "contactPlaceholder": "LINE / Instagram / 电话", "url": "作品或预约页面 URL", "urlPlaceholder": "Instagram、Hot Pepper、官网或预约页面", "monthly": "每月接待顾客数", "language": "联系语言", "features": "最想测试的功能", "painPoint": "现在最想解决的问题", "painPlaceholder": "例:顾客预约前不容易确认方向、咨询时间太长、想提高新客预约率"},
+    "roles": ["个人发型设计师", "沙龙负责人 / 主理人", "沙龙员工", "其他"],
+    "languages": ["中文", "日文", "英文"],
+    "features": ["顾客预约前 AI 试发型", "美发师公开主页", "自己的发型模板", "顾客管理 / 跟进提醒"],
+    "submitting": "提交中…",
+    "submit": "申请内测名额",
+    "submitNote": "提交后我们会根据测试阶段和地区陆续联系。"
+  },
   "marketing": {
     "htmlLang": "zh-CN",
     "title": "Aitori Stylist | 面向美发师与美发沙龙的AI发型模拟和顾客管理工具",

+ 20 - 2
frontend/src/index.css

@@ -210,10 +210,23 @@ textarea {
 }
 
 .locale-switcher {
-  padding: 0.55rem 0.9rem;
+  width: auto;
+  min-width: 0;
+  min-height: 32px;
+  height: 32px;
+  padding: 0 14px;
   border-radius: 999px;
-  border: 1px solid rgba(35, 39, 47, 0.1);
+  border: 1px solid var(--dark-gray);
   background: #fff;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  color: var(--dark-gray);
+  font-size: 13px;
+  font-weight: 700;
+  line-height: 1;
+  text-decoration: none;
+  white-space: nowrap;
 }
 
 .hero-section,
@@ -735,6 +748,11 @@ footer.footer-light {
   padding-bottom: calc(80px + var(--aitori-safe-bottom));
 }
 
+.beta-apply-navbar .container-fluid {
+  min-height: 86px;
+  align-items: center;
+}
+
 .aitori-invite-shell {
   min-height: 100svh;
   padding-top: calc(28px + var(--aitori-safe-top));

+ 3 - 1
frontend/src/layouts/DashboardLayout.tsx

@@ -4,6 +4,7 @@ import { Link, NavLink } from 'react-router-dom'
 import { useLegacyThemePage, usePageContext } from '../shared/pageContext'
 import { AppLogo } from '../components/common/AppLogo'
 import { logout } from '../services/api/auth'
+import { LocaleSwitcher } from '../components/common/LocaleSwitcher'
 
 function DashboardHeader() {
   const { locale, navigate, path, copy } = usePageContext()
@@ -51,7 +52,8 @@ function DashboardHeader() {
               </ul>
             </div>
           </div>
-          <div className="col-auto d-none d-lg-flex stylist-dashboard-actions">
+          <div className="col-auto d-flex stylist-dashboard-actions gap-2">
+            <LocaleSwitcher locale={locale} />
             <a href={path('/login')} onClick={handleLogout} className="login-header-link">{navCopy.logout}</a>
           </div>
         </div>

+ 4 - 0
frontend/src/layouts/PublicLayout.tsx

@@ -3,6 +3,7 @@ import type { ReactNode } from 'react'
 import { Link, useLocation } from 'react-router-dom'
 import { useLegacyThemePage, usePageContext } from '../shared/pageContext'
 import { AppLogo } from '../components/common/AppLogo'
+import { LocaleSwitcher } from '../components/common/LocaleSwitcher'
 
 function PublicHeader() {
   const { locale, path, copy } = usePageContext()
@@ -47,6 +48,9 @@ function PublicHeader() {
               </ul>
             </div>
           </div>
+          <div className="col-auto d-flex stylist-dashboard-actions gap-2">
+            <LocaleSwitcher locale={locale} />
+          </div>
         </div>
       </nav>
     </header>

+ 73 - 27
frontend/src/pages/customer/CustomerSimulationPage.tsx

@@ -3,23 +3,32 @@ import { Link } from 'react-router-dom'
 import { Helmet } from 'react-helmet-async'
 import { PublicLayout } from '../../layouts/PublicLayout'
 import { TEMPLATE_LIMIT, usePageContext, usePreviewUrl } from '../../shared/pageContext'
+import type { SimulationTemplateOption } from '../../services/api/contracts'
 import {
-  CUSTOMER_SIMULATION_PHOTO_KEY,
-  publicHairstyleTemplates,
-  systemHairstyleTemplates,
-} from '../../shared/demoContent'
+  createPublicSimulation,
+  listPublicSimulationTemplates,
+  uploadMedia,
+} from '../../services/api/simulations'
 import { DEFAULT_USER_AVATAR_URL } from '../../shared/uiAssets'
 
+export const CUSTOMER_SIMULATION_CODE_KEY = 'aitori-customer-simulation-code'
+
 export function CustomerSimulationPage() {
-  const { locale, navigate, path } = usePageContext()
+  const { locale, navigate, path, copy } = usePageContext()
+  const pageCopy = copy.customerSimulationFlow
   const [code, setCode] = useState('')
   const [codeChecked, setCodeChecked] = useState(false)
+  const [stylistName, setStylistName] = useState('Aitori Stylist')
   const [file, setFile] = useState<File | null>(null)
   const [source, setSource] = useState<'stylist' | 'system'>('stylist')
+  const [stylistTemplates, setStylistTemplates] = useState<SimulationTemplateOption[]>([])
+  const [systemTemplates, setSystemTemplates] = useState<SimulationTemplateOption[]>([])
   const [selectedTemplates, setSelectedTemplates] = useState<string[]>([])
+  const [applying, setApplying] = useState(false)
+  const [submitting, setSubmitting] = useState(false)
   const [error, setError] = useState('')
   const previewUrl = usePreviewUrl(file, DEFAULT_USER_AVATAR_URL)
-  const templates = source === 'stylist' ? publicHairstyleTemplates : systemHairstyleTemplates
+  const templates = source === 'stylist' ? stylistTemplates : systemTemplates
 
   const toggleTemplate = (templateId: string) => {
     setSelectedTemplates((current) => current.includes(templateId)
@@ -27,42 +36,79 @@ export function CustomerSimulationPage() {
       : current.length >= TEMPLATE_LIMIT ? current : [...current, templateId])
   }
 
-  const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
+  const handleApplyCode = async () => {
+    const normalizedCode = code.trim().toUpperCase()
+    if (normalizedCode.length !== 8) {
+      setError(pageCopy.errors.codeLength)
+      return
+    }
+    setApplying(true)
+    setError('')
+    try {
+      const data = await listPublicSimulationTemplates(normalizedCode)
+      setCode(normalizedCode)
+      setCodeChecked(true)
+      setStylistName(data.stylist.name)
+      setStylistTemplates(data.stylistTemplates)
+      setSystemTemplates(data.systemTemplates)
+      setSource('stylist')
+      setSelectedTemplates([])
+    } catch (err) {
+      setCodeChecked(false)
+      setStylistName('Aitori Stylist')
+      setStylistTemplates([])
+      setSystemTemplates([])
+      setSelectedTemplates([])
+      setError(err instanceof Error ? err.message : pageCopy.errors.invalidCode)
+    } finally {
+      setApplying(false)
+    }
+  }
+
+  const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
     event.preventDefault()
-    if (!code.trim()) return setError('请输入美发师给你的模拟代码。')
-    if (!file) return setError('请上传你的照片。')
-    if (!selectedTemplates.length) return setError('请至少选择一个想尝试的发型。')
+    if (!codeChecked) return setError(pageCopy.errors.applyCode)
+    if (!file) return setError(pageCopy.errors.photo)
+    if (!selectedTemplates.length) return setError(pageCopy.errors.template)
+
+    setSubmitting(true)
     setError('')
-    const reader = new FileReader()
-    reader.onload = () => {
-      try { window.sessionStorage.setItem(CUSTOMER_SIMULATION_PHOTO_KEY, String(reader.result || '')) } catch { /* keep demo fallback */ }
-      navigate(path('/customer-simulation/result'))
+    try {
+      const uploaded = await uploadMedia(file, 'customer-simulation-origin-image')
+      const result = await createPublicSimulation({
+        inviteCode: code,
+        originImage: uploaded.url,
+        templateIds: selectedTemplates,
+      })
+      window.sessionStorage.setItem(CUSTOMER_SIMULATION_CODE_KEY, code)
+      navigate(path(`/customer-simulation/result/${result.simulationId}`))
+    } catch (err) {
+      setError(err instanceof Error ? err.message : pageCopy.errors.submit)
+    } finally {
+      setSubmitting(false)
     }
-    reader.readAsDataURL(file)
   }
 
   return (
     <PublicLayout footer={false}>
-      <Helmet><html lang={locale === 'ja' ? 'ja' : 'zh-CN'} /><title>Aitori Stylist | 顾客 AI 发型试试看</title><meta name="description" content="上传自己的照片,尝试 Demo Stylist 设计的发型模板。" /></Helmet>
+      <Helmet><html lang={locale === 'ja' ? 'ja' : 'zh-CN'} /><title>{`Aitori Stylist | ${pageCopy.title}`}</title><meta name="description" content={pageCopy.description} /></Helmet>
       <main className="stylist-workbench">
         <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('/profile')} 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>返回美发师主页</Link><button type="submit" form="customer-simulation-form" className="btn btn-large btn-rounded btn-base-color btn-box-shadow fw-700"><i className="feather icon-feather-cpu me-10px"></i>查看模拟效果</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">预约前试试看</span><h1 className="fw-700 text-dark-gray ls-minus-1px mb-10px">上传照片,先看看适不适合</h1><p className="fs-18 lh-30 mb-0">选择这位美发师公开的发型参考,AI 会把发型套用到你的照片上。预约或到店咨询前,可以先确认想尝试的方向。</p></div>
+          <div className="d-flex align-items-center justify-content-between flex-wrap gap-3 mb-4"><Link to={path('/profile')} 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="customer-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.submitting : pageCopy.viewResult}</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><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"><form id="customer-simulation-form" onSubmit={handleSubmit}>
           <div className="simulation-step workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px mb-4">
-            <div className="simulation-step-heading"><span>1</span><div><span className="fs-14 fw-700 text-base-color">使用代码</span><h2 className="fw-700 text-dark-gray fs-26 mb-0">输入美发师给你的模拟代码</h2></div></div>
-            <div className="simulation-choice-grid mt-25px mb-25px"><div className="simulation-choice-card is-static"><span className="simulation-choice-icon"><i className="feather icon-feather-gift"></i></span><strong>邀请测试代码</strong><small>现在是内测阶段,请使用美发师或 Aitori 提供的代码体验 AI 发型模拟。</small></div></div>
-            <div className="row g-3"><div className="col-md-8"><label className="form-label fw-700 text-dark-gray" htmlFor="stylist-credit-code">模拟代码</label><input id="stylist-credit-code" type="text" className="form-control border-radius-8px" placeholder="输入美发师给你的代码" value={code} onChange={(event) => { setCode(event.target.value); setCodeChecked(false) }} required /></div><div className="col-md-4 d-flex align-items-end"><button type="button" className="btn btn-large btn-rounded btn-base-color btn-box-shadow fw-700 w-100" onClick={() => setCodeChecked(Boolean(code.trim()))}><i className="feather icon-feather-check-circle me-10px"></i>{codeChecked ? '代码已确认' : '使用代码'}</button></div><div className="col-12"><div className="simulation-note-box"><i className="feather icon-feather-info"></i><span>没有代码?请<Link to={path('/beta-apply')}>联系 Aitori</Link>申请内测体验。</span></div></div></div>
+            <div className="simulation-step-heading"><span>1</span><div><span className="fs-14 fw-700 text-base-color">{pageCopy.step1.kicker}</span><h2 className="fw-700 text-dark-gray fs-26 mb-0">{pageCopy.step1.title}</h2></div></div>
+            <div className="simulation-choice-grid mt-25px mb-25px"><div className="simulation-choice-card is-static"><span className="simulation-choice-icon"><i className="feather icon-feather-gift"></i></span><strong>{pageCopy.step1.cardTitle}</strong><small>{pageCopy.step1.cardBody}</small></div></div>
+            <div className="row g-3"><div className="col-md-8"><label className="form-label fw-700 text-dark-gray" htmlFor="stylist-credit-code">{pageCopy.step1.label}</label><input id="stylist-credit-code" type="text" className="form-control border-radius-8px text-uppercase" maxLength={8} placeholder={pageCopy.step1.placeholder} value={code} onChange={(event) => { setCode(event.target.value.toUpperCase().replace(/[^A-Z0-9]/g, '')); setCodeChecked(false); setStylistName('Aitori Stylist'); setStylistTemplates([]); setSystemTemplates([]); setSelectedTemplates([]) }} required /></div><div className="col-md-4 d-flex align-items-end"><button type="button" disabled={applying} className="btn btn-large btn-rounded btn-base-color btn-box-shadow fw-700 w-100" onClick={() => void handleApplyCode()}><i className="feather icon-feather-check-circle me-10px"></i>{applying ? pageCopy.step1.applying : codeChecked ? pageCopy.step1.applied : pageCopy.step1.apply}</button></div><div className="col-12"><div className="simulation-note-box"><i className="feather icon-feather-info"></i><span>{pageCopy.step1.noCode}<Link to={path('/beta-apply')}>{pageCopy.step1.contact}</Link></span></div></div></div>
           </div>
-          <div className="simulation-step workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px mb-4"><div className="simulation-step-heading"><span>2</span><div><h2 className="fw-700 text-dark-gray fs-26 mb-0">上传你的照片</h2></div></div><label htmlFor="customer-simulation-photo-upload" className="simulation-photo-upload mt-25px"><input id="customer-simulation-photo-upload" type="file" accept="image/*" onChange={(event) => setFile(event.target.files?.[0] || null)} required /><img src={previewUrl} alt="上传你的照片" /><span><i className="feather icon-feather-upload me-8px"></i>支持 JPG / PNG</span></label></div>
+          <div className="simulation-step workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px mb-4"><div className="simulation-step-heading"><span>2</span><div><h2 className="fw-700 text-dark-gray fs-26 mb-0">{pageCopy.step2.title}</h2></div></div><label htmlFor="customer-simulation-photo-upload" className="simulation-photo-upload mt-25px"><input id="customer-simulation-photo-upload" type="file" accept="image/jpeg,image/png,image/webp" onChange={(event) => setFile(event.target.files?.[0] || null)} required /><img src={previewUrl} alt={pageCopy.step2.alt} /><span><i className="feather icon-feather-upload me-8px"></i>{pageCopy.step2.support}</span></label></div>
           <div className="simulation-step workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px">
-            <div className="simulation-step-heading"><span>3</span><div><h2 className="fw-700 text-dark-gray fs-26 mb-0">选择想尝试的发型</h2></div></div>
-            <div className="simulation-template-tabs mt-25px mb-20px"><label><input type="radio" name="template-source" checked={source === 'stylist'} onChange={() => setSource('stylist')} /><span><i className="feather icon-feather-grid"></i>美发师设计的发型</span></label><label><input type="radio" name="template-source" checked={source === 'system'} onChange={() => setSource('system')} /><span><i className="feather icon-feather-layers"></i>Aitori系统模板</span></label></div>
-            <div className="d-flex align-items-center justify-content-end flex-wrap gap-2 mb-25px"><span className="simulation-count-badge">已选 <strong>{selectedTemplates.length}</strong> 个发型</span></div>
-            <div className={`simulation-template-grid mb-30px${source === 'system' ? ' simulation-system-template-grid' : ''}`}>{templates.map((template) => { const active = selectedTemplates.includes(template.id); return <label key={template.id} className="simulation-template-card"><input type="checkbox" checked={active} disabled={!active && selectedTemplates.length >= TEMPLATE_LIMIT} onChange={() => toggleTemplate(template.id)} /><img src={template.image} alt={template.name} /><span>{template.name}</span></label> })}</div>
+            <div className="simulation-step-heading"><span>3</span><div><h2 className="fw-700 text-dark-gray fs-26 mb-0">{pageCopy.step3.title}</h2>{codeChecked ? <small>{pageCopy.step3.stylist.replace('{{name}}', stylistName)}</small> : null}</div></div>
+            {codeChecked ? <><div className="simulation-template-tabs mt-25px mb-20px"><label><input type="radio" name="template-source" checked={source === 'stylist'} onChange={() => setSource('stylist')} /><span><i className="feather icon-feather-grid"></i>{pageCopy.step3.stylistTab}</span></label><label><input type="radio" name="template-source" checked={source === 'system'} onChange={() => setSource('system')} /><span><i className="feather icon-feather-layers"></i>{pageCopy.step3.systemTab}</span></label></div><div className="d-flex align-items-center justify-content-end flex-wrap gap-2 mb-25px"><span className="simulation-count-badge">{pageCopy.step3.selected} <strong>{selectedTemplates.length}</strong></span></div>{templates.length ? <div className={`simulation-template-grid mb-30px${source === 'system' ? ' simulation-system-template-grid' : ''}`}>{templates.map((template) => { const active = selectedTemplates.includes(template.id); return <label key={template.id} className="simulation-template-card"><input type="checkbox" checked={active} disabled={!active && selectedTemplates.length >= TEMPLATE_LIMIT} onChange={() => toggleTemplate(template.id)} /><img src={template.coverUrl} alt={template.templateName} /><span>{template.templateName}</span></label> })}</div> : <p className="text-medium-gray">{pageCopy.step3.empty}</p>}</> : <div className="simulation-note-box mt-25px"><i className="feather icon-feather-lock"></i><span>{pageCopy.step3.codeRequired}</span></div>}
           </div>
-          <div className="customer-edit-actions workbench-panel border-radius-8px p-25px mt-4"><div><strong>查看 AI 发型效果</strong><span>根据你的照片和选择的发型,生成预约前可参考的模拟图。</span>{error ? <span className="text-red mt-5px" role="alert">{error}</span> : null}</div><div className="d-flex flex-wrap gap-2"><Link to={path('/profile')} className="btn btn-large btn-rounded btn-transparent-light-gray border border-1 border-color-dark-gray fw-700">取消</Link><button type="submit" className="btn btn-large btn-rounded btn-base-color btn-box-shadow fw-700"><i className="feather icon-feather-cpu me-10px"></i>查看模拟效果</button></div></div>
+          <div className="customer-edit-actions workbench-panel border-radius-8px p-25px mt-4"><div><strong>{pageCopy.action.title}</strong><span>{pageCopy.action.body}</span>{error ? <span className="text-red mt-5px" role="alert">{error}</span> : null}</div><div className="d-flex flex-wrap gap-2"><Link to={path('/profile')} 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.submitting : pageCopy.viewResult}</button></div></div>
         </form></div></section>
       </main>
     </PublicLayout>

+ 72 - 23
frontend/src/pages/customer/CustomerSimulationResultPage.tsx

@@ -1,37 +1,86 @@
-import { useState } from 'react'
-import { Link } from 'react-router-dom'
+import { useEffect, useMemo, useState } from 'react'
+import { Link, useParams } from 'react-router-dom'
 import { Helmet } from 'react-helmet-async'
 import { PublicLayout } from '../../layouts/PublicLayout'
 import { ImageLightbox } from '../../components/common/ImageLightbox'
-import { demoSimulationResults } from '../../shared/demoContent'
+import type { AiHairSimulationDetail } from '../../services/api/contracts'
+import { getPublicSimulationById } from '../../services/api/simulations'
 import { usePageContext } from '../../shared/pageContext'
-import { CUSTOMER_SIMULATION_PHOTO_KEY } from '../../shared/demoContent'
+import { CUSTOMER_SIMULATION_CODE_KEY } from './CustomerSimulationPage'
 
 export function CustomerSimulationResultPage() {
-  const { locale, path } = usePageContext()
+  const { locale, path, copy } = usePageContext()
+  const { simulationId } = useParams<{ simulationId: string }>()
+  const pageCopy = copy.customerSimulationResultFlow
+  const [detail, setDetail] = useState<AiHairSimulationDetail | null>(null)
+  const [error, setError] = useState('')
   const [preview, setPreview] = useState<{ image: string; alt: string } | null>(null)
-  const sourcePhoto = (() => {
-    try { return window.sessionStorage.getItem(CUSTOMER_SIMULATION_PHOTO_KEY) || '/images/mvp-simulation-result-10/source-photo.jpg' } catch { return '/images/mvp-simulation-result-10/source-photo.jpg' }
-  })()
+
+  useEffect(() => {
+    let cancelled = false
+    let timer: number | undefined
+    const inviteCode = window.sessionStorage.getItem(CUSTOMER_SIMULATION_CODE_KEY) || ''
+
+    const load = async () => {
+      if (!simulationId || !inviteCode) {
+        setError(pageCopy.sessionExpired)
+        return
+      }
+      try {
+        const data = await getPublicSimulationById(simulationId, inviteCode)
+        if (cancelled) return
+        setDetail(data)
+        setError('')
+        if (data.overallStatus === 'PROCESSING') {
+          timer = window.setTimeout(() => void load(), 3000)
+        }
+      } catch (err) {
+        if (!cancelled) setError(err instanceof Error ? err.message : pageCopy.loadFailed)
+      }
+    }
+    void load()
+    return () => {
+      cancelled = true
+      if (timer) window.clearTimeout(timer)
+    }
+  }, [pageCopy.loadFailed, pageCopy.sessionExpired, simulationId])
+
+  const results = useMemo(() => detail?.tasks.flatMap((task) =>
+    task.resultImages.map((image, index) => ({
+      id: `${task.id}-${index}`,
+      image,
+      title: task.templateName,
+      source: task.templateSource,
+    })),
+  ) ?? [], [detail])
+
+  const createdAt = detail?.createdAt
+    ? new Intl.DateTimeFormat(locale === 'ja' ? 'ja-JP' : 'zh-CN', { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(detail.createdAt))
+    : '--'
 
   return (
     <PublicLayout footer={false}>
-      <Helmet><html lang={locale === 'ja' ? 'ja' : 'zh-CN'} /><title>Aitori Stylist | AI 试发型结果</title></Helmet>
+      <Helmet><html lang={locale === 'ja' ? 'ja' : 'zh-CN'} /><title>{`Aitori Stylist | ${pageCopy.title}`}</title></Helmet>
       <main className="stylist-workbench">
-        <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('/customer-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>重新选择</Link><Link to={path('/profile')} className="btn btn-large btn-rounded btn-base-color btn-box-shadow fw-700"><i className="feather icon-feather-user me-10px"></i>返回美发师主页</Link></div></div></section>
-        <section className="pt-0"><div className="container"><div className="simulation-result-layout gallery-box">
-          <aside className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-25px">
-            <h2 className="fw-700 text-dark-gray fs-24 mb-20px">你的照片</h2>
-            <button type="button" className="simulation-original-card aitori-image-button" onClick={() => setPreview({ image: sourcePhoto, alt: '你的照片' })}><img className="simulation-result-image" src={sourcePhoto} alt="上传的照片" /></button>
-            <div className="template-detail-summary mt-20px"><div><span>发型师</span><strong>Demo Stylist</strong></div><div><span>生成时间</span><strong>2026/07/03</strong></div><div><span>生成数量</span><strong>3个模拟结果</strong></div></div>
-            <div className="simulation-note-box mt-20px"><i className="feather icon-feather-info"></i><span>模拟图用于预约前参考。最终发型会根据发质、长度和现场咨询调整。</span></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">Demo Result</span><h1 className="fw-700 text-dark-gray fs-26 mb-0">AI 试发型结果</h1></div><span className="simulation-count-badge">3个结果</span></div>
-            <div className="simulation-result-grid">{demoSimulationResults.map((item) => <article key={item.id} className="simulation-result-card"><button type="button" className="aitori-image-button" onClick={() => setPreview({ image: item.image, alt: item.title })}><img className="simulation-result-image" src={item.image} alt={item.title} /></button><div className="simulation-result-body"><h3>{item.title}</h3><span className="template-status-badge">Aitori系统模板</span><p>{item.customerNote}</p></div></article>)}</div>
-            <div className="customer-edit-actions border-radius-8px p-25px mt-4"><div><strong>喜欢这个方向吗?</strong><span>把模拟结果作为参考,到店时可以和 Demo Stylist 更具体地讨论长度、脸周和造型细节。</span></div><div className="d-flex flex-wrap gap-2"><Link to={path('/customer-simulation')} className="btn btn-large btn-rounded btn-transparent-light-gray border border-1 border-color-dark-gray fw-700">再试一次</Link><Link to={path('/profile')} className="btn btn-large btn-rounded btn-base-color btn-box-shadow fw-700">返回美发师主页</Link></div></div>
-          </div>
-        </div></div></section>
+        <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('/customer-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.reselect}</Link><Link to={path('/profile')} className="btn btn-large btn-rounded btn-base-color btn-box-shadow fw-700"><i className="feather icon-feather-user me-10px"></i>{pageCopy.profile}</Link></div></div></section>
+        <section className="pt-0"><div className="container">
+          {error ? <div className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px" role="alert"><p className="text-red mb-20px">{error}</p><Link to={path('/customer-simulation')} className="btn btn-large btn-rounded btn-base-color fw-700">{pageCopy.startAgain}</Link></div> : null}
+          {!error && !detail ? <div className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px" role="status"><div className="template-task-generating-visual"><div className="template-task-generating-gif"><span></span><span></span><span></span></div><div><strong>{pageCopy.loading}</strong><p>{pageCopy.loadingBody}</p></div></div></div> : null}
+          {detail ? <div className="simulation-result-layout gallery-box">
+            <aside className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-25px">
+              <h2 className="fw-700 text-dark-gray fs-24 mb-20px">{pageCopy.yourPhoto}</h2>
+              <button type="button" className="simulation-original-card aitori-image-button" onClick={() => setPreview({ image: detail.originImage, alt: pageCopy.yourPhoto })}><img className="simulation-result-image" src={detail.originImage} alt={pageCopy.yourPhoto} /></button>
+              <div className="template-detail-summary mt-20px"><div><span>{pageCopy.generatedAt}</span><strong>{createdAt}</strong></div><div><span>{pageCopy.resultCount}</span><strong>{pageCopy.count.replace('{{count}}', String(results.length))}</strong></div><div><span>{pageCopy.status}</span><strong>{pageCopy.statusMap[detail.overallStatus]}</strong></div></div>
+              <div className="simulation-note-box mt-20px"><i className="feather icon-feather-info"></i><span>{pageCopy.notice}</span></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">AI Result</span><h1 className="fw-700 text-dark-gray fs-26 mb-0">{pageCopy.title}</h1></div><span className="simulation-count-badge">{pageCopy.count.replace('{{count}}', String(results.length))}</span></div>
+              {detail.processingTaskCount ? <div className="template-task-generating-visual"><div className="template-task-generating-gif"><span></span><span></span><span></span></div><div><strong>{pageCopy.generating}</strong><p>{pageCopy.progress.replace('{{done}}', String(detail.successTaskCount)).replace('{{total}}', String(detail.totalTaskCount))}</p></div></div> : null}
+              {results.length ? <div className="simulation-result-grid">{results.map((item) => <article key={item.id} className="simulation-result-card"><button type="button" className="aitori-image-button" onClick={() => setPreview({ image: item.image, alt: item.title })}><img className="simulation-result-image" src={item.image} alt={item.title} /></button><div className="simulation-result-body"><h3>{item.title}</h3><span className="template-status-badge">{item.source === 'SYSTEM' ? pageCopy.systemTemplate : pageCopy.stylistTemplate}</span><p>{pageCopy.resultNote}</p></div></article>)}</div> : detail.overallStatus !== 'PROCESSING' ? <p className="text-medium-gray">{pageCopy.noResult}</p> : null}
+              <div className="customer-edit-actions border-radius-8px p-25px mt-4"><div><strong>{pageCopy.likeTitle}</strong><span>{pageCopy.likeBody}</span></div><div className="d-flex flex-wrap gap-2"><Link to={path('/customer-simulation')} className="btn btn-large btn-rounded btn-transparent-light-gray border border-1 border-color-dark-gray fw-700">{pageCopy.tryAgain}</Link><Link to={path('/profile')} className="btn btn-large btn-rounded btn-base-color btn-box-shadow fw-700">{pageCopy.profile}</Link></div></div>
+            </div>
+          </div> : null}
+        </div></section>
       </main>
       <ImageLightbox image={preview?.image || null} alt={preview?.alt} onClose={() => setPreview(null)} />
     </PublicLayout>

+ 12 - 11
frontend/src/pages/customer/StylistProfilePage.tsx

@@ -10,7 +10,8 @@ import { usePageContext } from '../../shared/pageContext'
 type TemplateFilter = '*' | PublicHairstyleTemplate['category']
 
 export function StylistProfilePage() {
-  const { locale, path } = usePageContext()
+  const { locale, path, copy } = usePageContext()
+  const pageCopy = copy.publicStylistProfileDemo
   const [profile] = useState(getStoredStylistProfile)
   const [filter, setFilter] = useState<TemplateFilter>('*')
   const [preview, setPreview] = useState<PublicHairstyleTemplate | null>(null)
@@ -23,28 +24,28 @@ export function StylistProfilePage() {
 
   return (
     <PublicLayout>
-      <Helmet><html lang={locale === 'ja' ? 'ja' : 'zh-CN'} /><title>Aitori Stylist | Demo Stylist 发型模板</title><meta name="description" content="选择 Demo Stylist 设计的发型模板,并用自己的照片进行 AI 试发型。" /><script type="application/ld+json">{JSON.stringify(structuredData)}</script></Helmet>
+      <Helmet><html lang={locale === 'ja' ? 'ja' : 'zh-CN'} /><title>{pageCopy.title}</title><meta name="description" content={pageCopy.description} /><script type="application/ld+json">{JSON.stringify(structuredData)}</script></Helmet>
       <section id="profile" className="stylist-profile-hero public-profile-hero cover-background ipad-top-space-margin">
         <div className="container"><div className="row align-items-center justify-content-center">
-          <div className="col-lg-5 col-md-8 md-mb-40px"><div className="stylist-profile-photo public-profile-photo bg-white border-radius-8px box-shadow-quadruple-large p-15px"><img src={profile.photo} alt={`${profile.displayName} 的头像`} className="border-radius-6px w-100" /></div></div>
-          <div className="col-lg-6 offset-lg-1"><span className="bg-base-color d-inline-block mb-20px fw-600 text-white text-uppercase border-radius-30px ps-20px pe-20px fs-12">{profile.displayName} 的发型模板</span><h1 className="fw-700 text-dark-gray ls-minus-1px mb-20px">用你的照片,试试这位发型师设计的发型。</h1><p className="fs-20 lh-32 text-dark-gray fw-600 mb-20px">这里收录了 {profile.displayName} 常用于咨询的发型模板。选择喜欢的发型后,可以上传自己的照片,用 AI 先看看大致效果。</p><p className="fs-17 lh-30 mb-30px">适合在预约前确认长度、脸周层次、刘海和整体氛围。模拟结果可作为到店咨询时的参考。</p><div className="mb-30px"><Link to={path('/customer-simulation')} className="btn btn-extra-large btn-rounded btn-base-color btn-box-shadow fw-700"><i className="feather icon-feather-camera me-10px"></i>用我的照片试发型</Link><a href="#templates" className="btn btn-extra-large btn-rounded btn-transparent-light-gray border border-1 border-color-dark-gray fw-700 ms-10px xs-ms-0 xs-mt-10px">先看发型模板</a></div><div className="row row-cols-3 g-3 text-center public-profile-stats"><div className="col"><div className="bg-white border-radius-8px p-20px h-100"><span className="d-block fs-24 fw-800 text-dark-gray">{profile.years}</span><span className="fs-14">经验</span></div></div><div className="col"><div className="bg-white border-radius-8px p-20px h-100"><span className="d-block fs-24 fw-800 text-dark-gray">8</span><span className="fs-14">公开模板</span></div></div><div className="col"><div className="bg-white border-radius-8px p-20px h-100"><span className="d-block fs-24 fw-800 text-dark-gray">{profile.rating}</span><span className="fs-14">评分</span></div></div></div></div>
+          <div className="col-lg-5 col-md-8 md-mb-40px"><div className="stylist-profile-photo public-profile-photo bg-white border-radius-8px box-shadow-quadruple-large p-15px"><img src={profile.photo} alt={`${profile.displayName} ${pageCopy.photoAlt}`} className="border-radius-6px w-100" /></div></div>
+          <div className="col-lg-6 offset-lg-1"><span className="bg-base-color d-inline-block mb-20px fw-600 text-white text-uppercase border-radius-30px ps-20px pe-20px fs-12">{profile.displayName}{pageCopy.templateSuffix}</span><h1 className="fw-700 text-dark-gray ls-minus-1px mb-20px">{pageCopy.heroTitle}</h1><p className="fs-20 lh-32 text-dark-gray fw-600 mb-20px">{pageCopy.heroLead.replace('{{name}}', profile.displayName)}</p><p className="fs-17 lh-30 mb-30px">{pageCopy.heroBody}</p><div className="mb-30px"><Link to={path('/customer-simulation')} className="btn btn-extra-large btn-rounded btn-base-color btn-box-shadow fw-700"><i className="feather icon-feather-camera me-10px"></i>{pageCopy.try}</Link><a href="#templates" className="btn btn-extra-large btn-rounded btn-transparent-light-gray border border-1 border-color-dark-gray fw-700 ms-10px xs-ms-0 xs-mt-10px">{pageCopy.browse}</a></div><div className="row row-cols-3 g-3 text-center public-profile-stats"><div className="col"><div className="bg-white border-radius-8px p-20px h-100"><span className="d-block fs-24 fw-800 text-dark-gray">{profile.years}</span><span className="fs-14">{pageCopy.stats[0]}</span></div></div><div className="col"><div className="bg-white border-radius-8px p-20px h-100"><span className="d-block fs-24 fw-800 text-dark-gray">8</span><span className="fs-14">{pageCopy.stats[1]}</span></div></div><div className="col"><div className="bg-white border-radius-8px p-20px h-100"><span className="d-block fs-24 fw-800 text-dark-gray">{profile.rating}</span><span className="fs-14">{pageCopy.stats[2]}</span></div></div></div></div>
         </div></div>
       </section>
 
-      <section className="half-section public-profile-actions pt-0"><div className="container"><div className="row row-cols-1 row-cols-lg-3 row-cols-md-3 justify-content-center g-4"><div className="col"><a href="#templates" className="public-profile-action-card public-profile-action-gallery"><i className="feather icon-feather-grid"></i><span><strong>选择发型模板</strong><small>浏览这位发型师公开的推荐发型。</small></span></a></div><div className="col"><Link to={path('/customer-simulation')} className="public-profile-action-card public-profile-action-simulation"><i className="feather icon-feather-camera"></i><span><strong>上传照片试发型</strong><small>用你的照片生成 AI 模拟效果。</small></span></Link></div><div className="col"><a href="#stylist" className="public-profile-action-card public-profile-action-booking"><i className="feather icon-feather-user"></i><span><strong>了解发型师</strong><small>查看擅长风格和咨询特点。</small></span></a></div></div></div></section>
+      <section className="half-section public-profile-actions pt-0"><div className="container"><div className="row row-cols-1 row-cols-lg-3 row-cols-md-3 justify-content-center g-4"><div className="col"><a href="#templates" className="public-profile-action-card public-profile-action-gallery"><i className="feather icon-feather-grid"></i><span><strong>{pageCopy.actions[0].title}</strong><small>{pageCopy.actions[0].body}</small></span></a></div><div className="col"><Link to={path('/customer-simulation')} className="public-profile-action-card public-profile-action-simulation"><i className="feather icon-feather-camera"></i><span><strong>{pageCopy.actions[1].title}</strong><small>{pageCopy.actions[1].body}</small></span></Link></div><div className="col"><a href="#stylist" className="public-profile-action-card public-profile-action-booking"><i className="feather icon-feather-user"></i><span><strong>{pageCopy.actions[2].title}</strong><small>{pageCopy.actions[2].body}</small></span></a></div></div></div></section>
 
       <section id="templates" className="bg-gradient-very-light-gray"><div className="container">
-        <div className="row justify-content-center mb-5"><div className="col-lg-8 text-center"><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">Style Templates</span><h2 className="fw-700 text-dark-gray ls-minus-1px">选择你想尝试的发型</h2><p className="w-80 lg-w-100 mx-auto">点击图片可以放大查看。决定想试哪一款后,进入 AI 试发型流程,上传自己的照片进行模拟。</p></div></div>
-        <div className="row"><div className="col-12 text-center"><ul className="portfolio-filter nav nav-tabs justify-content-center border-0 fw-500 alt-font pb-5">{([['*', '全部'], ['long', '长发'], ['medium', '中长发'], ['short', '短发']] as const).map(([value, label]) => <li key={value} className={`nav${filter === value ? ' active' : ''}`}><button type="button" onClick={() => setFilter(value)}>{label}</button></li>)}</ul></div></div>
+        <div className="row justify-content-center mb-5"><div className="col-lg-8 text-center"><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.templates.badge}</span><h2 className="fw-700 text-dark-gray ls-minus-1px">{pageCopy.templates.title}</h2><p className="w-80 lg-w-100 mx-auto">{pageCopy.templates.lead}</p></div></div>
+        <div className="row"><div className="col-12 text-center"><ul className="portfolio-filter nav nav-tabs justify-content-center border-0 fw-500 alt-font pb-5">{([['*', 0], ['long', 1], ['medium', 2], ['short', 3]] as const).map(([value, index]) => <li key={value} className={`nav${filter === value ? ' active' : ''}`}><button type="button" onClick={() => setFilter(value)}>{pageCopy.templates.filters[index]}</button></li>)}</ul></div></div>
         <ul className="aitori-template-gallery-grid">{visibleTemplates.map((item) => <li key={item.id} className="transition-inner-all"><div className="portfolio-box"><button type="button" onClick={() => setPreview(item)}><div className="portfolio-image"><img src={item.image} alt={item.name} /></div><div className="portfolio-hover justify-content-center align-items-center d-flex flex-column pt-40px pb-40px"><div className="text-uppercase portfolio-title mt-auto alt-font fs-26 fw-700 text-white" data-text={item.accent}></div><span className="mt-auto d-inline-block text-uppercase pt-5px pb-5px fw-500 ps-15px pe-15px bg-white text-dark-gray fs-12 lh-20 border-radius-2px">{item.name}</span></div></button></div></li>)}</ul>
       </div></section>
 
-      <section id="stylist" className="half-section"><div className="container"><div className="row align-items-center justify-content-center"><div className="col-lg-5 md-mb-35px"><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">Stylist</span><h2 className="fw-700 text-dark-gray ls-minus-1px mb-20px">{profile.displayName}</h2><p className="fs-18 lh-30 mb-20px">{profile.specialties}</p><p className="mb-0">如果你还不确定适合什么长度或风格,可以先用模板模拟,再把结果带到咨询里一起讨论。</p></div><div className="col-lg-5 offset-lg-1"><div className="row row-cols-1 g-3"><InfoCard label="沙龙" value={profile.address.replace(/\n/g, ' ')} /><InfoCard label="营业时间" value={profile.hours.replace(/\n/g, ' / ')} /><InfoCard label="擅长" value="脸周层次、自然长发、清爽短发、易打理造型" /></div></div></div></div></section>
+      <section id="stylist" className="half-section"><div className="container"><div className="row align-items-center justify-content-center"><div className="col-lg-5 md-mb-35px"><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.stylist.badge}</span><h2 className="fw-700 text-dark-gray ls-minus-1px mb-20px">{profile.displayName}</h2><p className="fs-18 lh-30 mb-20px">{profile.specialties}</p><p className="mb-0">{pageCopy.stylist.lead}</p></div><div className="col-lg-5 offset-lg-1"><div className="row row-cols-1 g-3"><InfoCard label={pageCopy.stylist.info[0]} value={profile.address.replace(/\n/g, ' ')} /><InfoCard label={pageCopy.stylist.info[1]} value={profile.hours.replace(/\n/g, ' / ')} /><InfoCard label={pageCopy.stylist.info[2]} value={pageCopy.stylist.specialty} /></div></div></div></div></section>
 
-      <section id="reviews" className="bg-gradient-very-light-gray half-section"><div className="container"><div className="row justify-content-center mb-5"><div className="col-lg-8 text-center"><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">Voice</span><h2 className="fw-700 text-dark-gray ls-minus-1px">顾客反馈</h2></div></div><div className="row row-cols-1 row-cols-lg-3 row-cols-md-2 justify-content-center g-4"><Review title="到店前就比较安心" body="先看过模拟图,再和发型师讨论,比较容易说出自己想要的感觉。" /><Review title="更容易比较长度" body="长一点、短一点的差别可以先看效果,决定发型时不会那么犹豫。" /><Review title="咨询更具体" body="拿着模拟结果沟通,发型师也更容易理解我想改变的地方。" /></div></div></section>
+      <section id="reviews" className="bg-gradient-very-light-gray half-section"><div className="container"><div className="row justify-content-center mb-5"><div className="col-lg-8 text-center"><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.reviews.badge}</span><h2 className="fw-700 text-dark-gray ls-minus-1px">{pageCopy.reviews.title}</h2></div></div><div className="row row-cols-1 row-cols-lg-3 row-cols-md-2 justify-content-center g-4">{pageCopy.reviews.items.map((item) => <Review key={item.title} title={item.title} body={item.body} />)}</div></div></section>
 
-      <section className="half-section"><div className="container"><div className="public-profile-bottom-cta"><div><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">AI Try-on</span><h2 className="fw-700 text-dark-gray fs-32 mb-10px">选好发型后,用你的照片试试看。</h2><p className="mb-0">模拟结果不是最终承诺,而是帮助你和发型师更快对齐方向的参考。</p></div><Link to={path('/customer-simulation')} className="btn btn-large btn-rounded btn-base-color btn-box-shadow fw-700">开始 AI 试发型</Link></div></div></section>
-      <ImageLightbox image={preview?.image || null} alt={preview?.name} onClose={() => setPreview(null)} action={<Link to={path('/customer-simulation')} className="btn btn-large btn-rounded btn-base-color btn-box-shadow fw-700">用我的照片试这款</Link>} />
+      <section className="half-section"><div className="container"><div className="public-profile-bottom-cta"><div><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.cta.badge}</span><h2 className="fw-700 text-dark-gray fs-32 mb-10px">{pageCopy.cta.title}</h2><p className="mb-0">{pageCopy.cta.body}</p></div><Link to={path('/customer-simulation')} className="btn btn-large btn-rounded btn-base-color btn-box-shadow fw-700">{pageCopy.cta.button}</Link></div></div></section>
+      <ImageLightbox image={preview?.image || null} alt={preview?.name} onClose={() => setPreview(null)} action={<Link to={path('/customer-simulation')} className="btn btn-large btn-rounded btn-base-color btn-box-shadow fw-700">{pageCopy.cta.lightbox}</Link>} />
     </PublicLayout>
   )
 }

+ 34 - 8
frontend/src/pages/dashboard/DashboardPage.tsx

@@ -6,6 +6,7 @@ import { usePageContext } from '../../shared/pageContext'
 import { DashboardLayout } from '../../layouts/DashboardLayout'
 import { getCustomerDashboardSummary, type CustomerDashboardSummary } from '../../services/api/customers'
 import { getTemplateDashboardSummary, type TemplateDashboardSummary } from '../../services/api/templates'
+import { getSimulationInviteCode } from '../../services/api/simulations'
 
 function greetingForHour(hour: number, locale: string): string {
   if (locale === 'ja') {
@@ -13,6 +14,8 @@ function greetingForHour(hour: number, locale: string): string {
     if (hour < 18) return 'こんにちは'
     return 'こんばんは'
   }
+  if (hour < 12) return '早上好'
+  if (hour < 18) return '下午好'
   return '晚上好'
 }
 
@@ -23,12 +26,26 @@ export function DashboardPage() {
   const stylistName = sessionIdentity.username || 'Tori'
   const [customerSummary, setCustomerSummary] = useState<CustomerDashboardSummary | null>(null)
   const [templateSummary, setTemplateSummary] = useState<TemplateDashboardSummary | null>(null)
+  const [inviteCode, setInviteCode] = useState('')
+  const [inviteCodeCopied, setInviteCodeCopied] = useState(false)
 
   useEffect(() => {
     getCustomerDashboardSummary().then(setCustomerSummary).catch(() => setCustomerSummary(null))
     getTemplateDashboardSummary().then(setTemplateSummary).catch(() => setTemplateSummary(null))
+    getSimulationInviteCode().then((data) => setInviteCode(data.inviteCode)).catch(() => setInviteCode(''))
   }, [])
 
+  const copyInviteCode = async () => {
+    if (!inviteCode) return
+    try {
+      await navigator.clipboard.writeText(inviteCode)
+      setInviteCodeCopied(true)
+      window.setTimeout(() => setInviteCodeCopied(false), 2000)
+    } catch {
+      setInviteCodeCopied(false)
+    }
+  }
+
   const now = useMemo(() => new Date(), [])
   const dateLabel = useMemo(
     () => new Intl.DateTimeFormat(locale === 'ja' ? 'ja-JP' : 'zh-CN', {
@@ -58,6 +75,15 @@ export function DashboardPage() {
             </div>
             <div className="col-lg-4 text-lg-end mt-3 mt-lg-0">
               <span className="d-block fs-14 text-medium-gray">{dateLabel}</span>
+              <div className="dashboard-invite-code mt-15px text-start" aria-live="polite">
+                <div>
+                  <span>{pageCopy.invite.label}</span>
+                  <strong>{inviteCode || '--------'}</strong>
+                </div>
+                <button type="button" className="btn btn-very-small btn-rounded btn-base-color fw-700" disabled={!inviteCode} onClick={() => void copyInviteCode()}>
+                  <i className="feather icon-feather-copy me-5px"></i>{inviteCodeCopied ? pageCopy.invite.copied : pageCopy.invite.copy}
+                </button>
+              </div>
             </div>
           </div>
 
@@ -65,34 +91,34 @@ export function DashboardPage() {
             <div className="col">
               <Link to={path('/customers/new')} className="workbench-action-card workbench-action-card-customer bg-white border-radius-8px box-shadow-extra-large h-100">
                 <span className="workbench-action-image"><span className="workbench-action-illustration"><i className="feather icon-feather-user-plus"></i></span></span>
-                <span className="workbench-action-body"><span className="d-block fw-700 text-dark-gray fs-20 mb-5px">新建顾客</span><span className="d-block text-medium-gray">登记首次到店顾客</span></span>
+                <span className="workbench-action-body"><span className="d-block fw-700 text-dark-gray fs-20 mb-5px">{pageCopy.actions.newCustomer.title}</span><span className="d-block text-medium-gray">{pageCopy.actions.newCustomer.body}</span></span>
               </Link>
             </div>
             <div className="col">
               <Link to={path('/simulation')} className="workbench-action-card workbench-action-card-simulation is-primary bg-white border-radius-8px box-shadow-extra-large h-100">
                 <span className="workbench-action-image"><span className="workbench-action-illustration"><i className="feather icon-feather-camera"></i></span></span>
-                <span className="workbench-action-body"><span className="d-block fw-700 text-dark-gray fs-20 mb-5px">AI发型模拟</span><span className="d-block text-medium-gray">从照片拍摄到发型提案</span></span>
+                <span className="workbench-action-body"><span className="d-block fw-700 text-dark-gray fs-20 mb-5px">{pageCopy.actions.simulation.title}</span><span className="d-block text-medium-gray">{pageCopy.actions.simulation.body}</span></span>
               </Link>
             </div>
             <div className="col">
               <Link to={path('/templates')} className="workbench-action-card workbench-action-card-template bg-white border-radius-8px box-shadow-extra-large h-100">
                 <span className="workbench-action-image"><span className="workbench-action-illustration"><i className="feather icon-feather-grid"></i></span></span>
-                <span className="workbench-action-body"><span className="d-block fw-700 text-dark-gray fs-20 mb-5px">我的发型模板</span><span className="d-block text-medium-gray">查看已登记的发型</span></span>
+                <span className="workbench-action-body"><span className="d-block fw-700 text-dark-gray fs-20 mb-5px">{pageCopy.actions.templates.title}</span><span className="d-block text-medium-gray">{pageCopy.actions.templates.body}</span></span>
               </Link>
             </div>
             <div className="col">
               <Link to={path('/profile/manage')} className="workbench-action-card workbench-action-card-profile bg-white border-radius-8px box-shadow-extra-large h-100">
                 <span className="workbench-action-image"><span className="workbench-action-illustration"><i className="feather icon-feather-home"></i></span></span>
-                <span className="workbench-action-body"><span className="d-block fw-700 text-dark-gray fs-20 mb-5px">个人主页</span><span className="d-block text-medium-gray">查看和编辑公开主页</span></span>
+                <span className="workbench-action-body"><span className="d-block fw-700 text-dark-gray fs-20 mb-5px">{pageCopy.actions.profile.title}</span><span className="d-block text-medium-gray">{pageCopy.actions.profile.body}</span></span>
               </Link>
             </div>
           </div>
 
           <div className="row row-cols-2 row-cols-lg-4 row-cols-md-4 g-3">
-            <div className="col"><div className="workbench-stat bg-white border-radius-8px p-20px"><span className="d-block fs-26 fw-800 text-dark-gray">{customerSummary?.total ?? '—'}</span><span className="fs-14">顾客总数</span></div></div>
-            <div className="col"><div className="workbench-stat bg-white border-radius-8px p-20px"><span className="d-block fs-26 fw-800 text-dark-gray">{customerSummary?.simulated ?? '—'}</span><span className="fs-14">已模拟</span></div></div>
-            <div className="col"><div className="workbench-stat bg-white border-radius-8px p-20px"><span className="d-block fs-26 fw-800 text-dark-gray">{templateSummary?.total ?? '—'}</span><span className="fs-14">已登记模板</span></div></div>
-            <div className="col"><div className="workbench-stat bg-white border-radius-8px p-20px"><span className="d-block fs-26 fw-800 text-dark-gray">{templateSummary?.published ?? '—'}</span><span className="fs-14">公开中</span></div></div>
+            <div className="col"><div className="workbench-stat bg-white border-radius-8px p-20px"><span className="d-block fs-26 fw-800 text-dark-gray">{customerSummary?.total ?? '—'}</span><span className="fs-14">{pageCopy.stats.total}</span></div></div>
+            <div className="col"><div className="workbench-stat bg-white border-radius-8px p-20px"><span className="d-block fs-26 fw-800 text-dark-gray">{customerSummary?.simulated ?? '—'}</span><span className="fs-14">{pageCopy.stats.simulated}</span></div></div>
+            <div className="col"><div className="workbench-stat bg-white border-radius-8px p-20px"><span className="d-block fs-26 fw-800 text-dark-gray">{templateSummary?.total ?? '—'}</span><span className="fs-14">{pageCopy.stats.total}</span></div></div>
+            <div className="col"><div className="workbench-stat bg-white border-radius-8px p-20px"><span className="d-block fs-26 fw-800 text-dark-gray">{templateSummary?.published ?? '—'}</span><span className="fs-14">{pageCopy.stats.published}</span></div></div>
           </div>
         </div>
       </section>

+ 11 - 2
frontend/src/pages/dashboard/StylistProfileManagePage.tsx

@@ -1,14 +1,22 @@
-import { useState, type FormEvent } from 'react'
+import { useEffect, useState, type FormEvent } from 'react'
 import { Link } from 'react-router-dom'
 import { Helmet } from 'react-helmet-async'
 import { DashboardLayout } from '../../layouts/DashboardLayout'
 import { usePageContext } from '../../shared/pageContext'
 import { getStoredStylistProfile, saveStoredStylistProfile, type StylistProfileDraft } from '../../shared/stylistProfileStore'
+import { getSimulationInviteCode } from '../../services/api/simulations'
 
 export function StylistProfileManagePage() {
   const { locale, navigate, path } = usePageContext()
   const [profile, setProfile] = useState<StylistProfileDraft>(getStoredStylistProfile)
   const [saved, setSaved] = useState(false)
+  const [inviteCode, setInviteCode] = useState('--------')
+
+  useEffect(() => {
+    getSimulationInviteCode()
+      .then((data) => setInviteCode(data.inviteCode))
+      .catch(() => setInviteCode('获取失败'))
+  }, [])
   const patch = (part: Partial<StylistProfileDraft>) => {
     setSaved(false)
     setProfile((current) => ({ ...current, ...part }))
@@ -56,7 +64,8 @@ export function StylistProfileManagePage() {
                 <img src={profile.photo} alt={`${profile.displayName} 的主页照片`} />
                 <div><span className="template-status-badge">公开中</span><h2>{profile.displayName}</h2><p>{profile.lead}</p><div className="stylist-profile-manage-stats"><span><strong>{profile.years}</strong>经验</span><span><strong>{profile.rating}</strong>评分</span><span><strong>{profile.reviewCount}</strong>评价</span></div></div>
               </div>
-              <div className="template-detail-summary mt-25px"><div><span>公开 URL</span><strong>/{locale}/profile</strong></div><div><span>预约入口</span><strong>{profile.bookingUrl ? '已设置' : '未设置'}</strong></div><div><span>公开模板</span><strong>8 件</strong></div></div>
+              <div className="template-detail-summary mt-25px"><div><span>公开 URL</span><strong>/{locale}/profile</strong></div><div><span>顾客模拟代码</span><strong>{inviteCode}</strong></div><div><span>预约入口</span><strong>{profile.bookingUrl ? '已设置' : '未设置'}</strong></div><div><span>公开模板</span><strong>8 件</strong></div></div>
+              <button type="button" className="btn btn-small btn-rounded btn-transparent-light-gray border border-1 border-color-dark-gray fw-700 w-100 mt-15px" disabled={inviteCode.length !== 8} onClick={() => void navigator.clipboard.writeText(inviteCode)}>复制顾客模拟代码</button>
               <Link to={path('/profile')} target="_blank" className="btn btn-large btn-rounded btn-base-color btn-box-shadow fw-700 w-100 mt-25px">查看自己的主页</Link>
             </aside>
 

+ 34 - 1
frontend/src/pages/dashboard/__tests__/DashboardPage.test.tsx

@@ -1,11 +1,16 @@
 import { HelmetProvider } from 'react-helmet-async'
 import { MemoryRouter, Route, Routes } from 'react-router-dom'
 import { render, screen } from '@testing-library/react'
-import { describe, expect, it } from 'vitest'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
 import '../../../i18n'
 import { setSession } from '../../../services/http/session'
+import { getSimulationInviteCode } from '../../../services/api/simulations'
 import { DashboardPage } from '../DashboardPage'
 
+vi.mock('../../../services/api/simulations', () => ({
+  getSimulationInviteCode: vi.fn(),
+}))
+
 function createToken(payload: Record<string, unknown>) {
   const encoded = btoa(JSON.stringify(payload))
     .replace(/\+/g, '-')
@@ -15,6 +20,10 @@ function createToken(payload: Record<string, unknown>) {
 }
 
 describe('DashboardPage', () => {
+  beforeEach(() => {
+    vi.mocked(getSimulationInviteCode).mockResolvedValue({ inviteCode: 'K8HUY7SS' })
+  })
+
   it('shows the username from the login token in the welcome header', async () => {
     setSession(
       createToken({
@@ -124,4 +133,28 @@ describe('DashboardPage', () => {
 
     expect(screen.getByText(dateLabel).closest('div')).toHaveClass('text-lg-end')
   })
+
+  it('shows the authenticated stylist invite code and a copy action', async () => {
+    setSession(
+      createToken({
+        sub: 1,
+        email: 'stylist@aitori.jp',
+        username: 'Ogawa',
+      }),
+      'Authorization',
+    )
+
+    render(
+      <HelmetProvider>
+        <MemoryRouter initialEntries={['/zh/dashboard']}>
+          <Routes>
+            <Route path="/:locale/dashboard" element={<DashboardPage />} />
+          </Routes>
+        </MemoryRouter>
+      </HelmetProvider>,
+    )
+
+    expect(await screen.findByText('K8HUY7SS')).toBeInTheDocument()
+    expect(screen.getByRole('button', { name: /复制/i })).toBeEnabled()
+  })
 })

+ 13 - 24
frontend/src/pages/dashboard/customers/CustomerDetailPage.tsx

@@ -54,31 +54,19 @@ export function CustomerDetailPage() {
   }, [customerId])
 
   const historyBatches = useMemo(() => {
-    if (galleryItems.length) {
-      const grouped = new Map<string, CustomerSimulationGalleryItem[]>()
-      galleryItems.forEach((item) => grouped.set(item.simulationId, [...(grouped.get(item.simulationId) || []), item]))
-      return Array.from(grouped.entries()).map(([simulationId, items]) => ({
+    const grouped = new Map<string, CustomerSimulationGalleryItem[]>()
+    galleryItems.forEach((item) => grouped.set(item.simulationId, [...(grouped.get(item.simulationId) || []), item]))
+    return Array.from(grouped.entries())
+      .map(([simulationId, items]) => ({
         id: simulationId,
         createdAt: items[0]?.createdAt,
-        title: items.some((item) => /30|60|居家/.test(item.templateName)) ? '剪后变化预测' : '发型模板模拟',
+        title: items.some((item) => /30|60|居家/.test(item.templateName)) ? pageCopy.history.aftercareSimulation : pageCopy.history.templateSimulation,
         items,
       }))
-    }
-    return [
-      {
-        id: '10', createdAt: '2026-07-03T23:52:00', title: '发型模板模拟',
-        items: [{ simulationId: '10', taskId: 'sample-template', imageUrl: '/images/mvp-simulation-history/template-324234-20260703.jpg', templateName: '324234', createdAt: '2026-07-03T23:52:00', taskStatus: 'SUCCESSES' as const }],
-      },
-      {
-        id: '9', createdAt: '2026-06-30T23:44:00', title: '剪后变化预测',
-        items: [
-          { simulationId: '9', taskId: 'sample-home', imageUrl: '/images/mvp-simulation-history/home-styling-20260630.jpg', templateName: '居家造型', createdAt: '2026-06-30T23:44:00', taskStatus: 'SUCCESSES' as const },
-          { simulationId: '9', taskId: 'sample-30', imageUrl: '/images/mvp-simulation-history/after-30-days-20260630.jpg', templateName: '居家造型30天后造型', createdAt: '2026-06-30T23:44:00', taskStatus: 'SUCCESSES' as const },
-          { simulationId: '9', taskId: 'sample-60', imageUrl: '/images/mvp-simulation-history/after-60-days-20260630.jpg', templateName: '居家造型60天后造型', createdAt: '2026-06-30T23:44:00', taskStatus: 'SUCCESSES' as const },
-        ],
-      },
-    ]
-  }, [galleryItems])
+      .sort((a, b) => Date.parse(b.createdAt || '') - Date.parse(a.createdAt || ''))
+  }, [galleryItems, pageCopy.history.aftercareSimulation, pageCopy.history.templateSimulation])
+
+  const taskStatusLabel = (status: CustomerSimulationGalleryItem['taskStatus']) => pageCopy.history.status?.[status] || ({ WAIT: '等待中', PROCESSING: '生成中', SUCCESSES: '已完成', FAILED: '生成失败' })[status]
 
   if (!customer) {
     return null
@@ -159,7 +147,7 @@ export function CustomerDetailPage() {
               <div className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px h-100">
                 <div className="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-25px">
                   <div>
-                    <h2 className="fw-700 text-dark-gray fs-26 mb-0">发型模拟历史</h2>
+                    <h2 className="fw-700 text-dark-gray fs-26 mb-0">{pageCopy.history.title}</h2>
                   </div>
                   <Link to={path(`/simulation?customerId=${customer.id}`)} className="btn btn-small btn-rounded btn-base-color fw-700">
                     <i className="feather icon-feather-camera me-6px"></i>{pageCopy.simulate}
@@ -168,12 +156,13 @@ export function CustomerDetailPage() {
                 <div className="customer-simulation-batches">
                   {historyBatches.map((batch) => (
                     <section key={batch.id} className="customer-simulation-batch">
-                      <div className="customer-simulation-batch-heading"><div><span>{formatDisplayDateTime(batch.createdAt, locale)}</span><strong>{batch.title}</strong></div><Link to={path(`/simulation/result/${batch.id}`)}>查看结果</Link></div>
+                      <div className="customer-simulation-batch-heading"><div><span>{formatDisplayDateTime(batch.createdAt, locale)}</span><strong>{batch.title}</strong></div><Link to={path(`/simulation/result/${batch.id}`)}>{pageCopy.history.action}</Link></div>
                       <div className="customer-photo-grid customer-simulation-history">
-                        {batch.items.map((item) => <Link key={`${item.taskId}-${item.imageUrl}`} to={path(`/simulation/result/${batch.id}`)} className="customer-photo-card customer-photo-card-link"><img src={item.imageUrl} alt={item.templateName} /><div><span>{batch.title === '剪后变化预测' ? '预测结果' : '模板结果'}</span><strong>{item.templateName}</strong></div></Link>)}
+                        {batch.items.map((item) => <Link key={`${item.taskId}-${item.imageUrl}`} to={path(`/simulation/result/${batch.id}`)} className="customer-photo-card customer-photo-card-link"><img src={item.imageUrl} alt={item.templateName} /><div><span>{taskStatusLabel(item.taskStatus)}</span><strong>{item.templateName}</strong></div></Link>)}
                       </div>
                     </section>
                   ))}
+                  {!historyBatches.length ? <p className="text-medium-gray mb-0">{pageCopy.photos.empty}</p> : null}
                 </div>
               </div>
             </div>

+ 1 - 1
frontend/src/pages/dashboard/customers/CustomerListPage.tsx

@@ -177,7 +177,7 @@ export function CustomerListPage() {
                     </div>
                   </div>
                   <span>{customer.trouble || '—'}</span>
-                  <span className="customer-row-action btn btn-small btn-rounded btn-transparent-light-gray border border-1 border-color-dark-gray fw-700">{pageCopy.detail}</span>
+                  <span className="customer-row-action btn btn-very-small btn-rounded btn-base-color fw-700">{pageCopy.detail}</span>
                 </Link>
               ))}
             </div>

+ 4 - 3
frontend/src/pages/dashboard/customers/__tests__/CustomerDetailPage.test.tsx

@@ -41,7 +41,7 @@ vi.mock('../../../../shared/pageContext', async () => {
             labels: ['电话', '邮箱', '生日', '到店频率', '发质', '药剂备注'],
             fallbacks: ['1994/08/18', '4〜6周', '细软 / 轻微发流 / 容易干燥', '8Lv米色、红调抑制、低刺激优先'],
           },
-          history: { badge: '施术历史', title: '施术历史', action: '添加记录' },
+          history: { badge: '模拟历史', title: '发型模拟历史', action: '查看结果', templateSimulation: '发型模板模拟', aftercareSimulation: '剪后变化预测', status: { WAIT: '等待中', PROCESSING: '生成中', SUCCESSES: '已完成', FAILED: '生成失败' } },
           photos: {
             badge: '照片与模拟',
             title: '过往照片和AI发型模拟',
@@ -115,8 +115,9 @@ describe('CustomerDetailPage', () => {
     expect(screen.getByRole('heading', { name: '发型模拟历史' })).toBeInTheDocument()
     expect(screen.queryByRole('button', { name: '添加记录' })).not.toBeInTheDocument()
     expect(screen.getAllByRole('link', { name: 'AI发型模拟' }).length).toBeGreaterThan(0)
-    expect(screen.getByText('发型模板模拟')).toBeInTheDocument()
-    expect(screen.getByText('剪后变化预测')).toBeInTheDocument()
+    expect(screen.getByText('暂无 AI 发型模拟结果')).toBeInTheDocument()
+    expect(screen.queryByText('发型模板模拟')).not.toBeInTheDocument()
+    expect(screen.queryByText('剪后变化预测')).not.toBeInTheDocument()
     expect(screen.queryByText('照片与模拟')).not.toBeInTheDocument()
     expect(screen.queryByText('过往照片和AI发型模拟')).not.toBeInTheDocument()
     expect(screen.queryByText('开始下一次咨询与施术')).not.toBeInTheDocument()

+ 12 - 11
frontend/src/pages/dashboard/simulation/SimulationResultPage.tsx

@@ -44,7 +44,8 @@ const demoDetail: AiHairSimulationDetail = {
 
 export function SimulationResultPage() {
   const { simulationId = '' } = useParams()
-  const { locale, path } = usePageContext()
+  const { locale, path, copy } = usePageContext()
+  const pageCopy = copy.simulationResultPage
   const [detail, setDetail] = useState<AiHairSimulationDetail | null>(simulationId === '10' ? demoDetail : null)
   const [loading, setLoading] = useState(simulationId !== '10')
   const [error, setError] = useState('')
@@ -68,7 +69,7 @@ export function SimulationResultPage() {
         if (data.overallStatus === 'PROCESSING') timer = window.setTimeout(() => void load(), 5000)
       } catch (loadError) {
         if (cancelled) return
-        setError(loadError instanceof Error ? loadError.message : '加载失败')
+        setError(loadError instanceof Error ? loadError.message : pageCopy.loadFailed)
         setLoading(false)
       }
     }
@@ -77,28 +78,28 @@ export function SimulationResultPage() {
       cancelled = true
       if (timer) window.clearTimeout(timer)
     }
-  }, [simulationId])
+  }, [pageCopy.loadFailed, simulationId])
 
   const backTarget = detail?.customer ? path(`/customers/${detail.customer.id}`) : path('/simulation')
 
   return (
     <DashboardLayout>
-      <Helmet><html lang={locale === 'ja' ? 'ja' : 'zh-CN'} /><title>Aitori Stylist | AI发型模拟结果</title></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>返回</Link></div></div></section>
+      <Helmet><html lang={locale === 'ja' ? 'ja' : 'zh-CN'} /><title>{`Aitori Stylist | ${pageCopy.title}`}</title></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></div></div></section>
       <section className="pt-0"><div className="container">
         {error ? <div className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px mb-3"><p role="alert" className="text-red mb-0">{error}</p></div> : null}
-        {loading ? <div className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px"><div className="simulation-loading-state"><img src={LOADING_ANIMATION_URL} alt="生成中" className="simulation-loading-gif" /><p className="mb-0 text-medium-gray">正在加载模拟结果…</p></div></div> : null}
+        {loading ? <div className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px"><div className="simulation-loading-state"><img src={LOADING_ANIMATION_URL} alt={pageCopy.card.processing} className="simulation-loading-gif" /><p className="mb-0 text-medium-gray">{pageCopy.loading}</p></div></div> : null}
         {detail ? <div className="simulation-result-layout gallery-box">
           <aside className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-25px">
-            <h1 className="fw-700 text-dark-gray fs-24 mb-20px">顾客照片</h1>
-            <button type="button" className="simulation-original-card aitori-image-button" onClick={() => setPreview({ image: detail.originImage || DEFAULT_USER_AVATAR_URL, alt: '顾客照片' })}><img className="simulation-result-image" src={detail.originImage || DEFAULT_USER_AVATAR_URL} alt="顾客照片" /></button>
-            <div className="template-detail-summary mt-20px"><div><span>顾客</span><strong>{detail.customer?.name || '匿名模拟'}</strong></div><div><span>生成时间</span><strong>{formatDisplayDate(detail.createdAt)}</strong></div><div><span>生成数量</span><strong>{detail.totalTaskCount}个方案</strong></div></div>
+            <h1 className="fw-700 text-dark-gray fs-24 mb-20px">{pageCopy.original.title}</h1>
+            <button type="button" className="simulation-original-card aitori-image-button" onClick={() => setPreview({ image: detail.originImage || DEFAULT_USER_AVATAR_URL, alt: pageCopy.original.title })}><img className="simulation-result-image" src={detail.originImage || DEFAULT_USER_AVATAR_URL} alt={pageCopy.original.title} /></button>
+            <div className="template-detail-summary mt-20px"><div><span>{pageCopy.original.customer}</span><strong>{detail.customer?.name || 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>
           </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"><h2 className="fw-700 text-dark-gray fs-26 mb-0">模拟结果</h2><span className="simulation-count-badge">{detail.totalTaskCount}个方案</span></div>
+            <div className="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-25px"><h2 className="fw-700 text-dark-gray fs-26 mb-0">{pageCopy.results.title}</h2><span className="simulation-count-badge">{pageCopy.results.count.replace('{{count}}', String(detail.totalTaskCount))}</span></div>
             <div className="simulation-result-grid">{detail.tasks.map((task) => {
               const image = task.resultImages[0] || task.templateCoverUrl || '/images/aitori-simulation-result.png'
-              return <article key={task.id} className="simulation-result-card"><button type="button" className="aitori-image-button" disabled={task.taskStatus === 'PROCESSING'} onClick={() => setPreview({ image, alt: task.templateName })}><img className="simulation-result-image" src={image} alt={task.templateName} /></button><div className="simulation-result-body"><h3>{task.templateName}</h3><span className="template-status-badge">{task.templateSource === 'SYSTEM' ? '系统模板' : '我的模板'}</span>{task.taskStatus === 'PROCESSING' ? <img src={LOADING_ANIMATION_URL} alt="生成中" className="simulation-result-card-loading" /> : null}<p>{task.taskStatus === 'FAILED' ? task.errorMsg || '生成失败,请重试。' : task.taskStatus === 'PROCESSING' ? '正在生成中,请稍候查看当前方案效果。' : '已完成生成,可查看当前方案效果。'}</p></div></article>
+              return <article key={task.id} className="simulation-result-card"><button type="button" className="aitori-image-button" disabled={task.taskStatus === 'PROCESSING'} onClick={() => setPreview({ image, alt: task.templateName })}><img className="simulation-result-image" src={image} alt={task.templateName} /></button><div className="simulation-result-body"><h3>{task.templateName}</h3><span className="template-status-badge">{task.templateSource === 'SYSTEM' ? pageCopy.source.system : pageCopy.source.stylist}</span>{task.taskStatus === 'PROCESSING' ? <img src={LOADING_ANIMATION_URL} alt={pageCopy.card.processing} className="simulation-result-card-loading" /> : null}<p>{task.taskStatus === 'FAILED' ? task.errorMsg || pageCopy.card.failed : task.taskStatus === 'PROCESSING' ? pageCopy.card.processing : pageCopy.card.success}</p></div></article>
             })}</div>
           </div>
         </div> : null}

+ 1 - 1
frontend/src/pages/dashboard/templates/TemplateListPage.tsx

@@ -311,7 +311,7 @@ export function TemplateListPage() {
                         </div>
                       ) : null}
                     </div>
-                    <div className="template-card-content">
+                    <div className="template-card-body template-card-content">
                       <div className="template-card-main">
                         <div className="template-card-meta">
                           <span>{pageCopy.categoryMap[template.category as keyof typeof pageCopy.categoryMap] || template.category}</span>

+ 156 - 217
frontend/src/pages/dashboard/templates/TemplateMakeTaskPage.tsx

@@ -1,269 +1,208 @@
-import { useEffect, useState } from 'react'
+import { useEffect, useState, type FormEvent } from 'react'
 import { Link, useParams } from 'react-router-dom'
 import { Helmet } from 'react-helmet-async'
 import { DashboardLayout } from '../../../layouts/DashboardLayout'
 import {
+  deleteTemplate,
+  getTemplateBasicInfo,
   getTemplateMakeTaskById,
+  updateTemplateBasicInfo,
+  type TemplateBasicInfo,
   type TemplateMakeTaskDetail,
 } from '../../../services/api/templates'
 import { usePageContext } from '../../../shared/pageContext'
-import { LOADING_ANIMATION_URL } from '../../../shared/uiAssets'
 
-const FALLBACK_PAGE_COPY = {
-  title: '模板制作任务',
-  description: '查看模板制作进度并等待任务完成。',
-  badge: '模板任务',
-  lead: '模板创建后会自动推进 step1 和 step2,请停留在当前页面等待结果。',
-  pendingName: '正在创建模板...',
-  resourceFallback: '暂无预览',
-  bundleReady: '模板包已生成',
-  bundlePending: '模板包生成后会显示',
-  actions: {
-    editTemplate: '编辑模板信息',
-    backToList: '返回模板列表',
-  },
-  labels: {
-    status: '任务状态',
-    createdAt: '创建时间',
-    taskId: '任务 ID',
-    refHair: '参考发型',
-    model: 'AI 模特',
-    clothes: '服装',
-    background: '背景',
-    resultImage: '封面结果',
-    error: '错误信息',
-  },
+const FALLBACK_TASK_COPY = {
+  title: '模板制作任务', description: '查看模板制作与编辑状态。', badge: '模板任务', pendingName: '正在创建模板...', resourceFallback: '暂无预览',
+  actions: { editTemplate: '编辑模板信息', backToList: '返回模板列表' },
+  labels: { refHair: '参考发型', model: 'AI模特', clothes: '服装', background: '背景', resultImage: '封面结果' },
   states: {
-    WAIT: {
-      label: '等待开始',
-      description: '系统已创建模板记录,正在准备第一步任务。',
-    },
-    PROCESSING: {
-      label: '制作中',
-      description: '系统正在轮询模板制作状态,请稍候。',
-    },
-    STEP1_SUCCESS: {
-      label: '第一步完成',
-      description: '参考发型已解析完成,正在进入模板封面合成。',
-    },
-    STEP2_SUCCESS: {
-      label: '制作完成',
-      description: '模板封面与模板包已生成,可以进入编辑页补充模板基本信息。',
-    },
-    FAILED: {
-      label: '制作失败',
-      description: '本次模板制作未完成,请检查素材后重试。',
-    },
+    WAIT: { label: '等待开始', description: '正在准备第一步任务。' },
+    PROCESSING: { label: '制作中', description: '系统正在生成模板,请稍候。' },
+    STEP1_SUCCESS: { label: '第一步完成', description: '正在生成最终模板封面。' },
+    STEP2_SUCCESS: { label: '制作完成', description: '模板封面与模板包已生成。' },
+    FAILED: { label: '制作失败', description: '请检查素材后重试。' },
   },
   steps: {
-    step1: {
-      title: 'Step 1 参考发型解析',
-      description: '读取 step1 模板并生成中间发型图。',
-    },
-    step2: {
-      title: 'Step 2 模板封面生成',
-      description: '组合模板包并生成最终模板封面。',
-    },
+    step1: { title: 'Step 1 参考发型解析', description: '读取参考发型并生成中间图。' },
+    step2: { title: 'Step 2 模板封面生成', description: '组合素材并生成最终模板封面。' },
   },
 } as const
 
+const FALLBACK_EDIT_COPY = {
+  badge: '模板编辑', info: { title: '发型模板基本信息', name: '模板名称', status: '状态', category: '分类', tags: '标签', tagsPlaceholder: '层次, 空气感', description: '说明' }, preview: { imageAlt: '模板封面预览', downloadBundle: '下载模板包' }, success: { saveSucceeded: '保存成功' }, errors: { saveFailed: '保存失败' }, submitTitle: '保存模板信息', submitLead: '保存模板名称、分类、状态、标签和说明。', statusOptions: { DRAFT: '草稿', PUBLIC: '公开中' }, categoryOptions: { LONG: '长发', MEDIUM: '中长发', SHORT: '短发', DYE: '染发' },
+} as const
+
 function formatTaskTime(locale: 'zh' | 'ja', value?: string) {
   if (!value) return '--'
-
   const date = new Date(value)
-  if (Number.isNaN(date.getTime())) return value
-
-  return new Intl.DateTimeFormat(locale === 'ja' ? 'ja-JP' : 'zh-CN', {
-    dateStyle: 'medium',
-    timeStyle: 'short',
-  }).format(date)
+  return Number.isNaN(date.getTime()) ? value : new Intl.DateTimeFormat(locale === 'ja' ? 'ja-JP' : 'zh-CN', { dateStyle: 'medium', timeStyle: 'short' }).format(date)
 }
 
-function getTaskTone(status?: TemplateMakeTaskDetail['taskStatus']) {
-  if (status === 'STEP2_SUCCESS') return 'success'
+function taskTone(status?: string) {
+  if (status === 'STEP2_SUCCESS' || status === 'SUCCESSES') return 'success'
   if (status === 'FAILED') return 'failed'
   if (status === 'PROCESSING' || status === 'STEP1_SUCCESS') return 'processing'
   return 'waiting'
 }
 
 export function TemplateMakeTaskPage() {
-  const params = useParams<{ taskId: string }>()
-  const { locale, path, copy } = usePageContext()
-  const pageCopy = copy.templateTaskPage ?? FALLBACK_PAGE_COPY
+  const { taskId } = useParams<{ taskId: string }>()
+  const { locale, navigate, path, copy } = usePageContext()
+  const rawTaskCopy = copy.templateTaskPage ?? {}
+  const pageCopy = {
+    ...FALLBACK_TASK_COPY,
+    ...rawTaskCopy,
+    actions: { ...FALLBACK_TASK_COPY.actions, ...rawTaskCopy.actions },
+    labels: { ...FALLBACK_TASK_COPY.labels, ...rawTaskCopy.labels },
+    states: { ...FALLBACK_TASK_COPY.states, ...rawTaskCopy.states },
+    steps: { ...FALLBACK_TASK_COPY.steps, ...rawTaskCopy.steps },
+  }
+  const rawEditCopy = copy.templateEditPage ?? {}
+  const commonCopy = copy.common ?? { sending: '保存中...', save: '保存', cancel: '取消' }
+  const editCopy = {
+    ...FALLBACK_EDIT_COPY,
+    ...rawEditCopy,
+    info: { ...FALLBACK_EDIT_COPY.info, ...rawEditCopy.info },
+    preview: { ...FALLBACK_EDIT_COPY.preview, ...rawEditCopy.preview },
+    success: { ...FALLBACK_EDIT_COPY.success, ...rawEditCopy.success },
+    errors: { ...FALLBACK_EDIT_COPY.errors, ...rawEditCopy.errors },
+    statusOptions: { ...FALLBACK_EDIT_COPY.statusOptions, ...rawEditCopy.statusOptions },
+    categoryOptions: { ...FALLBACK_EDIT_COPY.categoryOptions, ...rawEditCopy.categoryOptions },
+  }
   const [task, setTask] = useState<TemplateMakeTaskDetail | null>(null)
+  const [template, setTemplate] = useState<TemplateBasicInfo | null>(null)
+  const [templateName, setTemplateName] = useState('')
+  const [status, setStatus] = useState<TemplateBasicInfo['status']>('DRAFT')
+  const [category, setCategory] = useState<TemplateBasicInfo['category']>('LONG')
+  const [tags, setTags] = useState('')
+  const [description, setDescription] = useState('')
+  const [saving, setSaving] = useState(false)
+  const [message, setMessage] = useState('')
+  const [error, setError] = useState('')
+  const [deleteOpen, setDeleteOpen] = useState(false)
 
-  const taskStatus = task?.taskStatus ?? 'WAIT'
-  const stateCopy =
-    pageCopy.states?.[taskStatus as keyof typeof pageCopy.states] ??
-    FALLBACK_PAGE_COPY.states[taskStatus as keyof typeof FALLBACK_PAGE_COPY.states]
-  const step1Copy = pageCopy.steps?.step1 ?? FALLBACK_PAGE_COPY.steps.step1
-  const step2Copy = pageCopy.steps?.step2 ?? FALLBACK_PAGE_COPY.steps.step2
-  const labels = pageCopy.labels ?? FALLBACK_PAGE_COPY.labels
-  const actions = pageCopy.actions ?? FALLBACK_PAGE_COPY.actions
-  const tone = getTaskTone(taskStatus)
-  const previewItems = [
-    {
-      key: 'refHair',
-      label: labels.refHair,
-      image: task?.refHairUrl ?? null,
-    },
-    {
-      key: 'model',
-      label: labels.model,
-      image: task?.modelUrl ?? null,
-    },
-    {
-      key: 'clothes',
-      label: labels.clothes,
-      image: task?.clothesUrl ?? null,
-    },
-    {
-      key: 'background',
-      label: labels.background,
-      image: task?.backgroundUrl ?? null,
-    },
-  ]
+  const complete = task?.taskStatus === 'STEP2_SUCCESS'
+  const generating = !complete
+  const currentState = task ? pageCopy.states[task.taskStatus] : pageCopy.states.WAIT
+  const previewItems = task ? [
+    [pageCopy.labels.refHair, task.refHairUrl],
+    [pageCopy.labels.model, task.modelUrl],
+    [pageCopy.labels.clothes, task.clothesUrl],
+    [pageCopy.labels.background, task.backgroundUrl],
+  ] : []
 
   useEffect(() => {
     let cancelled = false
     let timer: number | undefined
-
+    const applyTemplate = (data: TemplateBasicInfo) => {
+      setTemplate(data)
+      setTemplateName(data.templateName)
+      setStatus(data.status)
+      setCategory(data.category)
+      setTags(data.tags.join(', '))
+      setDescription(data.description)
+    }
     const load = async () => {
-      if (!params.taskId) return
-      const data = await getTemplateMakeTaskById(params.taskId)
-      if (cancelled) return
-      setTask(data)
-
-      if (['WAIT', 'PROCESSING', 'STEP1_SUCCESS'].includes(data.taskStatus)) {
-        timer = window.setTimeout(() => {
-          void load()
-        }, 1500)
+      if (!taskId) return
+      try {
+        const data = await getTemplateMakeTaskById(taskId)
+        if (cancelled) return
+        setTask(data)
+        setError('')
+        if (data.taskStatus === 'STEP2_SUCCESS') {
+          const info = await getTemplateBasicInfo(data.templateId)
+          if (!cancelled) applyTemplate(info)
+          return
+        }
+        if (['WAIT', 'PROCESSING', 'STEP1_SUCCESS'].includes(data.taskStatus)) {
+          timer = window.setTimeout(() => void load(), 1500)
+        }
+      } catch (err) {
+        if (!cancelled) setError(err instanceof Error ? err.message : FALLBACK_TASK_COPY.states.FAILED.description)
       }
     }
-
     void load()
+    return () => { cancelled = true; if (timer) window.clearTimeout(timer) }
+  }, [taskId])
 
-    return () => {
-      cancelled = true
-      if (timer) {
-        window.clearTimeout(timer)
-      }
+  const handleSave = async (event: FormEvent<HTMLFormElement>) => {
+    event.preventDefault()
+    if (!task || saving) return
+    setSaving(true); setError(''); setMessage('')
+    try {
+      const updated = await updateTemplateBasicInfo(task.templateId, {
+        templateName: templateName.trim(), status, category,
+        tags: tags.split(',').map((tag) => tag.trim()).filter(Boolean),
+        description: description.trim(),
+      })
+      setTemplate(updated)
+      setMessage(editCopy.success.saveSucceeded)
+    } catch (err) {
+      setError(err instanceof Error ? err.message : editCopy.errors.saveFailed)
+    } finally { setSaving(false) }
+  }
+
+  const handleDelete = async () => {
+    if (!task) return
+    try {
+      await deleteTemplate(task.templateId)
+      navigate(path('/templates'))
+    } catch (err) {
+      setDeleteOpen(false)
+      setError(err instanceof Error ? err.message : pageCopy.delete?.failed || '删除失败')
     }
-  }, [params.taskId])
+  }
 
   return (
     <DashboardLayout>
-      <Helmet>
-        <html lang={locale === 'ja' ? 'ja' : 'zh-CN'} />
-        <title>{`Aitori Stylist | ${pageCopy.title}`}</title>
-        <meta name="description" content={pageCopy.description} />
-      </Helmet>
-      <section className="stylist-workbench-top ipad-top-space-margin mb-2">
+      <Helmet><html lang={locale === 'ja' ? 'ja' : 'zh-CN'} /><title>{`Aitori Stylist | ${pageCopy.title}`}</title><meta name="description" content={pageCopy.description} /></Helmet>
+      <section className={`stylist-workbench-top ipad-top-space-margin mb-2 template-task-demo ${complete ? 'is-complete' : 'is-generating'}`}>
         <div className="container">
-          <div className="template-task-layout">
-            <div className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px template-task-summary-panel">
-              <span className="fs-14 fw-700 text-base-color text-uppercase">
-                {pageCopy.badge}
-              </span>
-              <h1 className="fw-700 text-dark-gray fs-30 mb-10px mt-15px">
-                {pageCopy.title}
-              </h1>
-              <p className="fs-16 text-medium-gray mb-25px">
-                {pageCopy.lead}
-              </p>
-              <div className={`template-task-state-pill is-${tone}`}>
-                <span>{labels.status}</span>
-                <strong>{stateCopy?.label ?? taskStatus}</strong>
-              </div>
-              {tone === 'processing' || tone === 'waiting' ? (
-                <img src={LOADING_ANIMATION_URL} alt="模板制作中" className="template-task-loading-gif" />
-              ) : null}
-              <div className="template-task-headline">
-                <h2 className="fw-700 text-dark-gray fs-24 mb-10px">
-                  {task?.templateName || pageCopy.pendingName || FALLBACK_PAGE_COPY.pendingName}
-                </h2>
-                <p className="fs-15 text-medium-gray mb-0">
-                  {stateCopy?.description ?? pageCopy.lead}
-                </p>
-              </div>
-              <div className="template-task-meta-list">
-                <div>
-                  <span>{labels.createdAt}</span>
-                  <strong>{formatTaskTime(locale, task?.createTime)}</strong>
-                </div>
-                <div>
-                  <span>{labels.taskId}</span>
-                  <strong>{params.taskId ?? '--'}</strong>
-                </div>
-              </div>
-              {task?.errorMessage ? (
-                <div className="template-task-error-box">
-                  <span>{labels.error}</span>
-                  <strong>{task.errorMessage}</strong>
-                </div>
-              ) : null}
-              {task?.taskStatus === 'STEP2_SUCCESS' ? (
-                <Link
-                  to={path(`/templates/${task.templateId}/edit?taskId=${params.taskId ?? ''}`)}
-                  className="btn btn-large btn-rounded btn-base-color btn-box-shadow fw-700 mt-20px"
-                >
-                  {actions.editTemplate}
-                </Link>
-              ) : null}
-              <Link
-                to={path('/templates')}
-                className="btn btn-large btn-rounded btn-transparent-light-gray border border-1 border-color-dark-gray fw-700 mt-10px"
-              >
-                {actions.backToList}
-              </Link>
-            </div>
-            <div className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px">
-              <div className="template-task-step-grid">
-                <div className={`template-task-step-card is-${getTaskTone(task?.step1.resultImage ? 'STEP2_SUCCESS' : task?.step1.status as TemplateMakeTaskDetail['taskStatus'])}`}>
-                  <div className="template-task-step-header">
-                    <strong>{step1Copy.title}</strong>
-                    <span>{task?.step1.status || 'WAIT'}</span>
-                  </div>
-                  <p>{step1Copy.description}</p>
-                </div>
-                <div className={`template-task-step-card is-${getTaskTone(task?.step2.resultImage ? 'STEP2_SUCCESS' : task?.step2.status as TemplateMakeTaskDetail['taskStatus'])}`}>
-                  <div className="template-task-step-header">
-                    <strong>{step2Copy.title}</strong>
-                    <span>{task?.step2.status || 'WAIT'}</span>
-                  </div>
-                  <p>{step2Copy.description}</p>
-                  {task?.step2.resultImage ? (
-                    <img
-                      src={task.step2.resultImage}
-                      alt={labels.resultImage}
-                      className="template-task-step-preview"
-                    />
-                  ) : null}
+          <div className="template-task-page-head mb-4">
+            <div><span className="fs-14 fw-700 text-base-color text-uppercase">{pageCopy.badge}</span><h1 className="fw-700 text-dark-gray fs-30 mb-10px mt-10px">{task?.templateName || pageCopy.pendingName}</h1><p className="mb-0 text-medium-gray">{currentState.description}</p></div>
+            <div className="template-task-head-actions"><Link to={path('/templates')} className="btn btn-small btn-rounded btn-transparent-light-gray border border-1 border-color-dark-gray fw-700">{pageCopy.actions.backToList}</Link>{complete ? <a href="#template-edit-section" className="btn btn-small btn-rounded btn-base-color fw-700">{pageCopy.actions.editTemplate}</a> : null}</div>
+          </div>
+
+          {error ? <p className="alert alert-danger" role="alert">{error}</p> : null}
+          {message ? <p className="alert alert-success" role="status">{message}</p> : null}
+
+          <details key={complete ? 'complete' : 'generating'} className="template-task-history" open={generating}>
+            <summary><span>{pageCopy.badge}</span><strong>{complete ? pageCopy.history?.closedTitle || '查看生成任务信息' : currentState.label}</strong><em>{formatTaskTime(locale, task?.createTime)}</em></summary>
+            <div className="template-task-generating-visual"><div className="template-task-generating-gif"><span></span><span></span><span></span></div><div><strong>{currentState.label}</strong><p>{currentState.description}</p></div></div>
+            <div className="template-task-detail-layout">
+              <div className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px">
+                <h2 className="fw-700 text-dark-gray fs-24 mb-20px">{pageCopy.history?.stepsTitle || '生成步骤'}</h2>
+                <div className="template-task-step-grid">
+                  <div className={`template-task-step-card is-${taskTone(task?.step1.resultImage ? 'SUCCESSES' : task?.step1.status)}`}><div className="template-task-step-header"><strong>{pageCopy.steps.step1.title}</strong><span>{task?.step1.status || 'WAIT'}</span></div><p>{pageCopy.steps.step1.description}</p></div>
+                  <div className={`template-task-step-card is-${taskTone(task?.step2.resultImage ? 'SUCCESSES' : task?.step2.status)}`}><div className="template-task-step-header"><strong>{pageCopy.steps.step2.title}</strong><span>{task?.step2.status || 'WAIT'}</span></div><p>{pageCopy.steps.step2.description}</p>{task?.step2.resultImage ? <img className="template-task-step-preview" src={task.step2.resultImage} alt={pageCopy.labels.resultImage} /> : null}</div>
                 </div>
               </div>
-              <div className="template-task-resource-grid">
-                {previewItems.map((item) => (
-                  <div key={item.key} className="template-task-resource-card">
-                    {item.image ? (
-                      <img src={item.image} alt={item.label} />
-                    ) : (
-                      <div className="template-task-resource-placeholder">
-                        {pageCopy.resourceFallback || FALLBACK_PAGE_COPY.resourceFallback}
-                      </div>
-                    )}
-                    <div>
-                      <span>{item.label}</span>
-                    </div>
-                  </div>
-                ))}
+              <div className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px">
+                <h2 className="fw-700 text-dark-gray fs-24 mb-20px">{pageCopy.history?.assetsTitle || '输入素材'}</h2>
+                <div className="template-task-resource-grid">{previewItems.map(([label, image]) => <div key={label} className="template-task-resource-card">{image ? <img src={image} alt={label} /> : <div>{pageCopy.resourceFallback}</div>}<div><span>{label}</span></div></div>)}</div>
               </div>
-              <div className="template-task-bundle-note">
-                <span>{task?.step2.bundleUrl ? pageCopy.bundleReady || FALLBACK_PAGE_COPY.bundleReady : pageCopy.bundlePending || FALLBACK_PAGE_COPY.bundlePending}</span>
+            </div>
+          </details>
+
+          {complete && template ? <form id="template-edit-section" className="template-task-edit-section workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px mt-4" onSubmit={handleSave}>
+            <div className="template-task-edit-head mb-25px"><div><span className="fs-14 fw-700 text-base-color text-uppercase">{editCopy.badge}</span><h2 className="fw-700 text-dark-gray fs-26 mb-0">{editCopy.info.title}</h2></div><span className="template-status-badge">{editCopy.statusOptions[status]}</span></div>
+            <div className="template-task-edit-layout">
+              <div><img className="template-task-cover-preview mt-0" src={template.coverUrl || task.step2.resultImage || task.refHairUrl} alt={editCopy.preview.imageAlt} />{template.templateUrl ? <a href={template.templateUrl} target="_blank" rel="noreferrer" className="btn btn-small btn-rounded btn-transparent-light-gray border border-1 border-color-dark-gray fw-700 w-100 mt-15px">{editCopy.preview.downloadBundle}</a> : null}</div>
+              <div className="row g-3">
+                <div className="col-md-7"><label htmlFor="task-template-name" className="customer-edit-label">{editCopy.info.name}</label><input id="task-template-name" className="form-control customer-edit-control" value={templateName} onChange={(event) => setTemplateName(event.target.value)} required /></div>
+                <div className="col-md-5"><label htmlFor="task-template-status" className="customer-edit-label">{editCopy.info.status}</label><select id="task-template-status" className="form-select customer-edit-control" value={status} onChange={(event) => setStatus(event.target.value as TemplateBasicInfo['status'])}><option value="DRAFT">{editCopy.statusOptions.DRAFT}</option><option value="PUBLIC">{editCopy.statusOptions.PUBLIC}</option></select></div>
+                <div className="col-md-6"><label htmlFor="task-template-category" className="customer-edit-label">{editCopy.info.category}</label><select id="task-template-category" className="form-select customer-edit-control" value={category} onChange={(event) => setCategory(event.target.value as TemplateBasicInfo['category'])}><option value="LONG">{editCopy.categoryOptions.LONG}</option><option value="MEDIUM">{editCopy.categoryOptions.MEDIUM}</option><option value="SHORT">{editCopy.categoryOptions.SHORT}</option><option value="DYE">{editCopy.categoryOptions.DYE}</option></select></div>
+                <div className="col-md-6"><label htmlFor="task-template-tags" className="customer-edit-label">{editCopy.info.tags}</label><input id="task-template-tags" className="form-control customer-edit-control" value={tags} onChange={(event) => setTags(event.target.value)} placeholder={editCopy.info.tagsPlaceholder} /></div>
+                <div className="col-12"><label htmlFor="task-template-description" className="customer-edit-label">{editCopy.info.description}</label><textarea id="task-template-description" className="form-control customer-edit-control" rows={6} value={description} onChange={(event) => setDescription(event.target.value)} /></div>
               </div>
             </div>
-          </div>
+            <div className="customer-edit-actions border-radius-8px p-25px mt-4"><div><strong>{editCopy.submitTitle}</strong><span>{editCopy.submitLead}</span></div><div className="d-flex flex-wrap gap-2"><button type="button" className="btn btn-large btn-rounded btn-transparent-light-gray border border-1 border-color-red text-red fw-700" onClick={() => setDeleteOpen(true)}>{pageCopy.delete?.button || '删除模板'}</button><button type="submit" disabled={saving} className="btn btn-large btn-rounded btn-base-color btn-box-shadow fw-700">{saving ? commonCopy.sending : commonCopy.save}</button></div></div>
+          </form> : null}
         </div>
       </section>
+
+      {deleteOpen ? <dialog className="template-delete-dialog" open aria-modal="true"><h2 className="fw-700 text-dark-gray fs-24">{pageCopy.delete?.title || '确认删除模板?'}</h2><p>{pageCopy.delete?.body || '删除后模板和生成任务将不再显示,且无法恢复。'}</p><div className="d-flex justify-content-end gap-2"><button type="button" className="btn btn-small btn-rounded btn-transparent-light-gray border" onClick={() => setDeleteOpen(false)}>{commonCopy.cancel}</button><button type="button" className="btn btn-small btn-rounded btn-base-color" onClick={() => void handleDelete()}>{pageCopy.delete?.confirm || '确认删除'}</button></div></dialog> : null}
     </DashboardLayout>
   )
 }

+ 22 - 6
frontend/src/pages/dashboard/templates/__tests__/TemplateMakeTaskPage.test.tsx

@@ -2,11 +2,17 @@ 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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
 import { TemplateMakeTaskPage } from '../TemplateMakeTaskPage'
 
-const { getTemplateMakeTaskById } = vi.hoisted(() => ({
+const { getTemplateMakeTaskById, getTemplateBasicInfo, updateTemplateBasicInfo, deleteTemplate } = vi.hoisted(() => ({
   getTemplateMakeTaskById: vi.fn(),
+  getTemplateBasicInfo: vi.fn().mockResolvedValue({
+    id: '101', templateName: '柔和层次长发', status: 'DRAFT', category: 'LONG',
+    tags: [], description: '', coverUrl: 'https://cdn.example.com/final.jpg', templateUrl: 'https://cdn.example.com/template.zip',
+  }),
+  updateTemplateBasicInfo: vi.fn(),
+  deleteTemplate: vi.fn(),
 }))
 
 vi.mock('../../../../layouts/DashboardLayout', () => ({
@@ -15,6 +21,9 @@ vi.mock('../../../../layouts/DashboardLayout', () => ({
 
 vi.mock('../../../../services/api/templates', () => ({
   getTemplateMakeTaskById,
+  getTemplateBasicInfo,
+  updateTemplateBasicInfo,
+  deleteTemplate,
 }))
 
 vi.mock('../../../../shared/pageContext', () => ({
@@ -39,6 +48,14 @@ vi.mock('../../../../shared/pageContext', () => ({
 
 afterEach(() => {
   vi.useRealTimers()
+  vi.clearAllMocks()
+})
+
+beforeEach(() => {
+  getTemplateBasicInfo.mockResolvedValue({
+    id: '101', templateName: '柔和层次长发', status: 'DRAFT', category: 'LONG',
+    tags: [], description: '', coverUrl: 'https://cdn.example.com/final.jpg', templateUrl: 'https://cdn.example.com/template.zip',
+  })
 })
 
 describe('TemplateMakeTaskPage', () => {
@@ -127,13 +144,12 @@ describe('TemplateMakeTaskPage', () => {
 
     await act(async () => {
       await vi.advanceTimersByTimeAsync(1500)
+      await Promise.resolve()
     })
 
     expect(getTemplateMakeTaskById).toHaveBeenCalledTimes(2)
-    expect(screen.getByRole('link', { name: '编辑模板信息' })).toHaveAttribute(
-      'href',
-      '/zh/templates/101/edit?taskId=88',
-    )
+    expect(screen.getByRole('link', { name: '编辑模板信息' })).toHaveAttribute('href', '#template-edit-section')
+    expect(screen.getByRole('textbox', { name: '模板名称' })).toHaveValue('柔和层次长发')
   })
 
   it('hides the step1 result and uses the updated step2 copy', async () => {

+ 35 - 32
frontend/src/pages/public/BetaApplyPage.tsx

@@ -3,10 +3,12 @@ import { Link } from 'react-router-dom'
 import { Helmet } from 'react-helmet-async'
 import { useLegacyThemePage, usePageContext } from '../../shared/pageContext'
 import { submitBetaApplication } from '../../services/api/betaApplications'
+import { LocaleSwitcher } from '../../components/common/LocaleSwitcher'
 import { AppLogo } from '../../components/common/AppLogo'
 
 export function BetaApplyPage() {
-  const { locale, path } = usePageContext()
+  const { locale, path, copy } = usePageContext()
+  const pageCopy = copy.betaApplyPage
   const [submitting, setSubmitting] = useState(false)
   const [submitted, setSubmitted] = useState(false)
   const [error, setError] = useState('')
@@ -35,7 +37,7 @@ export function BetaApplyPage() {
       setSubmitted(true)
       event.currentTarget.reset()
     } catch (submitError) {
-      setError(submitError instanceof Error ? submitError.message : '提交失败,请稍后重试')
+      setError(submitError instanceof Error ? submitError.message : pageCopy.error)
     } finally {
       setSubmitting(false)
     }
@@ -45,27 +47,28 @@ export function BetaApplyPage() {
     <>
       <Helmet>
         <html lang={locale === 'ja' ? 'ja' : 'zh-CN'} />
-        <title>Aitori Stylist | 申请参与内测</title>
-        <meta name="description" content="申请参与 Aitori Stylist 内测。面向发型设计师与沙龙,测试期间免费,正式上线后内测参与者可获得折扣价格。" />
+        <title>{pageCopy.title}</title>
+        <meta name="description" content={pageCopy.description} />
       </Helmet>
       <header>
-        <nav className="navbar navbar-expand-lg header-light bg-white box-shadow-small">
+        <nav className="navbar navbar-expand-lg header-light bg-white box-shadow-small beta-apply-navbar">
           <div className="container-fluid">
             <div className="col-auto me-auto"><Link className="navbar-brand" to={path('')}><AppLogo /></Link></div>
             <div className="col-auto menu-order position-static">
-              <button className="navbar-toggler float-start" type="button" data-bs-toggle="collapse" data-bs-target="#betaNavbar" aria-controls="betaNavbar" aria-label="打开导航">
+              <button className="navbar-toggler float-start" type="button" data-bs-toggle="collapse" data-bs-target="#betaNavbar" aria-controls="betaNavbar" aria-label={locale === 'ja' ? 'メニューを開く' : '打开导航'}>
                 <span className="navbar-toggler-line"></span><span className="navbar-toggler-line"></span><span className="navbar-toggler-line"></span><span className="navbar-toggler-line"></span>
               </button>
               <div className="collapse navbar-collapse" id="betaNavbar">
                 <ul className="navbar-nav">
-                  <li className="nav-item"><Link to={path('')} className="nav-link">内测介绍</Link></li>
-                  <li className="nav-item"><Link to={path('/profile')} className="nav-link">主页示例</Link></li>
-                  <li className="nav-item"><Link to={path('/customer-simulation')} className="nav-link">试发型体验</Link></li>
-                  <li className="nav-item"><Link to={path('/login')} className="nav-link">测试后台</Link></li>
-                  <li className="nav-item"><Link to={path('/beta-apply')} className="nav-link active">申请内测</Link></li>
+                  <li className="nav-item"><Link to={path('')} className="nav-link">{pageCopy.menu[0]}</Link></li>
+                  <li className="nav-item"><Link to={path('/profile')} className="nav-link">{pageCopy.menu[1]}</Link></li>
+                  <li className="nav-item"><Link to={path('/customer-simulation')} className="nav-link">{pageCopy.menu[2]}</Link></li>
+                  <li className="nav-item"><Link to={path('/login')} className="nav-link">{pageCopy.menu[3]}</Link></li>
+                  <li className="nav-item"><Link to={path('/beta-apply')} className="nav-link active">{pageCopy.menu[4]}</Link></li>
                 </ul>
               </div>
             </div>
+            <div className="col-auto d-flex"><LocaleSwitcher locale={locale} /></div>
           </div>
         </nav>
       </header>
@@ -75,36 +78,36 @@ export function BetaApplyPage() {
           <div className="container">
             <div className="row align-items-center justify-content-between g-5">
               <div className="col-lg-5">
-                <span className="bg-base-color d-inline-block mb-20px fw-600 text-white border-radius-30px ps-20px pe-20px fs-12">Aitori Stylist Beta</span>
-                <h1 className="fw-700 text-dark-gray ls-minus-1px mb-20px">申请参与 Aitori Stylist 内测</h1>
-                <p className="fs-19 lh-32 text-dark-gray fw-600 mb-20px">我们正在邀请发型设计师与沙龙一起测试预约前 AI 试发型、公开主页、发型模板和顾客咨询体验。</p>
-                <p className="mb-30px">测试期间免费使用。正式上线后,内测参与者也会获得折扣价格。</p>
+                <span className="bg-base-color d-inline-block mb-20px fw-600 text-white border-radius-30px ps-20px pe-20px fs-12">{pageCopy.heroBadge}</span>
+                <h1 className="fw-700 text-dark-gray ls-minus-1px mb-20px">{pageCopy.heroTitle}</h1>
+                <p className="fs-19 lh-32 text-dark-gray fw-600 mb-20px">{pageCopy.lead}</p>
+                <p className="mb-30px">{pageCopy.free}</p>
                 <div className="aitori-beta-apply-points">
-                  <div><i className="feather icon-feather-camera"></i><span>让顾客在预约前先试发型</span></div>
-                  <div><i className="feather icon-feather-grid"></i><span>展示你设计的发型模板</span></div>
-                  <div><i className="feather icon-feather-message-circle"></i><span>让咨询沟通更具体</span></div>
+                  <div><i className="feather icon-feather-camera"></i><span>{pageCopy.points[0]}</span></div>
+                  <div><i className="feather icon-feather-grid"></i><span>{pageCopy.points[1]}</span></div>
+                  <div><i className="feather icon-feather-message-circle"></i><span>{pageCopy.points[2]}</span></div>
                 </div>
               </div>
 
               <div className="col-lg-6">
                 <form className="aitori-beta-apply-form bg-white border-radius-8px box-shadow-extra-large p-35px" onSubmit={handleSubmit}>
-                  <div className="mb-25px"><span className="fs-14 fw-700 text-base-color">Beta Application</span><h2 className="fw-700 text-dark-gray fs-28 mb-0">告诉我们你的场景</h2></div>
-                  {submitted ? <div className="alert alert-success" role="status">申请已提交,感谢参与!我们会根据测试阶段和地区陆续联系。</div> : null}
+                  <div className="mb-25px"><span className="fs-14 fw-700 text-base-color">{pageCopy.formBadge}</span><h2 className="fw-700 text-dark-gray fs-28 mb-0">{pageCopy.formTitle}</h2></div>
+                  {submitted ? <div className="alert alert-success" role="status">{pageCopy.success}</div> : null}
                   {error ? <div className="alert alert-danger" role="alert">{error}</div> : null}
                   <div className="row g-3">
-                    <Field id="apply-name" name="name" label="姓名" placeholder="你的姓名" required />
-                    <Field id="apply-email" name="email" label="邮箱" placeholder="example@email.com" type="email" required />
-                    <Field id="apply-salon" name="salonName" label="沙龙名 / 店铺名" placeholder="例:Aitori Demo Salon" required />
-                    <Field id="apply-area" name="area" label="所在地区" placeholder="例:东京 / 大阪 / 上海" required />
-                    <div className="col-md-6"><label className="form-label fw-700 text-dark-gray" htmlFor="apply-role">你的角色</label><select id="apply-role" name="role" className="form-control border-radius-8px" required defaultValue=""><option value="" disabled>请选择</option><option value="stylist">个人发型设计师</option><option value="salon-owner">沙龙负责人 / 主理人</option><option value="staff">沙龙员工</option><option value="other">其他</option></select></div>
-                    <Field id="apply-contact" name="contact" label="常用联系方式" placeholder="LINE / Instagram / 电话" required />
-                    <div className="col-12"><label className="form-label fw-700 text-dark-gray" htmlFor="apply-url">作品或预约页面 URL</label><input id="apply-url" name="portfolioUrl" type="url" className="form-control border-radius-8px" placeholder="Instagram、Hot Pepper、官网或预约页面" /></div>
-                    <div className="col-md-6"><label className="form-label fw-700 text-dark-gray" htmlFor="apply-volume">每月接待顾客数</label><select id="apply-volume" name="monthlyCustomers" className="form-control border-radius-8px" defaultValue=""><option value="">请选择</option><option value="1-30">1-30</option><option value="31-100">31-100</option><option value="100+">100+</option></select></div>
-                    <div className="col-md-6"><label className="form-label fw-700 text-dark-gray" htmlFor="apply-language">联系语言</label><select id="apply-language" name="language" className="form-control border-radius-8px" defaultValue="zh"><option value="zh">中文</option><option value="ja">日文</option><option value="en">英文</option></select></div>
-                    <div className="col-12"><span className="form-label fw-700 text-dark-gray d-block">最想测试的功能</span><div className="aitori-beta-apply-checks"><label><input type="checkbox" name="features" value="customer-try-on" defaultChecked />顾客预约前 AI 试发型</label><label><input type="checkbox" name="features" value="profile" />美发师公开主页</label><label><input type="checkbox" name="features" value="templates" />自己的发型模板</label><label><input type="checkbox" name="features" value="follow" />顾客管理 / 跟进提醒</label></div></div>
-                    <div className="col-12"><label className="form-label fw-700 text-dark-gray" htmlFor="apply-pain">现在最想解决的问题</label><textarea id="apply-pain" name="painPoint" className="form-control border-radius-8px" rows={4} placeholder="例:顾客预约前不容易确认方向、咨询时间太长、想提高新客预约率"></textarea></div>
+                    <Field id="apply-name" name="name" label={pageCopy.fields.name} placeholder={pageCopy.fields.namePlaceholder} required />
+                    <Field id="apply-email" name="email" label={pageCopy.fields.email} placeholder="example@email.com" type="email" required />
+                    <Field id="apply-salon" name="salonName" label={pageCopy.fields.salon} placeholder={pageCopy.fields.salonPlaceholder} required />
+                    <Field id="apply-area" name="area" label={pageCopy.fields.area} placeholder={pageCopy.fields.areaPlaceholder} required />
+                    <div className="col-md-6"><label className="form-label fw-700 text-dark-gray" htmlFor="apply-role">{pageCopy.fields.role}</label><select id="apply-role" name="role" className="form-control border-radius-8px" required defaultValue=""><option value="" disabled>{pageCopy.fields.rolePlaceholder}</option><option value="stylist">{pageCopy.roles[0]}</option><option value="salon-owner">{pageCopy.roles[1]}</option><option value="staff">{pageCopy.roles[2]}</option><option value="other">{pageCopy.roles[3]}</option></select></div>
+                    <Field id="apply-contact" name="contact" label={pageCopy.fields.contact} placeholder={pageCopy.fields.contactPlaceholder} required />
+                    <div className="col-12"><label className="form-label fw-700 text-dark-gray" htmlFor="apply-url">{pageCopy.fields.url}</label><input id="apply-url" name="portfolioUrl" type="url" className="form-control border-radius-8px" placeholder={pageCopy.fields.urlPlaceholder} /></div>
+                    <div className="col-md-6"><label className="form-label fw-700 text-dark-gray" htmlFor="apply-volume">{pageCopy.fields.monthly}</label><select id="apply-volume" name="monthlyCustomers" className="form-control border-radius-8px" defaultValue=""><option value="">{pageCopy.fields.rolePlaceholder}</option><option value="1-30">1-30</option><option value="31-100">31-100</option><option value="100+">100+</option></select></div>
+                    <div className="col-md-6"><label className="form-label fw-700 text-dark-gray" htmlFor="apply-language">{pageCopy.fields.language}</label><select id="apply-language" name="language" className="form-control border-radius-8px" defaultValue={locale}><option value="zh">{pageCopy.languages[0]}</option><option value="ja">{pageCopy.languages[1]}</option><option value="en">{pageCopy.languages[2]}</option></select></div>
+                    <div className="col-12"><span className="form-label fw-700 text-dark-gray d-block">{pageCopy.fields.features}</span><div className="aitori-beta-apply-checks"><label><input type="checkbox" name="features" value="customer-try-on" defaultChecked />{pageCopy.features[0]}</label><label><input type="checkbox" name="features" value="profile" />{pageCopy.features[1]}</label><label><input type="checkbox" name="features" value="templates" />{pageCopy.features[2]}</label><label><input type="checkbox" name="features" value="follow" />{pageCopy.features[3]}</label></div></div>
+                    <div className="col-12"><label className="form-label fw-700 text-dark-gray" htmlFor="apply-pain">{pageCopy.fields.painPoint}</label><textarea id="apply-pain" name="painPoint" className="form-control border-radius-8px" rows={4} placeholder={pageCopy.fields.painPlaceholder}></textarea></div>
                   </div>
-                  <div className="aitori-beta-apply-submit mt-30px"><button type="submit" className="btn btn-large btn-rounded btn-base-color btn-box-shadow fw-700" disabled={submitting}><i className="feather icon-feather-send me-10px"></i>{submitting ? '提交中…' : '申请内测名额'}</button><span>提交后我们会根据测试阶段和地区陆续联系。</span></div>
+                  <div className="aitori-beta-apply-submit mt-30px"><button type="submit" className="btn btn-large btn-rounded btn-base-color btn-box-shadow fw-700" disabled={submitting}><i className="feather icon-feather-send me-10px"></i>{submitting ? pageCopy.submitting : pageCopy.submit}</button><span>{pageCopy.submitNote}</span></div>
                 </form>
               </div>
             </div>

+ 21 - 27
frontend/src/pages/public/LandingPage.tsx

@@ -2,23 +2,15 @@ import { useEffect, useRef, useState } from 'react'
 import { Link } from 'react-router-dom'
 import { Helmet } from 'react-helmet-async'
 import { useLegacyThemePage, usePageContext } from '../../shared/pageContext'
-
-const inviteLinks = [
-  { label: '主页示例', path: '/profile' },
-  { label: '试发型体验', path: '/customer-simulation' },
-  { label: '测试后台', path: '/login' },
-  { label: '申请内测', path: '/beta-apply' },
-]
-
-const inviteMeta = [
-  { label: 'Status', title: '少量名额开放中', body: '我们正在邀请愿意共创测试的发型设计师与沙龙。' },
-  { label: 'For', title: '发型设计师 / 沙龙主理人', body: '适合重视咨询体验、提案转化和长期顾客关系的团队。' },
-  { label: 'Core', title: '预约前可视化提案', body: '让顾客在到店前先看到方向,也让咨询从一开始更具体。' },
-  { label: 'Contact', title: '告诉我们你的场景', body: '测试期间免费使用;正式上线后,内测参与者也会获得折扣价格。' },
-]
+import { LocaleSwitcher } from '../../components/common/LocaleSwitcher'
 
 export function LandingPage() {
-  const { locale, path } = usePageContext()
+  const { locale, path, copy } = usePageContext()
+  const pageCopy = copy.inviteLanding
+  const inviteLinks = [
+    { label: pageCopy.menu[0], path: '/profile' }, { label: pageCopy.menu[1], path: '/customer-simulation' },
+    { label: pageCopy.menu[2], path: '/login' }, { label: pageCopy.menu[3], path: '/beta-apply' },
+  ]
   const [menuOpen, setMenuOpen] = useState(false)
   const pageRef = useRef<HTMLElement>(null)
   const rafRef = useRef<number | null>(null)
@@ -52,8 +44,8 @@ export function LandingPage() {
     <>
       <Helmet>
         <html lang={locale === 'ja' ? 'ja' : 'zh-CN'} />
-        <title>Aitori Stylist | 发型设计师与沙龙内测招募中</title>
-        <meta name="description" content="Aitori Stylist 正在邀请发型设计师与沙龙参加内测。一起打磨预约前 AI 试发型、公开主页、发型模板和顾客咨询体验。" />
+        <title>{pageCopy.title}</title>
+        <meta name="description" content={pageCopy.description} />
       </Helmet>
       <main
         ref={pageRef}
@@ -65,20 +57,21 @@ export function LandingPage() {
           <source src="/images/aitori-hairstyle-three-style-loop-final-web.mp4" type="video/mp4" />
         </video>
         <div className="aitori-invite-shell">
-          <nav className="aitori-invite-nav" aria-label="主导航">
+          <nav className="aitori-invite-nav" aria-label={locale === 'ja' ? 'メインナビゲーション' : '主导航'}>
             <Link className="aitori-invite-logo" to={path('')} aria-label="Aitori Stylist">
               <img src="/images/logo_color.svg" alt="Aitori Stylist" />
             </Link>
             <div className="aitori-invite-links">
               {inviteLinks.map((item) => <Link key={item.path} to={path(item.path)}>{item.label}</Link>)}
+              <LocaleSwitcher locale={locale} />
             </div>
-            <button className="aitori-invite-menu-button" type="button" aria-label="打开菜单" aria-expanded={menuOpen} onClick={() => setMenuOpen(true)}>
+            <button className="aitori-invite-menu-button" type="button" aria-label={pageCopy.menuOpen} aria-expanded={menuOpen} onClick={() => setMenuOpen(true)}>
               <i className="feather icon-feather-menu"></i>
             </button>
           </nav>
 
           <div className="aitori-invite-meta">
-            {inviteMeta.map((item) => (
+            {pageCopy.meta.map((item) => (
               <div key={item.label}>
                 <span className="aitori-invite-label">{item.label}</span>
                 <strong>{item.title}</strong>
@@ -91,16 +84,16 @@ export function LandingPage() {
 
           <section className="aitori-invite-bottom" aria-labelledby="invite-heading">
             <div className="aitori-invite-heading">
-              <span className="aitori-invite-kicker">Aitori Stylist Beta</span>
-              <h1 id="invite-heading">你设计的发型,<span>让顾客先用 AI 试试看。</span></h1>
+              <span className="aitori-invite-kicker">{pageCopy.badge}</span>
+              <h1 id="invite-heading">{pageCopy.headline}<span>{pageCopy.headlineEmphasis}</span></h1>
             </div>
             <div className="aitori-invite-side">
-              <p>Aitori Stylist 正在内测。让顾客在预约前用自己的照片尝试你设计的发型,减少不安,提高预约数和客户满意度。测试期间免费,正式上线后内测参与者也会获得折扣价格。</p>
+              <p>{pageCopy.body}</p>
               <div className="aitori-invite-actions">
-                <Link to={path('/beta-apply')} className="aitori-invite-primary"><i className="feather icon-feather-mail"></i>申请参与测试</Link>
-                <Link to={path('/profile')} className="aitori-invite-secondary">查看设计师主页示例</Link>
+                <Link to={path('/beta-apply')} className="aitori-invite-primary"><i className="feather icon-feather-mail"></i>{pageCopy.apply}</Link>
+                <Link to={path('/profile')} className="aitori-invite-secondary">{pageCopy.sample}</Link>
               </div>
-              <div className="aitori-invite-note"><span>一起测试的内容</span><strong>预约前试发型 · 设计师主页 · 发型模板 · 顾客咨询记录</strong></div>
+              <div className="aitori-invite-note"><span>{pageCopy.noteLabel}</span><strong>{pageCopy.note}</strong></div>
             </div>
           </section>
         </div>
@@ -108,10 +101,11 @@ export function LandingPage() {
         <div className="aitori-invite-mobile-menu" aria-hidden={!menuOpen}>
           <div className="aitori-invite-mobile-head">
             <img src="/images/logo_color.svg" alt="Aitori Stylist" />
-            <button type="button" aria-label="关闭菜单" onClick={() => setMenuOpen(false)}><i className="feather icon-feather-x"></i></button>
+            <button type="button" aria-label={pageCopy.menuClose} onClick={() => setMenuOpen(false)}><i className="feather icon-feather-x"></i></button>
           </div>
           <div className="aitori-invite-mobile-links">
             {inviteLinks.map((item) => <Link key={item.path} to={path(item.path)} onClick={() => setMenuOpen(false)}>{item.label}</Link>)}
+            <LocaleSwitcher locale={locale} />
           </div>
         </div>
       </main>

+ 5 - 3
frontend/src/pages/public/LoginPage.tsx

@@ -3,9 +3,10 @@ import { Helmet } from 'react-helmet-async'
 import { useLegacyThemePage, usePageContext } from '../../shared/pageContext'
 import { MarketingLink } from '../../components/common/MarketingLink'
 import { stylistLogin } from '../../services/api/auth'
+import { LocaleSwitcher } from '../../components/common/LocaleSwitcher'
 
 export function LoginPage() {
-  const { navigate, path, copy } = usePageContext()
+  const { locale, navigate, path, copy } = usePageContext()
   const page = copy.marketing
   const [email, setEmail] = useState('')
   const [password, setPassword] = useState('')
@@ -48,8 +49,9 @@ export function LoginPage() {
                 <img src="/images/logo_color.svg" alt="Aitori Stylist" className="mobile-logo" />
               </MarketingLink>
             </div>
-            <div className="col-auto">
-              <MarketingLink to={path('')} className="login-header-link mt-25px">{page.login.backLink}</MarketingLink>
+            <div className="col-auto d-flex align-items-center gap-2 login-header-actions">
+              <LocaleSwitcher locale={locale} />
+              <MarketingLink to={path('')} className="login-header-link">{page.login.backLink}</MarketingLink>
             </div>
           </div>
         </nav>

+ 1 - 1
frontend/src/services/api/contracts.ts

@@ -216,7 +216,7 @@ export type CustomerSimulationGalleryItem = {
   imageUrl: string
   templateName: string
   createdAt: string
-  taskStatus: 'SUCCESSES'
+  taskStatus: 'WAIT' | 'PROCESSING' | 'SUCCESSES' | 'FAILED'
 }
 export type StylistProfileTemplateEntity = MockStylistProfileTemplate
 export type StylistReviewCardEntity = MockStylistReviewCard

+ 32 - 0
frontend/src/services/api/simulations.ts

@@ -63,6 +63,12 @@ export type ListSimulationTemplatesParams = {
   pageSize?: number
 }
 
+export type PublicSimulationTemplates = {
+  stylist: { name: string; email: string }
+  stylistTemplates: SimulationTemplateOption[]
+  systemTemplates: SimulationTemplateOption[]
+}
+
 export async function listSimulationTemplates(
   params: ListSimulationTemplatesParams,
 ): Promise<ListEnvelope<SimulationTemplateOption> & { page: number; pageSize: number }> {
@@ -89,6 +95,32 @@ export async function getSimulationById(simulationId: string): Promise<AiHairSim
   return http.get(`/simulations/${simulationId}`)
 }
 
+export async function listPublicSimulationTemplates(code?: string): Promise<PublicSimulationTemplates> {
+  const query = code ? `?code=${encodeURIComponent(code.trim().toUpperCase())}` : ''
+  return http.get(`/public-simulations/templates${query}`)
+}
+
+export async function createPublicSimulation(input: {
+  inviteCode: string
+  originImage: string
+  templateIds: string[]
+  simulateRemark?: string
+}): Promise<{ simulationId: string }> {
+  return http.post('/public-simulations', input)
+}
+
+export async function getPublicSimulationById(
+  simulationId: string,
+  inviteCode: string,
+): Promise<AiHairSimulationDetail> {
+  const query = new URLSearchParams({ code: inviteCode.trim().toUpperCase() })
+  return http.get(`/public-simulations/${encodeURIComponent(simulationId)}?${query.toString()}`)
+}
+
+export async function getSimulationInviteCode(): Promise<{ inviteCode: string }> {
+  return http.get('/simulations/invite-code')
+}
+
 export async function listCustomerSimulationGallery(
   customerId: string,
 ): Promise<{ items: CustomerSimulationGalleryItem[] }> {

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است