瀏覽代碼

feat: complete template management workflow

ChenYu 3 月之前
父節點
當前提交
a49e573e2a
共有 33 個文件被更改,包括 3091 次插入 和 419 次删除
  1. 31 1
      backend/package-lock.json
  2. 3 1
      backend/package.json
  3. 8 0
      backend/src/modules/templates/dto/bulk-delete-templates.dto.ts
  4. 33 0
      backend/src/modules/templates/dto/create-template-make-task.dto.ts
  5. 12 0
      backend/src/modules/templates/dto/get-template-assets.dto.ts
  6. 13 4
      backend/src/modules/templates/dto/list-templates-query.dto.ts
  7. 10 0
      backend/src/modules/templates/dto/template-basic-info.dto.ts
  8. 27 0
      backend/src/modules/templates/dto/template-make-task-detail.dto.ts
  9. 21 0
      backend/src/modules/templates/dto/update-template-basic-info.dto.ts
  10. 60 0
      backend/src/modules/templates/template-ai.client.ts
  11. 123 0
      backend/src/modules/templates/template-task-files.ts
  12. 331 0
      backend/src/modules/templates/template-task-runner.ts
  13. 14 1
      backend/src/modules/templates/templates.module.ts
  14. 6 0
      backend/src/step2/config.json
  15. 82 0
      backend/src/step2/content.json
  16. 1 0
      backend/src/types/yazl.d.ts
  17. 1 1
      backend/tsconfig.json
  18. 350 6
      frontend/public/css/aitori.css
  19. 4 0
      frontend/src/App.tsx
  20. 28 0
      frontend/src/__tests__/App.test.tsx
  21. 2 0
      frontend/src/config/routes.ts
  22. 122 18
      frontend/src/i18n/locales/ja/translation.json
  23. 122 18
      frontend/src/i18n/locales/zh/translation.json
  24. 234 155
      frontend/src/pages/dashboard/templates/TemplateDetailPage.tsx
  25. 380 0
      frontend/src/pages/dashboard/templates/TemplateEditPage.tsx
  26. 159 109
      frontend/src/pages/dashboard/templates/TemplateFormPage.tsx
  27. 272 0
      frontend/src/pages/dashboard/templates/TemplateMakeTaskPage.tsx
  28. 127 0
      frontend/src/pages/dashboard/templates/__tests__/TemplateDetailPage.test.tsx
  29. 176 0
      frontend/src/pages/dashboard/templates/__tests__/TemplateEditPage.test.tsx
  30. 121 100
      frontend/src/pages/dashboard/templates/__tests__/TemplateFormPage.test.tsx
  31. 138 0
      frontend/src/pages/dashboard/templates/__tests__/TemplateMakeTaskPage.test.tsx
  32. 76 0
      frontend/src/services/api/contracts.ts
  33. 4 5
      frontend/src/services/api/pageData.ts

+ 31 - 1
backend/package-lock.json

@@ -21,7 +21,8 @@
         "class-validator": "^0.14.2",
         "ioredis": "^5.6.1",
         "reflect-metadata": "^0.2.2",
-        "rxjs": "^7.8.1"
+        "rxjs": "^7.8.1",
+        "yazl": "^3.3.1"
       },
       "devDependencies": {
         "@nestjs/cli": "^11.0.0",
@@ -32,6 +33,7 @@
         "@types/multer": "^2.1.0",
         "@types/node": "^22.0.0",
         "@types/supertest": "^6.0.2",
+        "@types/yazl": "^3.3.1",
         "eslint": "^9.0.0",
         "jest": "^30.0.0",
         "prettier": "^3.3.3",
@@ -3111,6 +3113,16 @@
       "dev": true,
       "license": "MIT"
     },
+    "node_modules/@types/yazl": {
+      "version": "3.3.1",
+      "resolved": "https://registry.npmjs.org/@types/yazl/-/yazl-3.3.1.tgz",
+      "integrity": "sha512-DIWfCKpsTp6hE5BDBHV3+fIL/bLUF9Bv13iDrWnMlmhQpH67buNvI291ZauQ1xcccxK3FqQ9honnXpq4R8NMuQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/node": "*"
+      }
+    },
     "node_modules/@typescript-eslint/eslint-plugin": {
       "version": "8.62.0",
       "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.62.0.tgz",
@@ -4476,6 +4488,15 @@
         "ieee754": "^1.1.13"
       }
     },
+    "node_modules/buffer-crc32": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz",
+      "integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=8.0.0"
+      }
+    },
     "node_modules/buffer-equal-constant-time": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
@@ -11059,6 +11080,15 @@
         "node": ">=12"
       }
     },
+    "node_modules/yazl": {
+      "version": "3.3.1",
+      "resolved": "https://registry.npmjs.org/yazl/-/yazl-3.3.1.tgz",
+      "integrity": "sha512-BbETDVWG+VcMUle37k5Fqp//7SDOK2/1+T7X8TD96M3D9G8jK5VLUdQVdVjGi8im7FGkazX7kk5hkU8X4L5Bng==",
+      "license": "MIT",
+      "dependencies": {
+        "buffer-crc32": "^1.0.0"
+      }
+    },
     "node_modules/yn": {
       "version": "3.1.1",
       "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",

+ 3 - 1
backend/package.json

@@ -42,7 +42,8 @@
     "class-validator": "^0.14.2",
     "ioredis": "^5.6.1",
     "reflect-metadata": "^0.2.2",
-    "rxjs": "^7.8.1"
+    "rxjs": "^7.8.1",
+    "yazl": "^3.3.1"
   },
   "devDependencies": {
     "@nestjs/cli": "^11.0.0",
@@ -53,6 +54,7 @@
     "@types/multer": "^2.1.0",
     "@types/node": "^22.0.0",
     "@types/supertest": "^6.0.2",
+    "@types/yazl": "^3.3.1",
     "eslint": "^9.0.0",
     "jest": "^30.0.0",
     "prettier": "^3.3.3",

+ 8 - 0
backend/src/modules/templates/dto/bulk-delete-templates.dto.ts

@@ -0,0 +1,8 @@
+import { ArrayNotEmpty, IsArray, IsString } from 'class-validator';
+
+export class BulkDeleteTemplatesDto {
+  @IsArray()
+  @ArrayNotEmpty()
+  @IsString({ each: true })
+  ids!: string[];
+}

+ 33 - 0
backend/src/modules/templates/dto/create-template-make-task.dto.ts

@@ -0,0 +1,33 @@
+import { IsArray, IsIn, IsOptional, IsString, IsUrl } from 'class-validator';
+
+export class CreateTemplateMakeTaskDto {
+  @IsString()
+  templateName!: string;
+
+  @IsIn(['LONG', 'MEDIUM', 'SHORT', 'DYE'])
+  category!: 'LONG' | 'MEDIUM' | 'SHORT' | 'DYE';
+
+  @IsIn(['DRAFT', 'PUBLIC'])
+  status!: 'DRAFT' | 'PUBLIC';
+
+  @IsOptional()
+  @IsString()
+  description?: string;
+
+  @IsOptional()
+  @IsArray()
+  @IsString({ each: true })
+  tags?: string[];
+
+  @IsUrl()
+  refHairUrl!: string;
+
+  @IsUrl()
+  modelUrl!: string;
+
+  @IsUrl()
+  clothesUrl!: string;
+
+  @IsUrl()
+  backgroundUrl!: string;
+}

+ 12 - 0
backend/src/modules/templates/dto/get-template-assets.dto.ts

@@ -0,0 +1,12 @@
+export type TemplateAssetOptionDto = {
+  id: string;
+  name: string;
+  url: string;
+  configKey: string;
+};
+
+export type TemplateAssetsDto = {
+  models: TemplateAssetOptionDto[];
+  clothes: TemplateAssetOptionDto[];
+  backgrounds: TemplateAssetOptionDto[];
+};

+ 13 - 4
backend/src/modules/templates/dto/list-templates-query.dto.ts

@@ -1,4 +1,5 @@
-import { IsOptional, IsString } from 'class-validator';
+import { Type } from 'class-transformer';
+import { IsInt, IsOptional, IsString, Min } from 'class-validator';
 
 export class ListTemplatesQueryDto {
   @IsOptional()
@@ -11,9 +12,17 @@ export class ListTemplatesQueryDto {
 
   @IsOptional()
   @IsString()
-  purpose?: string;
+  status?: string;
 
   @IsOptional()
-  @IsString()
-  status?: string;
+  @Type(() => Number)
+  @IsInt()
+  @Min(1)
+  page?: number;
+
+  @IsOptional()
+  @Type(() => Number)
+  @IsInt()
+  @Min(1)
+  pageSize?: number;
 }

+ 10 - 0
backend/src/modules/templates/dto/template-basic-info.dto.ts

@@ -0,0 +1,10 @@
+export class TemplateBasicInfoDto {
+  id!: string;
+  templateName!: string;
+  status!: 'DRAFT' | 'PUBLIC';
+  category!: 'LONG' | 'MEDIUM' | 'SHORT' | 'DYE';
+  tags!: string[];
+  description!: string;
+  coverUrl!: string;
+  templateUrl!: string;
+}

+ 27 - 0
backend/src/modules/templates/dto/template-make-task-detail.dto.ts

@@ -0,0 +1,27 @@
+export type TemplateMakeTaskStepDto = {
+  taskId: string;
+  status: string;
+  templateUrl: string | null;
+  resultImage: string | null;
+  bundleUrl?: string | null;
+};
+
+export type TemplateMakeTaskDetailDto = {
+  id: string;
+  templateId: string;
+  templateName: string;
+  taskStatus: 'WAIT' | 'PROCESSING' | 'STEP1_SUCCESS' | 'STEP2_SUCCESS' | 'FAILED';
+  errorMessage: string | null;
+  createTime: string;
+  refHairUrl: string;
+  modelUrl: string;
+  clothesUrl: string;
+  backgroundUrl: string;
+  step1: TemplateMakeTaskStepDto;
+  step2: TemplateMakeTaskStepDto;
+  template: {
+    coverUrl: string;
+    templateUrl: string;
+    status: 'DRAFT' | 'PUBLIC';
+  };
+};

+ 21 - 0
backend/src/modules/templates/dto/update-template-basic-info.dto.ts

@@ -0,0 +1,21 @@
+import { IsArray, IsIn, IsOptional, IsString } from 'class-validator';
+
+export class UpdateTemplateBasicInfoDto {
+  @IsString()
+  templateName!: string;
+
+  @IsIn(['DRAFT', 'PUBLIC'])
+  status!: 'DRAFT' | 'PUBLIC';
+
+  @IsIn(['LONG', 'MEDIUM', 'SHORT', 'DYE'])
+  category!: 'LONG' | 'MEDIUM' | 'SHORT' | 'DYE';
+
+  @IsOptional()
+  @IsArray()
+  @IsString({ each: true })
+  tags?: string[];
+
+  @IsOptional()
+  @IsString()
+  description?: string;
+}

+ 60 - 0
backend/src/modules/templates/template-ai.client.ts

@@ -0,0 +1,60 @@
+import { Injectable, ServiceUnavailableException } from '@nestjs/common';
+import { ConfigService } from '@nestjs/config';
+
+@Injectable()
+export class TemplateAiClient {
+  constructor(private readonly configService: ConfigService) {}
+
+  async executeTemplate(
+    template: string,
+    inputs: Record<string, unknown>,
+  ): Promise<{ taskId: string }> {
+    const response = await fetch(
+      `${this.configService.getOrThrow<string>('aiSimulation.baseUrl')}/execute`,
+      {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ template, inputs }),
+      },
+    );
+
+    if (!response.ok) {
+      throw new ServiceUnavailableException('AI 模板任务创建失败');
+    }
+
+    const data = (await response.json()) as { taskId?: string };
+    if (!data.taskId) {
+      throw new ServiceUnavailableException('AI 模板任务创建失败');
+    }
+
+    return { taskId: data.taskId };
+  }
+
+  async getResult(taskId: string): Promise<{
+    status: 'processing' | 'successes' | 'failed';
+    message: string;
+    images: string[];
+  }> {
+    const response = await fetch(
+      `${this.configService.getOrThrow<string>('aiSimulation.baseUrl')}/result?taskId=${encodeURIComponent(taskId)}`,
+    );
+
+    if (!response.ok) {
+      throw new ServiceUnavailableException('AI 模板结果查询失败');
+    }
+
+    const data = (await response.json()) as {
+      status: 'processing' | 'successes' | 'failed';
+      message?: string;
+      data?: {
+        images?: Array<string | null | undefined> | null;
+      } | null;
+    };
+
+    return {
+      status: data.status,
+      message: data.message ?? '',
+      images: data.data?.images?.filter((item): item is string => Boolean(item)) ?? [],
+    };
+  }
+}

+ 123 - 0
backend/src/modules/templates/template-task-files.ts

@@ -0,0 +1,123 @@
+import { Injectable } from '@nestjs/common';
+import { mkdtemp, mkdir, readFile, rm, writeFile, copyFile } from 'fs/promises';
+import { createWriteStream } from 'fs';
+import { tmpdir } from 'os';
+import { basename, join, resolve } from 'path';
+import { ZipFile } from 'yazl';
+import { StorageService } from '../storage/storage.service';
+
+type PrepareStep2BundleInput = {
+  totalTaskId: string;
+  step1ResultImg: string;
+  clothesUrl: string;
+  backgroundUrl: string;
+};
+
+@Injectable()
+export class TemplateTaskFiles {
+  constructor(private readonly storageService: StorageService) {}
+
+  private async fetchBuffer(url: string): Promise<Buffer> {
+    const response = await fetch(url);
+    if (!response.ok) {
+      throw new Error(`下载文件失败: ${url}`);
+    }
+
+    return Buffer.from(await response.arrayBuffer());
+  }
+
+  private async createWorkDir(totalTaskId: string): Promise<string> {
+    const rootDir = join(tmpdir(), 'template-make');
+    await mkdir(rootDir, { recursive: true });
+    return mkdtemp(join(rootDir, `${totalTaskId}-`));
+  }
+
+  private async zipFiles(filePaths: string[], outputPath: string): Promise<void> {
+    await new Promise<void>((resolvePromise, rejectPromise) => {
+      const zipFile = new ZipFile();
+      const output = createWriteStream(outputPath);
+
+      output.on('close', () => resolvePromise());
+      output.on('error', rejectPromise);
+      zipFile.outputStream.on('error', rejectPromise);
+
+      for (const filePath of filePaths) {
+        zipFile.addFile(filePath, basename(filePath));
+      }
+
+      zipFile.end();
+      zipFile.outputStream.pipe(output);
+    });
+  }
+
+  async downloadToTempFile(
+    url: string,
+    totalTaskId: string,
+    filename: string,
+  ): Promise<string> {
+    const workDir = await this.createWorkDir(totalTaskId);
+    const destinationPath = join(workDir, filename);
+    await writeFile(destinationPath, await this.fetchBuffer(url));
+    return destinationPath;
+  }
+
+  async prepareStep2Bundle(
+    input: PrepareStep2BundleInput,
+  ): Promise<{
+    uploadedZipUrl: string;
+    workDir: string;
+    localFiles: Record<string, string>;
+  }> {
+    const workDir = await this.createWorkDir(input.totalTaskId);
+    const configPath = resolve(process.cwd(), 'src/step2/config.json');
+    const contentPath = resolve(process.cwd(), 'src/step2/content.json');
+    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 zipPath = join(workDir, 'template.zip');
+
+    await copyFile(configPath, copiedConfigPath);
+    await copyFile(contentPath, copiedContentPath);
+    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 this.zipFiles(
+      [
+        copiedConfigPath,
+        copiedContentPath,
+        hairstylePath,
+        outfitPath,
+        backgroundPath,
+      ],
+      zipPath,
+    );
+
+    const upload = await this.storageService.uploadBuffer({
+      buffer: await readFile(zipPath),
+      contentType: 'application/zip',
+      originalFilename: 'template.zip',
+      type: 'ai-template-package',
+    });
+
+    return {
+      uploadedZipUrl: upload.url,
+      workDir,
+      localFiles: {
+        step1Image: hairstylePath,
+        clothes: outfitPath,
+        background: backgroundPath,
+        zip: zipPath,
+      },
+    };
+  }
+
+  async cleanup(workDir: string | null | undefined): Promise<void> {
+    if (!workDir) {
+      return;
+    }
+
+    await rm(workDir, { recursive: true, force: true });
+  }
+}

+ 331 - 0
backend/src/modules/templates/template-task-runner.ts

@@ -0,0 +1,331 @@
+import { Injectable, NotFoundException } from '@nestjs/common';
+import { ConfigService } from '@nestjs/config';
+import { PrismaService } from '../../prisma/prisma.service';
+import { TemplateMakeTaskDetailDto } from './dto/template-make-task-detail.dto';
+import { TemplateAiClient } from './template-ai.client';
+import { TemplateTaskFiles } from './template-task-files';
+
+type TemplateTaskRecord = {
+  id: bigint;
+  totalTaskId: string;
+  taskStatus: 'WAIT' | 'PROCESSING' | 'STEP1_SUCCESS' | 'STEP2_SUCCESS' | 'FAILED';
+  templateId: bigint | null;
+  step1TaskId: string;
+  step2TaskId: string;
+  refHairUrl: string;
+  modelUrl: string;
+  clothesUrl: string;
+  backgroundUrl: string;
+  step1ResultImg: string;
+  step2ResultImg: string;
+  step1Resource: string | null;
+  step2Resource: string | null;
+  createTime: Date;
+  updateTime: Date;
+};
+
+type TemplateRecord = {
+  id: bigint;
+  templateName: string;
+  coverUrl: string;
+  templateUrl: string;
+  status: 'DRAFT' | 'PUBLIC';
+  hairstylistId: bigint | null;
+};
+
+@Injectable()
+export class TemplateTaskRunner {
+  constructor(
+    private readonly prisma: PrismaService,
+    private readonly templateAiClient: TemplateAiClient,
+    private readonly templateTaskFiles: TemplateTaskFiles,
+    private readonly configService: ConfigService,
+  ) {}
+
+  private shouldRefreshTask(updateTime: Date): boolean {
+    const refreshIntervalMs =
+      this.configService.get<number>('aiSimulation.refreshIntervalMs') ?? 0;
+    return Date.now() - updateTime.getTime() >= refreshIntervalMs;
+  }
+
+  private parseResource(resource: string | null): Record<string, unknown> {
+    if (!resource) {
+      return {};
+    }
+
+    try {
+      const parsed = JSON.parse(resource) as unknown;
+      return parsed && typeof parsed === 'object'
+        ? (parsed as Record<string, unknown>)
+        : {};
+    } catch {
+      return {};
+    }
+  }
+
+  private async getOwnedTask(
+    hairstylistId: bigint,
+    taskId: string,
+  ): Promise<{ task: TemplateTaskRecord; template: TemplateRecord }> {
+    const task = (await this.prisma.hairTemplateMakeTask.findFirst({
+      where: {
+        id: BigInt(taskId),
+        isDeleted: 0,
+      },
+    })) as TemplateTaskRecord | null;
+
+    if (!task?.templateId) {
+      throw new NotFoundException('模板制作任务不存在');
+    }
+
+    const template = (await this.prisma.hairTemplate.findFirst({
+      where: {
+        id: task.templateId,
+        hairstylistId,
+        isDeleted: 0,
+      },
+    })) as TemplateRecord | null;
+
+    if (!template) {
+      throw new NotFoundException('模板制作任务不存在');
+    }
+
+    return { task, template };
+  }
+
+  private buildDetail(
+    task: TemplateTaskRecord,
+    template: TemplateRecord,
+  ): TemplateMakeTaskDetailDto {
+    const step1Resource = this.parseResource(task.step1Resource);
+    const step2Resource = this.parseResource(task.step2Resource);
+    const errorMessage =
+      (step2Resource.lastError as string | undefined) ??
+      (step1Resource.lastError as string | undefined) ??
+      null;
+
+    return {
+      id: task.id.toString(),
+      templateId: template.id.toString(),
+      templateName: template.templateName,
+      taskStatus: task.taskStatus,
+      errorMessage,
+      createTime: task.createTime.toISOString(),
+      refHairUrl: task.refHairUrl,
+      modelUrl: task.modelUrl,
+      clothesUrl: task.clothesUrl,
+      backgroundUrl: task.backgroundUrl,
+      step1: {
+        taskId: task.step1TaskId,
+        status:
+          task.step1ResultImg && task.taskStatus !== 'FAILED'
+            ? 'STEP1_SUCCESS'
+            : task.taskStatus === 'WAIT'
+              ? 'WAIT'
+              : task.taskStatus === 'FAILED' && !task.step2TaskId
+                ? 'FAILED'
+                : 'PROCESSING',
+        templateUrl:
+          typeof step1Resource.templateUrl === 'string'
+            ? step1Resource.templateUrl
+            : null,
+        resultImage: task.step1ResultImg || null,
+      },
+      step2: {
+        taskId: task.step2TaskId,
+        status:
+          task.taskStatus === 'STEP2_SUCCESS'
+            ? 'STEP2_SUCCESS'
+            : task.step2TaskId
+              ? task.taskStatus === 'FAILED'
+                ? 'FAILED'
+                : 'PROCESSING'
+              : 'WAIT',
+        templateUrl: null,
+        bundleUrl:
+          typeof step2Resource.uploadedZipUrl === 'string'
+            ? step2Resource.uploadedZipUrl
+            : null,
+        resultImage: task.step2ResultImg || null,
+      },
+      template: {
+        coverUrl: template.coverUrl,
+        templateUrl: template.templateUrl,
+        status: template.status,
+      },
+    };
+  }
+
+  private async refreshStep1(
+    task: TemplateTaskRecord,
+  ): Promise<TemplateTaskRecord> {
+    const result = await this.templateAiClient.getResult(task.step1TaskId);
+
+    if (result.status === 'processing') {
+      return task;
+    }
+
+    if (result.status === 'failed') {
+      return (await this.prisma.hairTemplateMakeTask.update({
+        where: { id: task.id },
+        data: {
+          taskStatus: 'FAILED',
+          step1Resource: JSON.stringify({
+            ...this.parseResource(task.step1Resource),
+            lastError: result.message || 'step1 失败',
+          }),
+        },
+      })) as TemplateTaskRecord;
+    }
+
+    const step1Image = result.images[0] ?? '';
+    const localFile = await this.templateTaskFiles.downloadToTempFile(
+      step1Image,
+      task.totalTaskId,
+      'hairstyle.jpg',
+    );
+
+    return (await this.prisma.hairTemplateMakeTask.update({
+      where: { id: task.id },
+      data: {
+        taskStatus: 'STEP1_SUCCESS',
+        step1ResultImg: step1Image,
+        step1Resource: JSON.stringify({
+          ...this.parseResource(task.step1Resource),
+          localFile,
+          lastError: null,
+        }),
+      },
+    })) as TemplateTaskRecord;
+  }
+
+  private async startStep2(task: TemplateTaskRecord): Promise<TemplateTaskRecord> {
+    const prepared = await this.templateTaskFiles.prepareStep2Bundle({
+      totalTaskId: task.totalTaskId,
+      step1ResultImg: task.step1ResultImg,
+      clothesUrl: task.clothesUrl,
+      backgroundUrl: task.backgroundUrl,
+    });
+
+    const step2 = await this.templateAiClient.executeTemplate(
+      prepared.uploadedZipUrl,
+      {
+        images: [task.modelUrl],
+      },
+    );
+
+    return (await this.prisma.hairTemplateMakeTask.update({
+      where: { id: task.id },
+      data: {
+        step2TaskId: step2.taskId,
+        taskStatus: 'PROCESSING',
+        step2Resource: JSON.stringify({
+          uploadedZipUrl: prepared.uploadedZipUrl,
+          localFiles: prepared.localFiles,
+          workDir: prepared.workDir,
+          lastError: null,
+        }),
+      },
+    })) as TemplateTaskRecord;
+  }
+
+  private async refreshStep2(
+    task: TemplateTaskRecord,
+    template: TemplateRecord,
+  ): Promise<{ task: TemplateTaskRecord; template: TemplateRecord }> {
+    const result = await this.templateAiClient.getResult(task.step2TaskId);
+    const step2Resource = this.parseResource(task.step2Resource);
+
+    if (result.status === 'processing') {
+      return { task, template };
+    }
+
+    if (result.status === 'failed') {
+      const failedTask = (await this.prisma.hairTemplateMakeTask.update({
+        where: { id: task.id },
+        data: {
+          taskStatus: 'FAILED',
+          step2Resource: JSON.stringify({
+            ...step2Resource,
+            lastError: result.message || 'step2 失败',
+          }),
+        },
+      })) as TemplateTaskRecord;
+
+      await this.templateTaskFiles.cleanup(
+        typeof step2Resource.workDir === 'string' ? step2Resource.workDir : null,
+      );
+      return { task: failedTask, template };
+    }
+
+    const coverUrl = result.images[0] ?? '';
+    const uploadedZipUrl =
+      typeof step2Resource.uploadedZipUrl === 'string'
+        ? step2Resource.uploadedZipUrl
+        : '';
+
+    const updatedTemplate = (await this.prisma.hairTemplate.update({
+      where: { id: template.id },
+      data: {
+        coverUrl,
+        templateUrl: uploadedZipUrl,
+      },
+    })) as TemplateRecord;
+
+    const updatedTask = (await this.prisma.hairTemplateMakeTask.update({
+      where: { id: task.id },
+      data: {
+        taskStatus: 'STEP2_SUCCESS',
+        step2ResultImg: coverUrl,
+        step2Resource: JSON.stringify({
+          ...step2Resource,
+          resultImage: coverUrl,
+          lastError: null,
+        }),
+      },
+    })) as TemplateTaskRecord;
+
+    await this.templateTaskFiles.cleanup(
+      typeof step2Resource.workDir === 'string' ? step2Resource.workDir : null,
+    );
+
+    return { task: updatedTask, template: updatedTemplate };
+  }
+
+  async refreshTask(
+    hairstylistId: bigint,
+    taskId: string,
+  ): Promise<TemplateMakeTaskDetailDto> {
+    let { task, template } = await this.getOwnedTask(hairstylistId, taskId);
+    const originalStatus = task.taskStatus;
+
+    if (
+      task.taskStatus === 'PROCESSING' &&
+      task.step1TaskId &&
+      !task.step1ResultImg &&
+      this.shouldRefreshTask(task.updateTime)
+    ) {
+      task = await this.refreshStep1(task);
+    }
+
+    if (
+      originalStatus === 'STEP1_SUCCESS' &&
+      task.taskStatus === 'STEP1_SUCCESS' &&
+      !task.step2TaskId
+    ) {
+      task = await this.startStep2(task);
+    }
+
+    if (
+      task.taskStatus === 'PROCESSING' &&
+      task.step2TaskId &&
+      this.shouldRefreshTask(task.updateTime)
+    ) {
+      const refreshed = await this.refreshStep2(task, template);
+      task = refreshed.task;
+      template = refreshed.template;
+    }
+
+    return this.buildDetail(task, template);
+  }
+}

+ 14 - 1
backend/src/modules/templates/templates.module.ts

@@ -1,9 +1,22 @@
 import { Module } from '@nestjs/common';
+import { ConfigModule } from '@nestjs/config';
+import { PrismaModule } from '../../prisma/prisma.module';
+import { AuthModule } from '../auth/auth.module';
+import { StorageModule } from '../storage/storage.module';
+import { TemplateAiClient } from './template-ai.client';
+import { TemplateTaskFiles } from './template-task-files';
+import { TemplateTaskRunner } from './template-task-runner';
 import { TemplatesController } from './templates.controller';
 import { TemplatesService } from './templates.service';
 
 @Module({
+  imports: [AuthModule, PrismaModule, ConfigModule, StorageModule],
   controllers: [TemplatesController],
-  providers: [TemplatesService],
+  providers: [
+    TemplatesService,
+    TemplateAiClient,
+    TemplateTaskFiles,
+    TemplateTaskRunner,
+  ],
 })
 export class TemplatesModule {}

+ 6 - 0
backend/src/step2/config.json

@@ -0,0 +1,6 @@
+{
+  "name": "Asymmetrical Bob",
+  "type": "Group",
+  "style": "default",
+  "version": "1.0.0"
+}

File diff suppressed because it is too large
+ 82 - 0
backend/src/step2/content.json


+ 1 - 0
backend/src/types/yazl.d.ts

@@ -0,0 +1 @@
+declare module 'yazl';

+ 1 - 1
backend/tsconfig.json

@@ -16,6 +16,6 @@
     "moduleResolution": "node",
     "types": ["node", "jest"]
   },
-  "include": ["src/**/*.ts", "test/**/*.ts"],
+  "include": ["src/**/*.ts", "src/**/*.d.ts", "test/**/*.ts"],
   "exclude": ["node_modules", "dist"]
 }

+ 350 - 6
frontend/public/css/aitori.css

@@ -782,9 +782,26 @@ textarea.customer-edit-control {
     grid-template-columns: repeat(4, minmax(0, 1fr));
     gap: 20px;
 }
+.template-list-filter-panel {
+    padding-bottom: 20px !important;
+}
+.template-list-filter-grid {
+    row-gap: 14px;
+}
+.template-list-results-panel {
+    padding-top: 26px !important;
+}
+.template-list-results-header {
+    margin-bottom: 22px !important;
+}
+.template-list-results-actions {
+    gap: 10px !important;
+}
 .template-card {
     overflow: hidden;
-    display: block;
+    display: flex;
+    flex-direction: column;
+    min-height: 100%;
     border: 1px solid rgba(44, 46, 60, 0.08);
     border-radius: 8px;
     background-color: #fff;
@@ -792,19 +809,84 @@ textarea.customer-edit-control {
     text-decoration: none;
     transition: transform 0.2s ease, box-shadow 0.2s ease;
 }
+.template-card-checkbox {
+    width: 16px;
+    height: 16px;
+    accent-color: var(--base-color);
+    flex: 0 0 16px;
+}
+.template-card-shell {
+    display: flex;
+    flex: 1 1 auto;
+    flex-direction: column;
+    height: 100%;
+    color: inherit;
+    text-decoration: none;
+}
 .template-card:hover {
     transform: translateY(-2px);
     box-shadow: 0 16px 36px rgba(44, 46, 60, 0.12);
     color: inherit;
 }
+.template-card.is-static:hover {
+    transform: none;
+    box-shadow: none;
+}
 .template-card img {
     width: 100%;
     aspect-ratio: 4 / 5;
     object-fit: cover;
     display: block;
 }
-.template-card-body {
-    padding: 18px;
+.template-card-image {
+    position: relative;
+}
+.template-card-image-link {
+    display: block;
+    color: inherit;
+    text-decoration: none;
+}
+.template-card-image-selection {
+    position: absolute;
+    top: 14px;
+    left: 14px;
+    z-index: 2;
+    display: inline-flex;
+    align-items: center;
+    justify-content: center;
+    width: 34px;
+    height: 34px;
+    border-radius: 50%;
+    background-color: rgba(255, 255, 255, 0.92);
+    box-shadow: 0 6px 14px rgba(44, 46, 60, 0.12);
+}
+.template-card-image-overlay {
+    position: absolute;
+    right: 14px;
+    bottom: 14px;
+    display: flex;
+    justify-content: flex-end;
+    pointer-events: none;
+}
+.template-card-image-overlay .template-status-badge {
+    background-color: rgba(44, 46, 60, 0.82);
+    color: #fff;
+    box-shadow: 0 8px 18px rgba(44, 46, 60, 0.18);
+}
+.template-card-content {
+    display: flex;
+    flex: 1 1 auto;
+    flex-direction: column;
+    padding: 18px 18px 16px;
+}
+.template-card-main {
+    display: flex;
+    flex: 1 1 auto;
+    flex-direction: column;
+}
+.template-card-title-link {
+    color: inherit;
+    text-decoration: none;
 }
 .template-card-meta,
 .template-card-footer {
@@ -820,21 +902,32 @@ textarea.customer-edit-control {
     text-transform: uppercase;
 }
 .template-card h3 {
-    margin: 12px 0 8px;
+    margin: 14px 0 8px;
     color: var(--dark-gray);
     font-size: 18px;
     font-weight: 800;
     line-height: 1.35;
+    line-clamp: 2;
+    display: -webkit-box;
+    -webkit-box-orient: vertical;
+    -webkit-line-clamp: 2;
+    overflow: hidden;
 }
 .template-card p {
-    min-height: 72px;
+    margin-top: 0;
     margin-bottom: 16px;
     color: var(--medium-gray);
     font-size: 14px;
     line-height: 1.7;
+    line-clamp: 3;
+    display: -webkit-box;
+    -webkit-box-orient: vertical;
+    -webkit-line-clamp: 3;
+    overflow: hidden;
 }
 .template-card-footer {
-    padding-top: 14px;
+    margin-top: auto;
+    padding-top: 12px;
     border-top: 1px solid rgba(44, 46, 60, 0.08);
 }
 .template-card-footer span {
@@ -847,6 +940,30 @@ textarea.customer-edit-control {
 .template-card-footer span:last-child {
     color: var(--base-color);
 }
+.template-card-footer-actions {
+    display: inline-flex;
+    align-items: center;
+    justify-content: flex-end;
+    gap: 14px;
+}
+.template-card-detail-action {
+    color: var(--base-color);
+    font-size: 13px;
+    font-weight: 800;
+    text-decoration: none;
+}
+.template-card-detail-action.is-disabled {
+    color: var(--medium-gray);
+}
+.template-card-delete-action {
+    border: 0;
+    padding: 0;
+    background: transparent;
+    color: #e24c4b;
+    font-size: 13px;
+    font-weight: 800;
+    line-height: 1;
+}
 .simulation-step-heading {
     display: flex;
     align-items: center;
@@ -1499,6 +1616,205 @@ textarea.customer-edit-control {
     grid-template-columns: repeat(4, minmax(0, 1fr));
     gap: 16px;
 }
+.template-source-toggle {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 12px;
+    margin-bottom: 20px;
+}
+.template-upload-inline {
+    display: grid;
+    gap: 10px;
+}
+.template-task-layout {
+    display: grid;
+    grid-template-columns: minmax(320px, 380px) minmax(0, 1fr);
+    gap: 24px;
+    align-items: start;
+}
+.template-task-summary-panel .btn {
+    width: 100%;
+}
+.template-task-state-pill {
+    display: inline-flex;
+    flex-wrap: wrap;
+    align-items: center;
+    gap: 8px 10px;
+    min-height: 36px;
+    padding: 8px 14px;
+    border-radius: 999px;
+    background-color: rgba(44, 46, 60, 0.08);
+    color: var(--dark-gray);
+    font-size: 13px;
+    font-weight: 800;
+}
+.template-task-state-pill span {
+    color: var(--medium-gray);
+    font-size: 12px;
+    font-weight: 800;
+    text-transform: uppercase;
+}
+.template-task-state-pill.is-processing,
+.template-task-step-card.is-processing {
+    border-color: rgba(156, 112, 237, 0.22);
+    background-color: rgba(156, 112, 237, 0.06);
+}
+.template-task-state-pill.is-success,
+.template-task-step-card.is-success {
+    border-color: rgba(82, 196, 26, 0.24);
+    background-color: rgba(82, 196, 26, 0.08);
+}
+.template-task-state-pill.is-failed,
+.template-task-step-card.is-failed {
+    border-color: rgba(217, 45, 32, 0.22);
+    background-color: rgba(217, 45, 32, 0.08);
+}
+.template-task-headline {
+    display: grid;
+    gap: 8px;
+    margin-top: 24px;
+}
+.template-task-meta-list {
+    display: grid;
+    gap: 0;
+    margin-top: 24px;
+}
+.template-task-meta-list div {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    gap: 16px;
+    min-height: 52px;
+    border-bottom: 1px solid rgba(44, 46, 60, 0.08);
+}
+.template-task-meta-list div:first-child {
+    border-top: 1px solid rgba(44, 46, 60, 0.08);
+}
+.template-task-meta-list span {
+    color: var(--medium-gray);
+    font-size: 13px;
+    font-weight: 700;
+}
+.template-task-meta-list strong {
+    color: var(--dark-gray);
+    font-size: 14px;
+    font-weight: 800;
+    text-align: right;
+}
+.template-task-error-box {
+    display: grid;
+    gap: 6px;
+    margin-top: 20px;
+    padding: 16px 18px;
+    border-radius: 8px;
+    border: 1px solid rgba(217, 45, 32, 0.12);
+    background-color: rgba(217, 45, 32, 0.06);
+}
+.template-task-error-box span {
+    color: #d92d20;
+    font-size: 12px;
+    font-weight: 800;
+    text-transform: uppercase;
+}
+.template-task-error-box strong {
+    color: var(--dark-gray);
+    font-size: 14px;
+    line-height: 1.6;
+}
+.template-task-step-grid {
+    display: grid;
+    grid-template-columns: repeat(2, minmax(0, 1fr));
+    gap: 16px;
+}
+.template-task-step-card {
+    display: grid;
+    gap: 12px;
+    min-height: 200px;
+    padding: 20px;
+    border: 1px solid rgba(44, 46, 60, 0.08);
+    border-radius: 8px;
+    background-color: #f7f7f8;
+}
+.template-task-step-header {
+    display: flex;
+    align-items: flex-start;
+    justify-content: space-between;
+    gap: 16px;
+}
+.template-task-step-header strong {
+    color: var(--dark-gray);
+    font-size: 18px;
+    line-height: 1.4;
+}
+.template-task-step-header span {
+    color: var(--base-color);
+    font-size: 12px;
+    font-weight: 800;
+    text-transform: uppercase;
+    white-space: nowrap;
+}
+.template-task-step-card p {
+    margin-bottom: 0;
+    color: var(--medium-gray);
+    font-size: 14px;
+    line-height: 1.7;
+}
+.template-task-step-preview {
+    width: 100%;
+    aspect-ratio: 4 / 5;
+    object-fit: cover;
+    display: block;
+    border-radius: 8px;
+}
+.template-task-resource-grid {
+    display: grid;
+    grid-template-columns: repeat(4, minmax(0, 1fr));
+    gap: 16px;
+    margin-top: 20px;
+}
+.template-task-resource-card {
+    overflow: hidden;
+    border: 1px solid rgba(44, 46, 60, 0.08);
+    border-radius: 8px;
+    background-color: #fff;
+}
+.template-task-resource-card img,
+.template-task-resource-placeholder {
+    width: 100%;
+    aspect-ratio: 4 / 5;
+    display: block;
+}
+.template-task-resource-card img {
+    object-fit: cover;
+}
+.template-task-resource-placeholder {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    background-color: #f7f7f8;
+    color: var(--medium-gray);
+    font-size: 13px;
+    font-weight: 700;
+}
+.template-task-resource-card div {
+    padding: 12px 14px;
+}
+.template-task-resource-card span {
+    color: var(--dark-gray);
+    font-size: 13px;
+    font-weight: 800;
+}
+.template-task-bundle-note {
+    margin-top: 20px;
+    padding: 14px 16px;
+    border-radius: 8px;
+    background-color: #f7f7f8;
+}
+.template-task-bundle-note span {
+    color: var(--medium-gray);
+    font-size: 14px;
+    font-weight: 700;
+}
 .template-option-card {
     position: relative;
     overflow: hidden;
@@ -1816,8 +2132,28 @@ footer ul li {
     .template-result-grid {
         grid-template-columns: 1fr;
     }
+    .template-task-layout {
+        grid-template-columns: 1fr;
+    }
+    .template-task-step-grid {
+        grid-template-columns: 1fr;
+    }
+    .template-task-resource-grid {
+        grid-template-columns: repeat(2, minmax(0, 1fr));
+    }
 }
 @media (max-width: 767px) {
+    .template-list-filter-panel {
+        padding: 20px !important;
+    }
+    .template-list-results-actions {
+        width: 100%;
+        justify-content: flex-start;
+    }
+    .template-card-toolbar {
+        min-height: 0;
+        padding: 12px 14px 10px;
+    }
     .customer-detail-profile {
         align-items: flex-start;
         flex-direction: column;
@@ -1868,6 +2204,14 @@ footer ul li {
     .template-option-grid {
         grid-template-columns: 1fr;
     }
+    .template-task-resource-grid {
+        grid-template-columns: 1fr;
+    }
+    .template-task-step-header,
+    .template-task-meta-list div {
+        align-items: flex-start;
+        flex-direction: column;
+    }
     .template-result-body > div {
         align-items: flex-start;
         flex-direction: column;

+ 4 - 0
frontend/src/App.tsx

@@ -10,6 +10,8 @@ import { CustomerDetailPage } from './pages/dashboard/customers/CustomerDetailPa
 import { TemplateListPage } from './pages/dashboard/templates/TemplateListPage'
 import { TemplateFormPage } from './pages/dashboard/templates/TemplateFormPage'
 import { TemplateDetailPage } from './pages/dashboard/templates/TemplateDetailPage'
+import { TemplateEditPage } from './pages/dashboard/templates/TemplateEditPage'
+import { TemplateMakeTaskPage } from './pages/dashboard/templates/TemplateMakeTaskPage'
 import { StylistSimulationPage } from './pages/dashboard/simulation/StylistSimulationPage'
 import { SimulationResultPage } from './pages/dashboard/simulation/SimulationResultPage'
 import { SimulationEditPage } from './pages/dashboard/simulation/SimulationEditPage'
@@ -32,6 +34,8 @@ function App() {
         <Route path={routes.customerEdit} element={<RequireAuth><CustomerFormPage /></RequireAuth>} />
         <Route path={routes.templates} element={<RequireAuth><TemplateListPage /></RequireAuth>} />
         <Route path={routes.templateNew} element={<RequireAuth><TemplateFormPage /></RequireAuth>} />
+        <Route path={routes.templateTaskDetail} element={<RequireAuth><TemplateMakeTaskPage /></RequireAuth>} />
+        <Route path={routes.templateEdit} element={<RequireAuth><TemplateEditPage /></RequireAuth>} />
         <Route path={routes.templateDetail} element={<RequireAuth><TemplateDetailPage /></RequireAuth>} />
         <Route path={routes.simulation} element={<RequireAuth><StylistSimulationPage /></RequireAuth>} />
         <Route path={routes.simulationResultDetail} element={<RequireAuth><SimulationResultPage /></RequireAuth>} />

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

@@ -38,6 +38,14 @@ vi.mock('../pages/dashboard/templates/TemplateDetailPage', () => ({
   TemplateDetailPage: () => <div>template-detail-page</div>,
 }))
 
+vi.mock('../pages/dashboard/templates/TemplateMakeTaskPage', () => ({
+  TemplateMakeTaskPage: () => <div>template-make-task-page</div>,
+}))
+
+vi.mock('../pages/dashboard/templates/TemplateEditPage', () => ({
+  TemplateEditPage: () => <div>template-edit-page</div>,
+}))
+
 vi.mock('../pages/dashboard/simulation/StylistSimulationPage', () => ({
   StylistSimulationPage: () => <div>simulation-page</div>,
 }))
@@ -100,4 +108,24 @@ describe('App customer routes', () => {
       expect(screen.getByText('simulation-result-page')).toBeInTheDocument()
     })
   })
+
+  it('renders template make task route', async () => {
+    window.history.replaceState({}, '', '/zh/templates/tasks/88')
+
+    render(<App />)
+
+    await waitFor(() => {
+      expect(screen.getByText('template-make-task-page')).toBeInTheDocument()
+    })
+  })
+
+  it('renders template edit route', async () => {
+    window.history.replaceState({}, '', '/zh/templates/101/edit')
+
+    render(<App />)
+
+    await waitFor(() => {
+      expect(screen.getByText('template-edit-page')).toBeInTheDocument()
+    })
+  })
 })

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

@@ -9,6 +9,8 @@ export const routes = {
   customerEdit: '/:locale/customers/:customerId/edit',
   templates: '/:locale/templates',
   templateNew: '/:locale/templates/new',
+  templateTaskDetail: '/:locale/templates/tasks/:taskId',
+  templateEdit: '/:locale/templates/:templateId/edit',
   templateDetail: '/:locale/templates/:templateId',
   simulation: '/:locale/simulation',
   simulationResult: '/:locale/simulation/result',

+ 122 - 18
frontend/src/i18n/locales/ja/translation.json

@@ -240,7 +240,7 @@
     "tableHeaders": ["顧客", "来店予定", "相談内容", "状態", "操作"],
     "hairLengthMap": { "short": "ショート", "medium": "ミディアム", "long": "ロング" },
     "statusMap": { "none": "未設定" },
-    "detail": "詳細",
+    "detail": "タスクを見る",
     "pagination": { "prev": "前へ", "next": "次へ" }
   },
   "templateListPage": {
@@ -253,11 +253,19 @@
       "searchLabel": "テンプレート検索",
       "searchPlaceholder": "名称・カテゴリ・タグ・メモで検索",
       "categoryLabel": "カテゴリ",
-      "categoryOptions": ["すべて", "ロング", "ミディアム", "ショート"],
-      "purposeLabel": "用途",
-      "purposeOptions": ["すべて", "初回提案", "カラー提案", "フォロー提案"],
+      "categoryOptions": [
+        { "value": "", "label": "すべて" },
+        { "value": "LONG", "label": "ロング" },
+        { "value": "MEDIUM", "label": "ミディアム" },
+        { "value": "SHORT", "label": "ショート" },
+        { "value": "DYE", "label": "カラー" }
+      ],
       "statusLabel": "状態",
-      "statusOptions": ["すべて", "公開中", "下書き"]
+      "statusOptions": [
+        { "value": "", "label": "すべて" },
+        { "value": "PUBLIC", "label": "公開中" },
+        { "value": "DRAFT", "label": "下書き" }
+      ]
     },
     "stats": {
       "total": "登録テンプレート",
@@ -268,10 +276,12 @@
     "resultBadge": "テンプレート一覧",
     "resultTitle": "検索結果 {{count}}件",
     "sortRecent": "並び替え:最近使用",
-    "categoryMap": { "Long": "ロング", "Medium": "ミディアム", "Short": "ショート", "Color": "カラー" },
-    "statusMap": { "Draft": "下書き", "Published": "公開中" },
+    "categoryMap": { "LONG": "ロング", "MEDIUM": "ミディアム", "SHORT": "ショート", "DYE": "カラー" },
+    "statusMap": { "DRAFT": "下書き", "PUBLIC": "公開中" },
     "usedCount": "{{count}}回使用",
-    "detail": "詳細",
+    "detail": "タスクを見る",
+    "selectAll": "すべて選択",
+    "deselectAll": "選択解除",
     "pagination": { "prev": "前へ", "next": "次へ" }
   },
   "templateFormPage": {
@@ -302,33 +312,33 @@
     "model": {
       "badge": "AIモデル",
       "title": "AIモデルを選択",
-      "options": { "natural": "ナチュラル", "soft": "ソフト", "fashion": "ファッション", "mature": "大人" }
+      "preset": "プリセットモデルを使う",
+      "upload": "カスタムモデルをアップロード",
+      "uploadLabel": "モデル画像をアップロード"
     },
     "outfit": {
       "badge": "服装",
       "title": "服装を選択",
-      "options": ["ベージュトップス", "白シャツ", "黒トップス", "サロンクロス", "カスタム服装画像をアップロード"],
-      "upload": "服装画像をアップロード",
-      "previewAlt": "服装画像プレビュー"
+      "preset": "プリセット服装を使う",
+      "upload": "カスタム服装をアップロード",
+      "uploadLabel": "服装画像をアップロード"
     },
     "background": {
       "badge": "背景",
       "title": "背景を選択",
-      "options": ["白背景", "サロン内観", "自然光", "カスタム背景画像をアップロード"],
-      "upload": "サロン背景画像をアップロード",
-      "previewAlt": "背景画像プレビュー"
+      "preset": "プリセット背景を使う",
+      "upload": "カスタム背景をアップロード",
+      "uploadLabel": "背景画像をアップロード"
     },
     "submitTitle": "AIヘアテンプレートを生成",
     "submitLead": "送信後、参考画像と選択したモデル・服装・背景をもとにテンプレートを生成します。",
     "generate": "AI生成",
-    "assetPreparationHint": "素材の準備がまだ完了していません。先に素材アップロードスクリプトを実行してください。",
     "loadingAssets": "素材を読み込み中です...",
     "assetLoadFailed": "素材の読み込みに失敗しました。再読み込みしてもう一度お試しください",
     "assetEmpty": "素材設定が未完了です。サーバー設定を確認してください",
     "submitting": {
       "uploading": "参考画像をアップロード中です...",
-      "generating": "テンプレートを生成中です。少し時間がかかる場合があります...",
-      "saving": "テンプレートを保存中です..."
+      "generating": "テンプレート作成タスクを送信中です..."
     },
     "errors": {
       "saveFailed": "保存に失敗しました"
@@ -388,6 +398,100 @@
     "statusOptions": { "Draft": "下書き", "Published": "公開中" },
     "categoryOptions": { "Long": "ロング", "Medium": "ミディアム", "Short": "ショート", "Color": "カラー" }
   },
+  "templateTaskPage": {
+    "title": "テンプレート作成タスク",
+    "description": "Aitori Stylist のテンプレート作成タスクページ。step1 と step2 の進行を表示し、完了後にテンプレート編集ページへ進めます。",
+    "badge": "テンプレートタスク",
+    "lead": "テンプレート作成後、step1 と step2 が自動で進行します。このページで結果をお待ちください。",
+    "pendingName": "テンプレートを作成中です...",
+    "resourceFallback": "プレビューはまだありません",
+    "bundleReady": "テンプレートパックが生成されました",
+    "bundlePending": "テンプレートパックは生成後に表示されます",
+    "actions": {
+      "editTemplate": "テンプレート情報を編集",
+      "backToList": "テンプレート一覧へ戻る"
+    },
+    "labels": {
+      "status": "タスク状態",
+      "createdAt": "作成日時",
+      "taskId": "タスク ID",
+      "refHair": "参考ヘア",
+      "model": "AIモデル",
+      "clothes": "服装",
+      "background": "背景",
+      "resultImage": "カバー結果",
+      "error": "エラー内容"
+    },
+    "states": {
+      "WAIT": {
+        "label": "開始待ち",
+        "description": "テンプレート情報は保存済みで、step1 の開始を待っています。"
+      },
+      "PROCESSING": {
+        "label": "作成中",
+        "description": "テンプレート作成の進行を確認中です。しばらくお待ちください。"
+      },
+      "STEP1_SUCCESS": {
+        "label": "Step 1 完了",
+        "description": "参考ヘアの解析が完了し、テンプレートカバー生成へ進んでいます。"
+      },
+      "STEP2_SUCCESS": {
+        "label": "作成完了",
+        "description": "テンプレートカバーとテンプレートパックの生成が完了しました。編集ページで基本情報を確認できます。"
+      },
+      "FAILED": {
+        "label": "作成失敗",
+        "description": "今回のテンプレート作成は完了できませんでした。素材を確認して再試行してください。"
+      }
+    },
+    "steps": {
+      "step1": {
+        "title": "Step 1 参考ヘア解析",
+        "description": "step1 テンプレートを読み込み、中間ヘア画像を生成します。"
+      },
+      "step2": {
+        "title": "Step 2 テンプレートカバー合成",
+        "description": "テンプレートパックを組み立て、最終カバー画像を生成します。"
+      }
+    }
+  },
+  "templateEditPage": {
+    "title": "テンプレート情報編集",
+    "description": "Aitori Stylist のテンプレート編集ページ。ヘアテンプレートの基本情報のみを更新できます。",
+    "back": "前のページへ戻る",
+    "badge": "テンプレート編集",
+    "lead": "テンプレートカバーを確認しながら、名称、状態、カテゴリ、タグ、説明を整えます。",
+    "preview": {
+      "badge": "テンプレートプレビュー",
+      "title": "テンプレートカバー",
+      "imageAlt": "テンプレートカバープレビュー",
+      "downloadBundle": "テンプレートパックをダウンロード",
+      "bundleUnavailable": "テンプレートパックはまだ利用できません"
+    },
+    "info": {
+      "badge": "テンプレート情報",
+      "title": "テンプレート基本情報",
+      "name": "テンプレート名",
+      "namePlaceholder": "例: やわらかレイヤーロング",
+      "status": "状態",
+      "category": "カテゴリ",
+      "tags": "タグ",
+      "tagsPlaceholder": "例: 初回提案, 顔周り, エアリー感",
+      "description": "説明",
+      "descriptionPlaceholder": "例: 長さを残しつつ軽やかな印象を提案したい顧客向け"
+    },
+    "states": {
+      "loading": "テンプレート情報を読み込み中です...",
+      "notFound": "テンプレートが存在しないか削除されました"
+    },
+    "errors": {
+      "saveFailed": "保存に失敗しました"
+    },
+    "submitTitle": "テンプレート情報を保存",
+    "submitLead": "保存後はテンプレート詳細へ戻り、利用状況や結果を続けて確認できます。",
+    "statusOptions": { "DRAFT": "下書き", "PUBLIC": "公開中" },
+    "categoryOptions": { "LONG": "ロング", "MEDIUM": "ミディアム", "SHORT": "ショート", "DYE": "カラー" }
+  },
   "simulationEditPage": {
     "title": "AIヘアシミュレーション微調整",
     "description": "Aitori Stylist の AI ヘアシミュレーション微調整ページ。現在のバージョンでは未開発シェルのみを残し、実際の微調整フローは後続 Case で実装します。",

+ 122 - 18
frontend/src/i18n/locales/zh/translation.json

@@ -240,7 +240,7 @@
     "tableHeaders": ["顾客", "到店计划", "咨询内容", "状态", "操作"],
     "hairLengthMap": { "short": "短发", "medium": "中长发", "long": "长发" },
     "statusMap": { "none": "待设置" },
-    "detail": "详情",
+    "detail": "查看任务",
     "pagination": { "prev": "上一页", "next": "下一页" }
   },
   "templateListPage": {
@@ -253,11 +253,19 @@
       "searchLabel": "发型模板搜索",
       "searchPlaceholder": "按名称、分类、标签或建议备注搜索",
       "categoryLabel": "分类",
-      "categoryOptions": ["全部", "长发", "中长发", "短发"],
-      "purposeLabel": "用途",
-      "purposeOptions": ["全部", "首次建议", "染发建议", "跟进建议"],
+      "categoryOptions": [
+        { "value": "", "label": "全部" },
+        { "value": "LONG", "label": "长发" },
+        { "value": "MEDIUM", "label": "中长发" },
+        { "value": "SHORT", "label": "短发" },
+        { "value": "DYE", "label": "染发" }
+      ],
       "statusLabel": "状态",
-      "statusOptions": ["全部", "公开中", "草稿"]
+      "statusOptions": [
+        { "value": "", "label": "全部" },
+        { "value": "PUBLIC", "label": "公开中" },
+        { "value": "DRAFT", "label": "草稿" }
+      ]
     },
     "stats": {
       "total": "已登记模板",
@@ -268,10 +276,12 @@
     "resultBadge": "发型模板列表",
     "resultTitle": "搜索结果 {{count}}件",
     "sortRecent": "排序:最近使用",
-    "categoryMap": { "Long": "长发", "Medium": "中长发", "Short": "短发", "Color": "染发" },
-    "statusMap": { "Draft": "草稿", "Published": "公开中" },
+    "categoryMap": { "LONG": "长发", "MEDIUM": "中长发", "SHORT": "短发", "DYE": "染发" },
+    "statusMap": { "DRAFT": "草稿", "PUBLIC": "公开中" },
     "usedCount": "使用 {{count}}次",
-    "detail": "详情",
+    "detail": "查看任务",
+    "selectAll": "全选",
+    "deselectAll": "取消全选",
     "pagination": { "prev": "上一页", "next": "下一页" }
   },
   "templateFormPage": {
@@ -302,33 +312,33 @@
     "model": {
       "badge": "AI模特",
       "title": "选择AI模特",
-      "options": { "natural": "自然", "soft": "柔美", "fashion": "时尚", "mature": "成熟" }
+      "preset": "使用预设模特",
+      "upload": "上传自定义模特",
+      "uploadLabel": "上传模特图片"
     },
     "outfit": {
       "badge": "服装",
       "title": "选择服装",
-      "options": ["米色上衣", "白衬衫", "黑色上衣", "沙龙披肩", "上传自定义服装照片"],
-      "upload": "服装照片上传",
-      "previewAlt": "服装照片预览"
+      "preset": "使用预设服装",
+      "upload": "上传自定义服装",
+      "uploadLabel": "上传服装图片"
     },
     "background": {
       "badge": "背景",
       "title": "选择背景",
-      "options": ["白色背景", "沙龙内景", "自然光", "上传自定义背景照片"],
-      "upload": "上传沙龙背景照片",
-      "previewAlt": "背景照片预览"
+      "preset": "使用预设背景",
+      "upload": "上传自定义背景",
+      "uploadLabel": "上传背景图片"
     },
     "submitTitle": "生成AI发型模板",
     "submitLead": "提交后,将根据参考图片以及所选模特、服装和背景生成发型模板。",
     "generate": "AI生成",
-    "assetPreparationHint": "资源未准备完成,请先执行资源上传脚本",
     "loadingAssets": "正在加载素材...",
     "assetLoadFailed": "素材加载失败,请刷新后重试",
     "assetEmpty": "素材未配置完成,请检查服务器配置",
     "submitting": {
       "uploading": "正在上传参考图...",
-      "generating": "正在生成模板,可能需要一点时间...",
-      "saving": "正在保存模板..."
+      "generating": "正在提交模板制作任务..."
     },
     "errors": {
       "saveFailed": "保存失败"
@@ -388,6 +398,100 @@
     "statusOptions": { "Draft": "草稿", "Published": "公开中" },
     "categoryOptions": { "Long": "长发", "Medium": "中长发", "Short": "短发", "Color": "染发" }
   },
+  "templateTaskPage": {
+    "title": "模板制作任务",
+    "description": "Aitori Stylist 模板制作任务页面。展示模板制作的 step1 和 step2 进度,并在完成后跳转到模板编辑页。",
+    "badge": "模板任务",
+    "lead": "模板创建后会自动推进 step1 和 step2,请停留在当前页面等待结果。",
+    "pendingName": "正在创建模板...",
+    "resourceFallback": "暂无预览",
+    "bundleReady": "模板包已生成",
+    "bundlePending": "模板包生成后会显示",
+    "actions": {
+      "editTemplate": "编辑模板信息",
+      "backToList": "返回模板列表"
+    },
+    "labels": {
+      "status": "任务状态",
+      "createdAt": "创建时间",
+      "taskId": "任务 ID",
+      "refHair": "参考发型",
+      "model": "AI模特",
+      "clothes": "服装",
+      "background": "背景",
+      "resultImage": "封面结果",
+      "error": "错误信息"
+    },
+    "states": {
+      "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": "组合模板包并生成最终模板封面。"
+      }
+    }
+  },
+  "templateEditPage": {
+    "title": "编辑模板信息",
+    "description": "Aitori Stylist 模板编辑页面。仅维护发型模板的基本信息,包括模板名称、状态、分类、标签和说明。",
+    "back": "返回上一页",
+    "badge": "模板编辑",
+    "lead": "确认模板封面后,在这里维护模板名称、状态、分类、标签和说明。",
+    "preview": {
+      "badge": "模板预览",
+      "title": "模板封面",
+      "imageAlt": "模板封面预览",
+      "downloadBundle": "下载模板包",
+      "bundleUnavailable": "模板包暂不可用"
+    },
+    "info": {
+      "badge": "模板信息",
+      "title": "发型模板基本信息",
+      "name": "模板名称",
+      "namePlaceholder": "例: 柔和层次长发",
+      "status": "状态",
+      "category": "分类",
+      "tags": "标签",
+      "tagsPlaceholder": "例: 首次建议, 脸周, 空气感",
+      "description": "说明",
+      "descriptionPlaceholder": "例: 适合保留长度并增加轻盈层次感的顾客"
+    },
+    "states": {
+      "loading": "正在加载模板信息...",
+      "notFound": "模板不存在或已删除"
+    },
+    "errors": {
+      "saveFailed": "保存失败"
+    },
+    "submitTitle": "保存模板信息",
+    "submitLead": "保存后返回模板详情页继续查看模板效果和使用结果。",
+    "statusOptions": { "DRAFT": "草稿", "PUBLIC": "公开中" },
+    "categoryOptions": { "LONG": "长发", "MEDIUM": "中长发", "SHORT": "短发", "DYE": "染发" }
+  },
   "simulationEditPage": {
     "title": "AI发型模拟微调",
     "description": "Aitori Stylist AI发型模拟微调页面。当前版本仅保留未开发壳页面,后续 Case 再接入真实微调流程。",

+ 234 - 155
frontend/src/pages/dashboard/templates/TemplateDetailPage.tsx

@@ -1,78 +1,118 @@
 import { useEffect, useState } from 'react'
 import { Link, useParams } from 'react-router-dom'
 import { Helmet } from 'react-helmet-async'
-import type { TemplateDetailStats, TemplateEntity } from '../../../services/api/contracts'
-import { getTemplateById, getTemplateStats, getTemplateUsageResults, updateTemplate, type TemplateUsageResult } from '../../../services/api/templates'
+import type { TemplateDetailStats } from '../../../services/api/contracts'
+import {
+  getTemplateBasicInfo,
+  getTemplateStats,
+  getTemplateUsageResults,
+  type TemplateBasicInfo,
+  type TemplateUsageResult,
+} from '../../../services/api/templates'
 import { usePageContext } from '../../../shared/pageContext'
 import { DashboardLayout } from '../../../layouts/DashboardLayout'
 
-function getAssetLabelFromUrl(url?: string) {
-  if (!url) return '-'
-  const lastSegment = url.split('/').pop()
-  return lastSegment || url
+const FALLBACK_PAGE_COPY = {
+  title: '发型模板详情',
+  description: '查看模板封面、基本信息与使用情况。',
+  back: '返回发型模板列表',
+  badge: '模板详情',
+  lead: '确认模板封面、基础信息和顾客使用结果。',
+  preview: {
+    badge: '预览',
+    title: '模板预览',
+    imageAlt: '模板封面',
+    downloadBundle: '下载模板包',
+    bundleUnavailable: '模板包尚未生成',
+  },
+  info: {
+    badge: '模板信息',
+    title: '发型模板基本信息',
+    name: '模板名称',
+    status: '状态',
+    category: '分类',
+    tags: '标签',
+    description: '说明',
+  },
+  stats: {
+    customers: '使用顾客数',
+    saveRate: '提案后保存率',
+    monthlyGenerated: '本月生成次数',
+  },
+  results: {
+    badge: '顾客生成结果',
+    title: '使用该模板的顾客',
+    action: '查看全部',
+    empty: '暂无顾客生成结果',
+  },
+  states: {
+    loading: '正在加载模板详情...',
+    notFound: '模板不存在或已删除',
+  },
+  statusOptions: {
+    DRAFT: '草稿',
+    PUBLIC: '公开中',
+  },
+  categoryOptions: {
+    LONG: '长发',
+    MEDIUM: '中长发',
+    SHORT: '短发',
+    DYE: '染发',
+  },
+} as const
+
+function renderValue(value?: string) {
+  return value && value.trim() ? value : '-'
 }
 
 export function TemplateDetailPage() {
   const { locale, path, copy } = usePageContext()
-  const params = useParams()
-  const [template, setTemplate] = useState<TemplateEntity | null>(null)
+  const params = useParams<{ templateId: string }>()
+  const pageCopy = copy.templateDetailPage ?? FALLBACK_PAGE_COPY
+  const [template, setTemplate] = useState<TemplateBasicInfo | null>(null)
   const [loading, setLoading] = useState(true)
   const [notFound, setNotFound] = useState(false)
-  const [name, setName] = useState('')
-  const [status, setStatus] = useState('Draft')
-  const [category, setCategory] = useState('Long')
-  const [tags, setTags] = useState('')
-  const [description, setDescription] = useState('')
   const [usageResults, setUsageResults] = useState<TemplateUsageResult[]>([])
   const [stats, setStats] = useState<TemplateDetailStats>({
     customerCount: 0,
     saveRate: 0,
     monthlyGeneratedCount: 0,
   })
-  const [saving, setSaving] = useState(false)
-  const [error, setError] = useState('')
 
-  const pageCopy = copy.templateDetailPage
   useEffect(() => {
     let cancelled = false
-    let timer: number | undefined
 
-    const loadTemplate = async (showLoading: boolean) => {
-      if (showLoading) {
-        setLoading(true)
+    const load = async () => {
+      if (!params.templateId) {
+        setNotFound(true)
+        setLoading(false)
+        return
       }
 
+      setLoading(true)
       setNotFound(false)
 
       try {
-        const item = await getTemplateById({ templateId: params.templateId })
+        const [templateData, statsData, usageData] = await Promise.all([
+          getTemplateBasicInfo(params.templateId),
+          getTemplateStats(params.templateId).catch(() => ({
+            customerCount: 0,
+            saveRate: 0,
+            monthlyGeneratedCount: 0,
+          })),
+          getTemplateUsageResults(params.templateId).catch(() => []),
+        ])
+
         if (cancelled) return
 
-        if (!item) {
+        setTemplate(templateData)
+        setStats(statsData)
+        setUsageResults(usageData)
+      } catch {
+        if (!cancelled) {
           setTemplate(null)
           setNotFound(true)
-          return
-        }
-
-        setTemplate(item)
-        setName(item.name)
-        setStatus(item.status)
-        setCategory(item.category)
-        setTags((item.tags || []).join(', '))
-        setDescription(item.description)
-
-        if (
-          item.generationStatus === 'pending' ||
-          item.generationStatus === 'processing'
-        ) {
-          timer = window.setTimeout(() => {
-            void loadTemplate(false)
-          }, 1500)
         }
-      } catch {
-        if (cancelled) return
-        setTemplate(null)
-        setNotFound(true)
       } finally {
         if (!cancelled) {
           setLoading(false)
@@ -80,56 +120,20 @@ export function TemplateDetailPage() {
       }
     }
 
-    void loadTemplate(true)
-
-    if (params.templateId) {
-      getTemplateUsageResults(params.templateId).then(setUsageResults).catch(() => setUsageResults([]))
-      getTemplateStats(params.templateId)
-        .then(setStats)
-        .catch(() =>
-          setStats({
-            customerCount: 0,
-            saveRate: 0,
-            monthlyGeneratedCount: 0,
-          }),
-        )
-    }
+    void load()
 
     return () => {
       cancelled = true
-      if (timer) {
-        window.clearTimeout(timer)
-      }
     }
   }, [params.templateId])
 
-  const handleSave = async () => {
-    if (!template || saving) return
-    setError('')
-    setSaving(true)
-    try {
-      const updated = await updateTemplate(template.id, {
-        name,
-        status,
-        category,
-        description,
-        tags: tags.split(',').map((tag) => tag.trim()).filter(Boolean),
-      })
-      setTemplate(updated)
-    } catch (err) {
-      setError(err instanceof Error ? err.message : '保存失败')
-    } finally {
-      setSaving(false)
-    }
-  }
-
   if (loading) {
     return (
       <DashboardLayout>
-        <section className="pt-0">
+        <section className="stylist-workbench-top ipad-top-space-margin mb-2">
           <div className="container">
             <p className="fs-14 text-medium-gray mt-4" role="status">
-              {pageCopy.states?.loading || '正在加载模板详情...'}
+              {pageCopy.states.loading}
             </p>
           </div>
         </section>
@@ -140,10 +144,10 @@ export function TemplateDetailPage() {
   if (notFound || !template) {
     return (
       <DashboardLayout>
-        <section className="pt-0">
+        <section className="stylist-workbench-top ipad-top-space-margin mb-2">
           <div className="container">
             <p className="fs-14 text-red mt-4" role="alert">
-              {pageCopy.states?.notFound || '模板不存在或已删除'}
+              {pageCopy.states.notFound}
             </p>
           </div>
         </section>
@@ -161,14 +165,25 @@ export function TemplateDetailPage() {
       <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('/templates')} 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 className="d-flex flex-wrap gap-2">
-              <Link to={path('/templates')} 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="button" onClick={handleSave} disabled={saving} className="btn btn-large btn-rounded btn-base-color btn-box-shadow fw-700"><i className="feather icon-feather-save me-10px"></i>{copy.common.save}</button>
-            </div>
+            <Link
+              to={path('/templates')}
+              className="btn btn-small btn-rounded btn-transparent-light-gray border border-1 border-color-dark-gray fw-700"
+            >
+              <i className="feather icon-feather-arrow-left me-6px"></i>
+              {pageCopy.back}
+            </Link>
+            <Link
+              to={path(`/templates/${template.id}/edit`)}
+              className="btn btn-large btn-rounded btn-base-color btn-box-shadow fw-700"
+            >
+              <i className="feather icon-feather-edit me-10px"></i>
+              {copy.common.edit}
+            </Link>
           </div>
           <div className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px">
-            <span className="bg-base-color d-inline-block mb-15px fw-600 text-white text-uppercase border-radius-30px ps-20px pe-20px fs-12">{pageCopy.badge}</span>
+            <span className="bg-base-color d-inline-block mb-15px fw-600 text-white text-uppercase border-radius-30px ps-20px pe-20px fs-12">
+              {pageCopy.badge}
+            </span>
             <h1 className="fw-700 text-dark-gray ls-minus-1px mb-10px">{pageCopy.title}</h1>
             <p className="fs-18 lh-30 mb-0">{pageCopy.lead}</p>
           </div>
@@ -177,86 +192,150 @@ export function TemplateDetailPage() {
 
       <section className="pt-0">
         <div className="container">
-          {error ? <p className="fs-14 text-red mb-3" role="alert">{error}</p> : null}
-          <form className="customer-edit-form">
-            <div className="row g-4">
-              <div className="col-xl-5 col-lg-6">
-                <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><span className="fs-14 fw-700 text-base-color text-uppercase">{pageCopy.preview.badge}</span><h2 className="fw-700 text-dark-gray fs-26 mb-0">{pageCopy.preview.title}</h2></div>
-                    <span className="template-status-badge">{pageCopy.statusOptions[status as keyof typeof pageCopy.statusOptions] || status}</span>
-                  </div>
-                  <div className="template-detail-preview"><img src={template.image || '/images/aitori-simulation-result.png'} alt={pageCopy.preview.imageAlt} /></div>
-                  <div className="template-detail-summary mt-20px">
-                    <div><span>{pageCopy.preview.generationStatus}</span><strong>{template.generationStatus || pageCopy.preview.pending}</strong></div>
-                    <div><span>{pageCopy.preview.progress}</span><strong>{template.generationProgressMessage || pageCopy.preview.progressFallback}</strong></div>
-                    <div><span>{pageCopy.preview.createdAt}</span><strong>{getAssetLabelFromUrl(template.modelImageUrl)}</strong></div>
-                  </div>
-                  <div className="d-flex flex-wrap gap-2 mt-20px">
-                    {template.generationStatus === 'succeeded' && 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"
-                      >
-                        {pageCopy.preview.downloadBundle}
-                      </a>
-                    ) : (
-                      <span className="fs-14 text-medium-gray">{pageCopy.preview.bundleUnavailable}</span>
-                    )}
+          <div className="row g-4">
+            <div className="col-xl-5 col-lg-6">
+              <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>
+                    <span className="fs-14 fw-700 text-base-color text-uppercase">
+                      {pageCopy.preview.badge}
+                    </span>
+                    <h2 className="fw-700 text-dark-gray fs-26 mb-0">{pageCopy.preview.title}</h2>
                   </div>
+                  <span className="template-status-badge">
+                    {pageCopy.statusOptions[template.status] ?? template.status}
+                  </span>
+                </div>
+                <div className="template-detail-preview">
+                  <img
+                    src={template.coverUrl || '/images/aitori-simulation-result.png'}
+                    alt={pageCopy.preview.imageAlt}
+                  />
+                </div>
+                <div className="d-flex flex-wrap gap-2 mt-20px">
+                  {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"
+                    >
+                      {pageCopy.preview.downloadBundle}
+                    </a>
+                  ) : (
+                    <span className="fs-14 text-medium-gray">
+                      {pageCopy.preview.bundleUnavailable}
+                    </span>
+                  )}
                 </div>
               </div>
+            </div>
 
-              <div className="col-xl-7 col-lg-6">
-                <div className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px h-100">
-                  <span className="fs-14 fw-700 text-base-color text-uppercase">{pageCopy.info.badge}</span>
-                  <h2 className="fw-700 text-dark-gray fs-26 mb-25px">{pageCopy.info.title}</h2>
-                  <div className="row g-3">
-                    <div className="col-md-7"><label htmlFor="template-name-detail" className="customer-edit-label">{pageCopy.info.name} <span className="text-base-color">*</span></label><input id="template-name-detail" type="text" className="form-control customer-edit-control" value={name} onChange={(event) => setName(event.target.value)} required /></div>
-                    <div className="col-md-5"><label htmlFor="template-visibility-detail" className="customer-edit-label">{pageCopy.info.status}</label><select id="template-visibility-detail" className="form-select customer-edit-control" value={status} onChange={(event) => setStatus(event.target.value)}><option value="Draft">{pageCopy.statusOptions.Draft}</option><option value="Published">{pageCopy.statusOptions.Published}</option></select></div>
-                    <div className="col-md-6"><label htmlFor="template-category-detail" className="customer-edit-label">{pageCopy.info.category}</label><select id="template-category-detail" className="form-select customer-edit-control" value={category} onChange={(event) => setCategory(event.target.value)}><option value="Long">{pageCopy.categoryOptions.Long}</option><option value="Medium">{pageCopy.categoryOptions.Medium}</option><option value="Short">{pageCopy.categoryOptions.Short}</option><option value="Color">{pageCopy.categoryOptions.Color}</option></select></div>
-                    <div className="col-md-6"><label htmlFor="template-tags-detail" className="customer-edit-label">{pageCopy.info.tags}</label><input id="template-tags-detail" type="text" className="form-control customer-edit-control" value={tags} onChange={(event) => setTags(event.target.value)} /></div>
-                    <div className="col-12"><label htmlFor="template-description-detail" className="customer-edit-label">{pageCopy.info.description}</label><textarea id="template-description-detail" className="form-control customer-edit-control" rows={6} value={description} onChange={(event) => setDescription(event.target.value)}></textarea></div>
+            <div className="col-xl-7 col-lg-6">
+              <div className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px h-100">
+                <span className="fs-14 fw-700 text-base-color text-uppercase">
+                  {pageCopy.info.badge}
+                </span>
+                <h2 className="fw-700 text-dark-gray fs-26 mb-25px">{pageCopy.info.title}</h2>
+                <div className="row g-3">
+                  <div className="col-md-7">
+                    <span className="customer-edit-label">{pageCopy.info.name}</span>
+                    <div className="form-control customer-edit-control d-flex align-items-center">
+                      {renderValue(template.templateName)}
+                    </div>
+                  </div>
+                  <div className="col-md-5">
+                    <span className="customer-edit-label">{pageCopy.info.status}</span>
+                    <div className="form-control customer-edit-control d-flex align-items-center">
+                      {pageCopy.statusOptions[template.status] ?? template.status}
+                    </div>
+                  </div>
+                  <div className="col-md-6">
+                    <span className="customer-edit-label">{pageCopy.info.category}</span>
+                    <div className="form-control customer-edit-control d-flex align-items-center">
+                      {pageCopy.categoryOptions[template.category] ?? template.category}
+                    </div>
+                  </div>
+                  <div className="col-md-6">
+                    <span className="customer-edit-label">{pageCopy.info.tags}</span>
+                    <div className="form-control customer-edit-control d-flex align-items-center">
+                      {renderValue(template.tags.join(', '))}
+                    </div>
+                  </div>
+                  <div className="col-12">
+                    <span className="customer-edit-label">{pageCopy.info.description}</span>
+                    <div className="form-control customer-edit-control min-height-180px">
+                      {renderValue(template.description)}
+                    </div>
                   </div>
                 </div>
               </div>
             </div>
+          </div>
 
-            <div className="row g-4 mt-1">
-              <div className="col-lg-4"><div className="workbench-stat bg-white border-radius-8px p-20px h-100"><span className="d-block fs-26 fw-800 text-dark-gray">{stats.customerCount}</span><span className="fs-14">{pageCopy.stats.customers}</span></div></div>
-              <div className="col-lg-4"><div className="workbench-stat bg-white border-radius-8px p-20px h-100"><span className="d-block fs-26 fw-800 text-dark-gray">{stats.saveRate}%</span><span className="fs-14">{pageCopy.stats.saveRate}</span></div></div>
-              <div className="col-lg-4"><div className="workbench-stat bg-white border-radius-8px p-20px h-100"><span className="d-block fs-26 fw-800 text-dark-gray">{stats.monthlyGeneratedCount}</span><span className="fs-14">{pageCopy.stats.monthlyGenerated}</span></div></div>
+          <div className="row g-4 mt-1">
+            <div className="col-lg-4">
+              <div className="workbench-stat bg-white border-radius-8px p-20px h-100">
+                <span className="d-block fs-26 fw-800 text-dark-gray">{stats.customerCount}</span>
+                <span className="fs-14">{pageCopy.stats.customers}</span>
+              </div>
             </div>
-
-            <div className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px mt-4">
-              <div className="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-25px">
-                <div><span className="fs-14 fw-700 text-base-color text-uppercase">{pageCopy.results.badge}</span><h2 className="fw-700 text-dark-gray fs-26 mb-0">{pageCopy.results.title}</h2></div>
-                <a href="#" className="btn btn-small btn-rounded btn-transparent-light-gray border border-1 border-color-dark-gray fw-700">{pageCopy.results.action}</a>
+            <div className="col-lg-4">
+              <div className="workbench-stat bg-white border-radius-8px p-20px h-100">
+                <span className="d-block fs-26 fw-800 text-dark-gray">{stats.saveRate}%</span>
+                <span className="fs-14">{pageCopy.stats.saveRate}</span>
               </div>
-
-              {usageResults.length === 0 ? (
-                <p className="fs-14 text-medium-gray" role="status">
-                  {pageCopy.results.empty}
-                </p>
-              ) : (
-                <div className="template-result-grid">
-                  {usageResults.map((item, index) => (
-                    <article key={`${item.date}-${index}`} className="template-result-card"><div className="template-result-images">{item.beforeImage ? <img src={item.beforeImage} alt="before" /> : null}{item.afterImage ? <img src={item.afterImage} alt="after" /> : null}</div><div className="template-result-body"><div><span className="template-status-badge">{item.status}</span><span className="fs-13 text-medium-gray">{item.date}</span></div><p>{item.note}</p></div></article>
-                  ))}
-                </div>
-              )}
             </div>
+            <div className="col-lg-4">
+              <div className="workbench-stat bg-white border-radius-8px p-20px h-100">
+                <span className="d-block fs-26 fw-800 text-dark-gray">
+                  {stats.monthlyGeneratedCount}
+                </span>
+                <span className="fs-14">{pageCopy.stats.monthlyGenerated}</span>
+              </div>
+            </div>
+          </div>
 
-            <div className="customer-edit-actions workbench-panel border-radius-8px p-25px mt-4">
-              <div><strong>{pageCopy.submitTitle}</strong><span>{pageCopy.submitLead}</span></div>
-              <div className="d-flex flex-wrap gap-2">
-                <Link to={path('/templates')} 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="button" onClick={handleSave} disabled={saving} className="btn btn-large btn-rounded btn-base-color btn-box-shadow fw-700"><i className="feather icon-feather-save me-10px"></i>{copy.common.save}</button>
+          <div className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px mt-4">
+            <div className="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-25px">
+              <div>
+                <span className="fs-14 fw-700 text-base-color text-uppercase">
+                  {pageCopy.results.badge}
+                </span>
+                <h2 className="fw-700 text-dark-gray fs-26 mb-0">{pageCopy.results.title}</h2>
               </div>
+              <a
+                href="#"
+                className="btn btn-small btn-rounded btn-transparent-light-gray border border-1 border-color-dark-gray fw-700"
+              >
+                {pageCopy.results.action}
+              </a>
             </div>
-          </form>
+
+            {usageResults.length === 0 ? (
+              <p className="fs-14 text-medium-gray" role="status">
+                {pageCopy.results.empty}
+              </p>
+            ) : (
+              <div className="template-result-grid">
+                {usageResults.map((item, index) => (
+                  <article key={`${item.date}-${index}`} className="template-result-card">
+                    <div className="template-result-images">
+                      {item.beforeImage ? <img src={item.beforeImage} alt="before" /> : null}
+                      {item.afterImage ? <img src={item.afterImage} alt="after" /> : null}
+                    </div>
+                    <div className="template-result-body">
+                      <div>
+                        <span className="template-status-badge">{item.status}</span>
+                        <span className="fs-13 text-medium-gray">{item.date}</span>
+                      </div>
+                      <p>{item.note}</p>
+                    </div>
+                  </article>
+                ))}
+              </div>
+            )}
+          </div>
         </div>
       </section>
     </DashboardLayout>

+ 380 - 0
frontend/src/pages/dashboard/templates/TemplateEditPage.tsx

@@ -0,0 +1,380 @@
+import { useEffect, useState } from 'react'
+import { Link, useParams, useSearchParams } from 'react-router-dom'
+import { Helmet } from 'react-helmet-async'
+import type { TemplateBasicInfo } from '../../../services/api/templates'
+import { getTemplateBasicInfo, updateTemplateBasicInfo } from '../../../services/api/templates'
+import { usePageContext } from '../../../shared/pageContext'
+import { DashboardLayout } from '../../../layouts/DashboardLayout'
+
+const FALLBACK_PAGE_COPY = {
+  title: '编辑模板信息',
+  description: '维护发型模板的基本信息。',
+  back: '返回上一页',
+  badge: '模板编辑',
+  lead: '在这里维护模板名称、状态、分类、标签和说明。',
+  preview: {
+    badge: '模板预览',
+    title: '模板封面',
+    imageAlt: '模板封面预览',
+    downloadBundle: '下载模板包',
+    bundleUnavailable: '模板包暂不可用',
+  },
+  info: {
+    badge: '模板信息',
+    title: '发型模板基本信息',
+    name: '模板名称',
+    namePlaceholder: '请输入模板名称',
+    status: '状态',
+    category: '分类',
+    tags: '标签',
+    tagsPlaceholder: '层次, 空气感',
+    description: '说明',
+    descriptionPlaceholder: '请输入说明',
+  },
+  states: {
+    loading: '正在加载模板信息...',
+    notFound: '模板不存在或已删除',
+  },
+  errors: {
+    saveFailed: '保存失败',
+  },
+  submitTitle: '保存模板信息',
+  submitLead: '保存后可继续查看模板详情与使用结果。',
+  statusOptions: {
+    DRAFT: '草稿',
+    PUBLIC: '公开中',
+  },
+  categoryOptions: {
+    LONG: '长发',
+    MEDIUM: '中长发',
+    SHORT: '短发',
+    DYE: '染发',
+  },
+} as const
+
+type EditableStatus = TemplateBasicInfo['status']
+type EditableCategory = TemplateBasicInfo['category']
+
+function toTagInput(tags: string[]) {
+  return tags.join(', ')
+}
+
+export function TemplateEditPage() {
+  const params = useParams<{ templateId: string }>()
+  const [searchParams] = useSearchParams()
+  const { locale, path, copy } = usePageContext()
+  const pageCopy = copy.templateEditPage ?? FALLBACK_PAGE_COPY
+  const taskId = searchParams.get('taskId')?.trim()
+  const returnTarget = taskId ? path(`/templates/tasks/${taskId}`) : path('/templates')
+  const [template, setTemplate] = useState<TemplateBasicInfo | null>(null)
+  const [loading, setLoading] = useState(true)
+  const [notFound, setNotFound] = useState(false)
+  const [templateName, setTemplateName] = useState('')
+  const [status, setStatus] = useState<EditableStatus>('DRAFT')
+  const [category, setCategory] = useState<EditableCategory>('LONG')
+  const [tags, setTags] = useState('')
+  const [description, setDescription] = useState('')
+  const [saving, setSaving] = useState(false)
+  const [error, setError] = useState('')
+
+  useEffect(() => {
+    let cancelled = false
+
+    const load = async () => {
+      if (!params.templateId) {
+        setNotFound(true)
+        setLoading(false)
+        return
+      }
+
+      setLoading(true)
+      setNotFound(false)
+
+      try {
+        const data = await getTemplateBasicInfo(params.templateId)
+        if (cancelled) return
+
+        setTemplate(data)
+        setTemplateName(data.templateName)
+        setStatus(data.status)
+        setCategory(data.category)
+        setTags(toTagInput(data.tags))
+        setDescription(data.description)
+      } catch {
+        if (!cancelled) {
+          setTemplate(null)
+          setNotFound(true)
+        }
+      } finally {
+        if (!cancelled) {
+          setLoading(false)
+        }
+      }
+    }
+
+    void load()
+
+    return () => {
+      cancelled = true
+    }
+  }, [params.templateId])
+
+  const handleSave = async () => {
+    if (!params.templateId || saving) return
+
+    setSaving(true)
+    setError('')
+
+    try {
+      const updated = await updateTemplateBasicInfo(params.templateId, {
+        templateName,
+        status,
+        category,
+        tags: tags
+          .split(',')
+          .map((tag) => tag.trim())
+          .filter(Boolean),
+        description,
+      })
+
+      setTemplate(updated)
+      setTemplateName(updated.templateName)
+      setStatus(updated.status)
+      setCategory(updated.category)
+      setTags(toTagInput(updated.tags))
+      setDescription(updated.description)
+    } catch (err) {
+      setError(err instanceof Error ? err.message : pageCopy.errors.saveFailed)
+    } finally {
+      setSaving(false)
+    }
+  }
+
+  if (loading) {
+    return (
+      <DashboardLayout>
+        <section className="stylist-workbench-top ipad-top-space-margin mb-2">
+          <div className="container">
+            <p className="fs-14 text-medium-gray" role="status">
+              {pageCopy.states.loading}
+            </p>
+          </div>
+        </section>
+      </DashboardLayout>
+    )
+  }
+
+  if (notFound || !template) {
+    return (
+      <DashboardLayout>
+        <section className="stylist-workbench-top ipad-top-space-margin mb-2">
+          <div className="container">
+            <p className="fs-14 text-red" role="alert">
+              {pageCopy.states.notFound}
+            </p>
+          </div>
+        </section>
+      </DashboardLayout>
+    )
+  }
+
+  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">
+        <div className="container">
+          <div className="d-flex align-items-center justify-content-between flex-wrap gap-3 mb-4">
+            <Link
+              to={returnTarget}
+              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 className="d-flex flex-wrap gap-2">
+              <Link
+                to={returnTarget}
+                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="button"
+                onClick={handleSave}
+                disabled={saving}
+                className="btn btn-large btn-rounded btn-base-color btn-box-shadow fw-700"
+              >
+                <i className="feather icon-feather-save me-10px"></i>
+                {copy.common.save}
+              </button>
+            </div>
+          </div>
+          <div className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px">
+            <span className="bg-base-color d-inline-block mb-15px fw-600 text-white text-uppercase border-radius-30px ps-20px pe-20px fs-12">
+              {pageCopy.badge}
+            </span>
+            <h1 className="fw-700 text-dark-gray ls-minus-1px mb-10px">{pageCopy.title}</h1>
+            <p className="fs-18 lh-30 mb-0">{pageCopy.lead}</p>
+          </div>
+        </div>
+      </section>
+
+      <section className="pt-0">
+        <div className="container">
+          {error ? (
+            <p className="fs-14 text-red mb-3" role="alert">
+              {error}
+            </p>
+          ) : null}
+          <div className="row g-4">
+            <div className="col-xl-5 col-lg-6">
+              <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>
+                    <span className="fs-14 fw-700 text-base-color text-uppercase">
+                      {pageCopy.preview.badge}
+                    </span>
+                    <h2 className="fw-700 text-dark-gray fs-26 mb-0">{pageCopy.preview.title}</h2>
+                  </div>
+                  <span className="template-status-badge">
+                    {pageCopy.statusOptions[status] ?? status}
+                  </span>
+                </div>
+                <div className="template-detail-preview">
+                  <img
+                    src={template.coverUrl || '/images/aitori-simulation-result.png'}
+                    alt={pageCopy.preview.imageAlt}
+                  />
+                </div>
+                <div className="d-flex flex-wrap gap-2 mt-20px">
+                  {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"
+                    >
+                      {pageCopy.preview.downloadBundle}
+                    </a>
+                  ) : (
+                    <span className="fs-14 text-medium-gray">
+                      {pageCopy.preview.bundleUnavailable}
+                    </span>
+                  )}
+                </div>
+              </div>
+            </div>
+
+            <div className="col-xl-7 col-lg-6">
+              <div className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px h-100">
+                <span className="fs-14 fw-700 text-base-color text-uppercase">
+                  {pageCopy.info.badge}
+                </span>
+                <h2 className="fw-700 text-dark-gray fs-26 mb-25px">{pageCopy.info.title}</h2>
+                <div className="row g-3">
+                  <div className="col-md-7">
+                    <label htmlFor="template-name-edit" className="customer-edit-label">
+                      {pageCopy.info.name}
+                    </label>
+                    <input
+                      id="template-name-edit"
+                      type="text"
+                      className="form-control customer-edit-control"
+                      value={templateName}
+                      onChange={(event) => setTemplateName(event.target.value)}
+                      placeholder={pageCopy.info.namePlaceholder}
+                    />
+                  </div>
+                  <div className="col-md-5">
+                    <label htmlFor="template-status-edit" className="customer-edit-label">
+                      {pageCopy.info.status}
+                    </label>
+                    <select
+                      id="template-status-edit"
+                      className="form-select customer-edit-control"
+                      value={status}
+                      onChange={(event) => setStatus(event.target.value as EditableStatus)}
+                    >
+                      <option value="DRAFT">{pageCopy.statusOptions.DRAFT}</option>
+                      <option value="PUBLIC">{pageCopy.statusOptions.PUBLIC}</option>
+                    </select>
+                  </div>
+                  <div className="col-md-6">
+                    <label htmlFor="template-category-edit" className="customer-edit-label">
+                      {pageCopy.info.category}
+                    </label>
+                    <select
+                      id="template-category-edit"
+                      className="form-select customer-edit-control"
+                      value={category}
+                      onChange={(event) => setCategory(event.target.value as EditableCategory)}
+                    >
+                      <option value="LONG">{pageCopy.categoryOptions.LONG}</option>
+                      <option value="MEDIUM">{pageCopy.categoryOptions.MEDIUM}</option>
+                      <option value="SHORT">{pageCopy.categoryOptions.SHORT}</option>
+                      <option value="DYE">{pageCopy.categoryOptions.DYE}</option>
+                    </select>
+                  </div>
+                  <div className="col-md-6">
+                    <label htmlFor="template-tags-edit" className="customer-edit-label">
+                      {pageCopy.info.tags}
+                    </label>
+                    <input
+                      id="template-tags-edit"
+                      type="text"
+                      className="form-control customer-edit-control"
+                      value={tags}
+                      onChange={(event) => setTags(event.target.value)}
+                      placeholder={pageCopy.info.tagsPlaceholder}
+                    />
+                  </div>
+                  <div className="col-12">
+                    <label htmlFor="template-description-edit" className="customer-edit-label">
+                      {pageCopy.info.description}
+                    </label>
+                    <textarea
+                      id="template-description-edit"
+                      className="form-control customer-edit-control"
+                      rows={6}
+                      value={description}
+                      onChange={(event) => setDescription(event.target.value)}
+                      placeholder={pageCopy.info.descriptionPlaceholder}
+                    />
+                  </div>
+                </div>
+              </div>
+            </div>
+          </div>
+
+          <div className="customer-edit-actions workbench-panel border-radius-8px p-25px mt-4">
+            <div>
+              <strong>{pageCopy.submitTitle}</strong>
+              <span>{pageCopy.submitLead}</span>
+            </div>
+            <div className="d-flex flex-wrap gap-2">
+              <Link
+                to={returnTarget}
+                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="button"
+                onClick={handleSave}
+                disabled={saving}
+                className="btn btn-large btn-rounded btn-base-color btn-box-shadow fw-700"
+              >
+                <i className="feather icon-feather-save me-10px"></i>
+                {copy.common.save}
+              </button>
+            </div>
+          </div>
+        </div>
+      </section>
+    </DashboardLayout>
+  )
+}

+ 159 - 109
frontend/src/pages/dashboard/templates/TemplateFormPage.tsx

@@ -4,12 +4,13 @@ import { Helmet } from 'react-helmet-async'
 import { usePageContext, usePreviewUrl } from '../../../shared/pageContext'
 import { DashboardLayout } from '../../../layouts/DashboardLayout'
 import {
-  createTemplateAndStartGeneration,
+  createTemplateMakeTask,
   getTemplateAssets,
+  type TemplateAssetOption,
+  type TemplateAssets,
 } from '../../../services/api/templates'
 import { uploadMedia } from '../../../services/api/media'
 import { ApiError } from '../../../services/http/client'
-import type { TemplateAssetOption } from '../../../services/api/contracts'
 
 function getSubmitErrorMessage(err: unknown, fallback: string) {
   if (
@@ -23,28 +24,18 @@ function getSubmitErrorMessage(err: unknown, fallback: string) {
   return err instanceof Error ? err.message : fallback
 }
 
-type TemplateOption = {
-  id: string
-  label: string
-  url: string
-  previewUrl: string
-}
+type AssetSource = 'preset' | 'upload'
 
-function toTemplateOptions(items: TemplateAssetOption[]): TemplateOption[] {
-  return items.map((item) => ({
-    id: item.id,
-    label: item.name,
-    url: item.url,
-    previewUrl: item.url,
-  }))
+const EMPTY_ASSETS: TemplateAssets = {
+  models: [],
+  clothes: [],
+  backgrounds: [],
 }
 
 export function TemplateFormPage() {
   const { locale, navigate, path, copy } = usePageContext()
   const pageCopy = copy.templateFormPage
-  const [modelOptions, setModelOptions] = useState<TemplateOption[]>([])
-  const [outfitOptions, setOutfitOptions] = useState<TemplateOption[]>([])
-  const [backgroundOptions, setBackgroundOptions] = useState<TemplateOption[]>([])
+  const [assets, setAssets] = useState<TemplateAssets>(EMPTY_ASSETS)
   const [assetsLoading, setAssetsLoading] = useState(true)
   const [assetsError, setAssetsError] = useState('')
   const [name, setName] = useState('')
@@ -56,6 +47,12 @@ export function TemplateFormPage() {
   const [selectedModelId, setSelectedModelId] = useState('')
   const [selectedOutfitId, setSelectedOutfitId] = useState('')
   const [selectedBackgroundId, setSelectedBackgroundId] = useState('')
+  const [modelSource, setModelSource] = useState<AssetSource>('preset')
+  const [outfitSource, setOutfitSource] = useState<AssetSource>('preset')
+  const [backgroundSource, setBackgroundSource] = useState<AssetSource>('preset')
+  const [customModelFile, setCustomModelFile] = useState<File | null>(null)
+  const [customOutfitFile, setCustomOutfitFile] = useState<File | null>(null)
+  const [customBackgroundFile, setCustomBackgroundFile] = useState<File | null>(null)
   const referencePreview = usePreviewUrl(referenceFile, '/images/stylist-profile/hairstyle/long/User3-4.jpg')
   const [submitting, setSubmitting] = useState(false)
   const [submitPhase, setSubmitPhase] = useState<'idle' | 'uploading' | 'generating'>('idle')
@@ -72,35 +69,18 @@ export function TemplateFormPage() {
         const data = await getTemplateAssets()
         if (cancelled) return
 
-        const nextModels = toTemplateOptions(data.models)
-        const nextOutfits = toTemplateOptions(data.clothes)
-        const nextBackgrounds = toTemplateOptions(data.backgrounds)
-
-        setModelOptions(nextModels)
-        setOutfitOptions(nextOutfits)
-        setBackgroundOptions(nextBackgrounds)
+        setAssets(data)
+        setSelectedModelId((current) => current || data.models[0]?.id || '')
+        setSelectedOutfitId((current) => current || data.clothes[0]?.id || '')
+        setSelectedBackgroundId((current) => current || data.backgrounds[0]?.id || '')
 
-        if (nextModels.length === 0 || nextOutfits.length === 0 || nextBackgrounds.length === 0) {
-          setSelectedModelId('')
-          setSelectedOutfitId('')
-          setSelectedBackgroundId('')
+        if (data.models.length === 0 || data.clothes.length === 0 || data.backgrounds.length === 0) {
           setAssetsError(pageCopy.assetEmpty || '素材未配置完成,请检查服务器配置')
-          return
         }
-
-        setSelectedModelId(nextModels[0].id)
-        setSelectedOutfitId(nextOutfits[0].id)
-        setSelectedBackgroundId(nextBackgrounds[0].id)
       } catch {
-        if (!cancelled) {
-          setModelOptions([])
-          setOutfitOptions([])
-          setBackgroundOptions([])
-          setSelectedModelId('')
-          setSelectedOutfitId('')
-          setSelectedBackgroundId('')
-          setAssetsError(pageCopy.assetLoadFailed || '素材加载失败,请刷新后重试')
-        }
+        if (cancelled) return
+        setAssets(EMPTY_ASSETS)
+        setAssetsError(pageCopy.assetLoadFailed || '素材加载失败,请刷新后重试')
       } finally {
         if (!cancelled) {
           setAssetsLoading(false)
@@ -115,15 +95,24 @@ export function TemplateFormPage() {
     }
   }, [pageCopy.assetEmpty, pageCopy.assetLoadFailed])
 
-  const hasReadyAssets = useMemo(
-    () =>
-      !assetsLoading &&
-      !assetsError &&
-      modelOptions.length > 0 &&
-      outfitOptions.length > 0 &&
-      backgroundOptions.length > 0,
-    [assetsLoading, assetsError, modelOptions, outfitOptions, backgroundOptions],
+  const selectedModel = useMemo(
+    () => assets.models.find((item) => item.id === selectedModelId) ?? null,
+    [assets.models, selectedModelId],
   )
+  const selectedOutfit = useMemo(
+    () => assets.clothes.find((item) => item.id === selectedOutfitId) ?? null,
+    [assets.clothes, selectedOutfitId],
+  )
+  const selectedBackground = useMemo(
+    () => assets.backgrounds.find((item) => item.id === selectedBackgroundId) ?? null,
+    [assets.backgrounds, selectedBackgroundId],
+  )
+  const hasReadyAssets =
+    !assetsLoading &&
+    !assetsError &&
+    assets.models.length > 0 &&
+    assets.clothes.length > 0 &&
+    assets.backgrounds.length > 0
 
   const submitMessage =
     submitPhase === 'uploading'
@@ -143,32 +132,60 @@ export function TemplateFormPage() {
         throw new Error(pageCopy.validation?.referenceRequired || '请先上传参考图')
       }
 
-      const selectedModel = modelOptions.find((item) => item.id === selectedModelId)
-      const selectedOutfit = outfitOptions.find((item) => item.id === selectedOutfitId)
-      const selectedBackground = backgroundOptions.find(
-        (item) => item.id === selectedBackgroundId,
-      )
+      setSubmitPhase('uploading')
+      const refHairUrl = (
+        await uploadMedia(referenceFile, 'ai-template-origin-image')
+      ).url
+
+      let modelUrl = selectedModel?.url ?? ''
+      let clothesUrl = selectedOutfit?.url ?? ''
+      let backgroundUrl = selectedBackground?.url ?? ''
 
-      if (!selectedModel || !selectedOutfit || !selectedBackground) {
-        throw new Error(pageCopy.validation?.assetsRequired || '请选择模特、服装和背景')
+      if (modelSource === 'upload') {
+        if (!customModelFile) {
+          throw new Error(pageCopy.validation?.assetsRequired || '请选择模特、服装和背景')
+        }
+        modelUrl = (await uploadMedia(customModelFile, 'ai-template-model-image')).url
       }
 
-      setSubmitPhase('uploading')
-      const referenceImage = (await uploadMedia(referenceFile)).url
+      if (outfitSource === 'upload') {
+        if (!customOutfitFile) {
+          throw new Error(pageCopy.validation?.assetsRequired || '请选择模特、服装和背景')
+        }
+        clothesUrl = (await uploadMedia(customOutfitFile, 'ai-template-clothing-image')).url
+      }
+
+      if (backgroundSource === 'upload') {
+        if (!customBackgroundFile) {
+          throw new Error(pageCopy.validation?.assetsRequired || '请选择模特、服装和背景')
+        }
+        backgroundUrl = (await uploadMedia(customBackgroundFile, 'ai-template-background-image')).url
+      }
+
+      if (!modelUrl || !clothesUrl || !backgroundUrl) {
+        throw new Error(pageCopy.validation?.assetsRequired || '请选择模特、服装和背景')
+      }
 
       setSubmitPhase('generating')
-      const template = await createTemplateAndStartGeneration({
-        name,
-        category,
-        status,
+      const task = await createTemplateMakeTask({
+        templateName: name,
+        category:
+          category === 'Long'
+            ? 'LONG'
+            : category === 'Medium'
+              ? 'MEDIUM'
+              : category === 'Short'
+                ? 'SHORT'
+                : 'DYE',
+        status: status === 'Published' ? 'PUBLIC' : 'DRAFT',
         description,
         tags: tags.split(',').map((tag) => tag.trim()).filter(Boolean),
-        referenceImage,
-        modelImageUrl: selectedModel.url,
-        outfitImageUrl: selectedOutfit.url,
-        backgroundImageUrl: selectedBackground.url,
+        refHairUrl,
+        modelUrl,
+        clothesUrl,
+        backgroundUrl,
       })
-      navigate(path(`/templates/${template.id}`))
+      navigate(path(`/templates/tasks/${task.taskId}`))
     } catch (err) {
       setError(getSubmitErrorMessage(err, pageCopy.errors?.saveFailed || '保存失败'))
     } finally {
@@ -223,7 +240,7 @@ export function TemplateFormPage() {
                   <span className="fs-14 fw-700 text-base-color text-uppercase">{pageCopy.info.badge}</span>
                   <h2 className="fw-700 text-dark-gray fs-26 mb-25px">{pageCopy.info.title}</h2>
                   <div className="row g-3">
-                    <div className="col-md-7"><label htmlFor="template-name" className="customer-edit-label">{pageCopy.info.name} <span className="text-base-color">*</span></label><input id="template-name" type="text" className="form-control customer-edit-control" value={name} onChange={(event) => setName(event.target.value)} placeholder={pageCopy.info.namePlaceholder} required /></div>
+                    <div className="col-md-7"><label htmlFor="template-name" className="customer-edit-label">{pageCopy.info.name} <span className="text-base-color">*</span></label><input aria-label={`${pageCopy.info.name} *`} id="template-name" type="text" className="form-control customer-edit-control" value={name} onChange={(event) => setName(event.target.value)} placeholder={pageCopy.info.namePlaceholder} required /></div>
                     <div className="col-md-5"><label htmlFor="template-visibility" className="customer-edit-label">{pageCopy.info.status}</label><select id="template-visibility" className="form-select customer-edit-control" value={status} onChange={(event) => setStatus(event.target.value)}><option value="Draft">{pageCopy.statusOptions.Draft}</option><option value="Published">{pageCopy.statusOptions.Published}</option></select></div>
                     <div className="col-md-6"><label htmlFor="template-category" className="customer-edit-label">{pageCopy.info.category}</label><select id="template-category" className="form-select customer-edit-control" value={category} onChange={(event) => setCategory(event.target.value)}><option value="Long">{pageCopy.categoryOptions.Long}</option><option value="Medium">{pageCopy.categoryOptions.Medium}</option><option value="Short">{pageCopy.categoryOptions.Short}</option><option value="Color">{pageCopy.categoryOptions.Color}</option></select></div>
                     <div className="col-md-6"><label htmlFor="template-tags" className="customer-edit-label">{pageCopy.info.tags}</label><input id="template-tags" type="text" className="form-control customer-edit-control" value={tags} onChange={(event) => setTags(event.target.value)} placeholder={pageCopy.info.tagsPlaceholder} /></div>
@@ -246,20 +263,31 @@ export function TemplateFormPage() {
                   {assetsError}
                 </p>
               ) : null}
-              <div className="template-option-grid">
-                {modelOptions.map((item) => (
-                  <label key={item.id} className="template-option-card">
-                    <input
-                      type="radio"
-                      name="ai-model"
-                      checked={selectedModelId === item.id}
-                      onChange={() => setSelectedModelId(item.id)}
-                    />
-                    <img src={item.previewUrl} alt={item.label} />
-                    <span>{item.label}</span>
-                  </label>
-                ))}
+              <div className="template-source-toggle">
+                <button type="button" className={`btn btn-small btn-rounded ${modelSource === 'preset' ? 'btn-base-color' : 'btn-transparent-light-gray border border-1 border-color-dark-gray'}`} onClick={() => setModelSource('preset')}>{pageCopy.model.preset || '使用预设模特'}</button>
+                <button type="button" className={`btn btn-small btn-rounded ${modelSource === 'upload' ? 'btn-base-color' : 'btn-transparent-light-gray border border-1 border-color-dark-gray'}`} onClick={() => setModelSource('upload')}>{pageCopy.model.upload || '上传自定义模特'}</button>
               </div>
+              {modelSource === 'preset' ? (
+                <div className="template-option-grid">
+                  {assets.models.map((item: TemplateAssetOption) => (
+                    <label key={item.id} className="template-option-card">
+                      <input
+                        type="radio"
+                        name="ai-model"
+                        checked={selectedModelId === item.id}
+                        onChange={() => setSelectedModelId(item.id)}
+                      />
+                      <img src={item.url} alt={item.name} />
+                      <span>{item.name}</span>
+                    </label>
+                  ))}
+                </div>
+              ) : (
+                <div className="template-upload-inline">
+                  <label htmlFor="template-model-upload" className="customer-edit-label">{pageCopy.model.uploadLabel || '上传模特图片'}</label>
+                  <input id="template-model-upload" aria-label={pageCopy.model.uploadLabel || '上传模特图片'} type="file" accept="image/*" className="form-control customer-edit-control" onChange={(event) => setCustomModelFile(event.target.files?.[0] || null)} />
+                </div>
+              )}
             </div>
 
             <div className="row g-4 mt-1">
@@ -267,20 +295,31 @@ export function TemplateFormPage() {
                 <div className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px h-100">
                   <span className="fs-14 fw-700 text-base-color text-uppercase">{pageCopy.outfit.badge}</span>
                   <h2 className="fw-700 text-dark-gray fs-26 mb-25px">{pageCopy.outfit.title}</h2>
-                  <div className="template-option-grid">
-                    {outfitOptions.map((item) => (
-                      <label key={item.id} className="template-option-card">
-                        <input
-                          type="radio"
-                          name="outfit"
-                          checked={selectedOutfitId === item.id}
-                          onChange={() => setSelectedOutfitId(item.id)}
-                        />
-                        <img src={item.previewUrl} alt={item.label} />
-                        <span>{item.label}</span>
-                      </label>
-                    ))}
+                  <div className="template-source-toggle">
+                    <button type="button" className={`btn btn-small btn-rounded ${outfitSource === 'preset' ? 'btn-base-color' : 'btn-transparent-light-gray border border-1 border-color-dark-gray'}`} onClick={() => setOutfitSource('preset')}>{pageCopy.outfit.preset || '使用预设服装'}</button>
+                    <button type="button" className={`btn btn-small btn-rounded ${outfitSource === 'upload' ? 'btn-base-color' : 'btn-transparent-light-gray border border-1 border-color-dark-gray'}`} onClick={() => setOutfitSource('upload')}>{pageCopy.outfit.upload || '上传自定义服装'}</button>
                   </div>
+                  {outfitSource === 'preset' ? (
+                    <div className="template-option-grid">
+                      {assets.clothes.map((item: TemplateAssetOption) => (
+                        <label key={item.id} className="template-option-card">
+                          <input
+                            type="radio"
+                            name="outfit"
+                            checked={selectedOutfitId === item.id}
+                            onChange={() => setSelectedOutfitId(item.id)}
+                          />
+                          <img src={item.url} alt={item.name} />
+                          <span>{item.name}</span>
+                        </label>
+                      ))}
+                    </div>
+                  ) : (
+                    <div className="template-upload-inline">
+                      <label htmlFor="template-outfit-upload" className="customer-edit-label">{pageCopy.outfit.uploadLabel || '上传服装图片'}</label>
+                      <input id="template-outfit-upload" aria-label={pageCopy.outfit.uploadLabel || '上传服装图片'} type="file" accept="image/*" className="form-control customer-edit-control" onChange={(event) => setCustomOutfitFile(event.target.files?.[0] || null)} />
+                    </div>
+                  )}
                 </div>
               </div>
 
@@ -288,20 +327,31 @@ export function TemplateFormPage() {
                 <div className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px h-100">
                   <span className="fs-14 fw-700 text-base-color text-uppercase">{pageCopy.background.badge}</span>
                   <h2 className="fw-700 text-dark-gray fs-26 mb-25px">{pageCopy.background.title}</h2>
-                  <div className="template-option-grid">
-                    {backgroundOptions.map((item) => (
-                      <label key={item.id} className="template-option-card">
-                        <input
-                          type="radio"
-                          name="background"
-                          checked={selectedBackgroundId === item.id}
-                          onChange={() => setSelectedBackgroundId(item.id)}
-                        />
-                        <img src={item.previewUrl} alt={item.label} />
-                        <span>{item.label}</span>
-                      </label>
-                    ))}
+                  <div className="template-source-toggle">
+                    <button type="button" className={`btn btn-small btn-rounded ${backgroundSource === 'preset' ? 'btn-base-color' : 'btn-transparent-light-gray border border-1 border-color-dark-gray'}`} onClick={() => setBackgroundSource('preset')}>{pageCopy.background.preset || '使用预设背景'}</button>
+                    <button type="button" className={`btn btn-small btn-rounded ${backgroundSource === 'upload' ? 'btn-base-color' : 'btn-transparent-light-gray border border-1 border-color-dark-gray'}`} onClick={() => setBackgroundSource('upload')}>{pageCopy.background.upload || '上传自定义背景'}</button>
                   </div>
+                  {backgroundSource === 'preset' ? (
+                    <div className="template-option-grid">
+                      {assets.backgrounds.map((item: TemplateAssetOption) => (
+                        <label key={item.id} className="template-option-card">
+                          <input
+                            type="radio"
+                            name="background"
+                            checked={selectedBackgroundId === item.id}
+                            onChange={() => setSelectedBackgroundId(item.id)}
+                          />
+                          <img src={item.url} alt={item.name} />
+                          <span>{item.name}</span>
+                        </label>
+                      ))}
+                    </div>
+                  ) : (
+                    <div className="template-upload-inline">
+                      <label htmlFor="template-background-upload" className="customer-edit-label">{pageCopy.background.uploadLabel || '上传背景图片'}</label>
+                      <input id="template-background-upload" aria-label={pageCopy.background.uploadLabel || '上传背景图片'} type="file" accept="image/*" className="form-control customer-edit-control" onChange={(event) => setCustomBackgroundFile(event.target.files?.[0] || null)} />
+                    </div>
+                  )}
                 </div>
               </div>
             </div>

+ 272 - 0
frontend/src/pages/dashboard/templates/TemplateMakeTaskPage.tsx

@@ -0,0 +1,272 @@
+import { useEffect, useState } from 'react'
+import { Link, useParams } from 'react-router-dom'
+import { Helmet } from 'react-helmet-async'
+import { DashboardLayout } from '../../../layouts/DashboardLayout'
+import {
+  getTemplateMakeTaskById,
+  type TemplateMakeTaskDetail,
+} from '../../../services/api/templates'
+import { usePageContext } from '../../../shared/pageContext'
+
+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: '错误信息',
+  },
+  states: {
+    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: '组合模板包并生成最终模板封面。',
+    },
+  },
+} 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)
+}
+
+function getTaskTone(status?: TemplateMakeTaskDetail['taskStatus']) {
+  if (status === 'STEP2_SUCCESS') 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 [task, setTask] = useState<TemplateMakeTaskDetail | null>(null)
+
+  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,
+    },
+  ]
+
+  useEffect(() => {
+    let cancelled = false
+    let timer: number | undefined
+
+    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)
+      }
+    }
+
+    void load()
+
+    return () => {
+      cancelled = true
+      if (timer) {
+        window.clearTimeout(timer)
+      }
+    }
+  }, [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">
+        <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>
+              <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>
+                  {task?.step1.resultImage ? (
+                    <img
+                      src={task.step1.resultImage}
+                      alt={step1Copy.title}
+                      className="template-task-step-preview"
+                    />
+                  ) : null}
+                </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>
+              </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>
+              <div className="template-task-bundle-note">
+                <span>{task?.step2.bundleUrl ? pageCopy.bundleReady || FALLBACK_PAGE_COPY.bundleReady : pageCopy.bundlePending || FALLBACK_PAGE_COPY.bundlePending}</span>
+              </div>
+            </div>
+          </div>
+        </div>
+      </section>
+    </DashboardLayout>
+  )
+}

+ 127 - 0
frontend/src/pages/dashboard/templates/__tests__/TemplateDetailPage.test.tsx

@@ -0,0 +1,127 @@
+import { render, screen, waitFor } from '@testing-library/react'
+import { HelmetProvider } from 'react-helmet-async'
+import { MemoryRouter, Route, Routes } from 'react-router-dom'
+import type { ReactNode } from 'react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { TemplateDetailPage } from '../TemplateDetailPage'
+
+const { getTemplateBasicInfo, getTemplateStats, getTemplateUsageResults } = vi.hoisted(() => ({
+  getTemplateBasicInfo: vi.fn(),
+  getTemplateStats: vi.fn(),
+  getTemplateUsageResults: vi.fn(),
+}))
+
+vi.mock('../../../../layouts/DashboardLayout', () => ({
+  DashboardLayout: ({ children }: { children: ReactNode }) => <>{children}</>,
+}))
+
+vi.mock('../../../../services/api/templates', () => ({
+  getTemplateBasicInfo,
+  getTemplateStats,
+  getTemplateUsageResults,
+}))
+
+vi.mock('../../../../shared/pageContext', () => ({
+  usePageContext: () => ({
+    locale: 'zh',
+    path: (path = '') => `/zh${path}`,
+    copy: {
+      common: {
+        edit: '编辑',
+      },
+      templateDetailPage: {
+        title: '模板详情',
+        description: 'desc',
+        back: '返回模板列表',
+        badge: '模板详情',
+        lead: 'lead',
+        preview: {
+          badge: '预览',
+          title: '模板封面',
+          imageAlt: '模板封面',
+          downloadBundle: '下载模板包',
+          bundleUnavailable: '模板包不可用',
+        },
+        info: {
+          badge: '模板信息',
+          title: '发型模板基本信息',
+          name: '模板名称',
+          status: '状态',
+          category: '分类',
+          tags: '标签',
+          description: '说明',
+        },
+        stats: {
+          customers: '使用顾客数',
+          saveRate: '提案后保存率',
+          monthlyGenerated: '本月生成次数',
+        },
+        results: {
+          badge: '顾客生成结果',
+          title: '使用该模板的顾客',
+          action: '查看全部',
+          empty: '暂无顾客生成结果',
+        },
+        states: {
+          loading: '正在加载模板详情...',
+          notFound: '模板不存在或已删除',
+        },
+        statusOptions: {
+          DRAFT: '草稿',
+          PUBLIC: '公开中',
+        },
+        categoryOptions: {
+          LONG: '长发',
+          MEDIUM: '中长发',
+          SHORT: '短发',
+          DYE: '染发',
+        },
+      },
+    },
+  }),
+}))
+
+describe('TemplateDetailPage', () => {
+  beforeEach(() => {
+    vi.clearAllMocks()
+    getTemplateBasicInfo.mockResolvedValue({
+      id: '101',
+      templateName: '柔和层次长发',
+      status: 'DRAFT',
+      category: 'LONG',
+      tags: ['层次', '空气感'],
+      description: '适合轻盈感修饰',
+      coverUrl: 'https://cdn.example.com/cover.jpg',
+      templateUrl: 'https://cdn.example.com/template.zip',
+    })
+    getTemplateStats.mockResolvedValue({
+      customerCount: 8,
+      saveRate: 64,
+      monthlyGeneratedCount: 12,
+    })
+    getTemplateUsageResults.mockResolvedValue([])
+  })
+
+  it('renders read-only basic info and links to the edit page', async () => {
+    render(
+      <HelmetProvider>
+        <MemoryRouter initialEntries={['/zh/templates/101']}>
+          <Routes>
+            <Route path="/:locale/templates/:templateId" element={<TemplateDetailPage />} />
+          </Routes>
+        </MemoryRouter>
+      </HelmetProvider>,
+    )
+
+    expect(await screen.findByText('柔和层次长发')).toBeInTheDocument()
+    expect(screen.getByText('层次, 空气感')).toBeInTheDocument()
+    expect(screen.getByRole('link', { name: '编辑' })).toHaveAttribute(
+      'href',
+      '/zh/templates/101/edit',
+    )
+
+    await waitFor(() => {
+      expect(screen.queryByRole('button', { name: '保存' })).not.toBeInTheDocument()
+    })
+  })
+})

+ 176 - 0
frontend/src/pages/dashboard/templates/__tests__/TemplateEditPage.test.tsx

@@ -0,0 +1,176 @@
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { HelmetProvider } from 'react-helmet-async'
+import { MemoryRouter, Route, Routes } from 'react-router-dom'
+import type { ReactNode } from 'react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { TemplateEditPage } from '../TemplateEditPage'
+
+const { getTemplateBasicInfo, updateTemplateBasicInfo } = vi.hoisted(() => ({
+  getTemplateBasicInfo: vi.fn(),
+  updateTemplateBasicInfo: vi.fn(),
+}))
+
+vi.mock('../../../../layouts/DashboardLayout', () => ({
+  DashboardLayout: ({ children }: { children: ReactNode }) => <>{children}</>,
+}))
+
+vi.mock('../../../../services/api/templates', () => ({
+  getTemplateBasicInfo,
+  updateTemplateBasicInfo,
+}))
+
+vi.mock('../../../../shared/pageContext', () => ({
+  usePageContext: () => ({
+    locale: 'zh',
+    path: (path = '') => `/zh${path}`,
+    copy: {
+      common: {
+        save: '保存',
+        cancel: '取消',
+      },
+      templateEditPage: {
+        title: '编辑模板信息',
+        description: 'desc',
+        back: '返回上一页',
+        badge: '模板编辑',
+        lead: 'lead',
+        preview: {
+          badge: '模板预览',
+          title: '模板封面',
+          imageAlt: '模板封面预览',
+          downloadBundle: '下载模板包',
+          bundleUnavailable: '模板包暂不可用',
+        },
+        info: {
+          badge: '模板信息',
+          title: '发型模板基本信息',
+          name: '模板名称',
+          namePlaceholder: '请输入模板名称',
+          status: '状态',
+          category: '分类',
+          tags: '标签',
+          tagsPlaceholder: '层次, 空气感',
+          description: '说明',
+          descriptionPlaceholder: '请输入说明',
+        },
+        states: {
+          loading: '正在加载模板信息...',
+          notFound: '模板不存在或已删除',
+        },
+        errors: {
+          saveFailed: '保存失败',
+        },
+        submitTitle: '保存模板信息',
+        submitLead: '保存后返回详情页',
+        statusOptions: {
+          DRAFT: '草稿',
+          PUBLIC: '公开中',
+        },
+        categoryOptions: {
+          LONG: '长发',
+          MEDIUM: '中长发',
+          SHORT: '短发',
+          DYE: '染发',
+        },
+      },
+    },
+  }),
+}))
+
+describe('TemplateEditPage', () => {
+  beforeEach(() => {
+    vi.clearAllMocks()
+    getTemplateBasicInfo.mockResolvedValue({
+      id: '101',
+      templateName: '柔和层次长发',
+      status: 'DRAFT',
+      category: 'LONG',
+      tags: ['层次', '空气感'],
+      description: '适合轻盈感修饰',
+      coverUrl: 'https://cdn.example.com/cover.jpg',
+      templateUrl: 'https://cdn.example.com/template.zip',
+    })
+    updateTemplateBasicInfo.mockResolvedValue({
+      id: '101',
+      templateName: '空气感中长发',
+      status: 'PUBLIC',
+      category: 'MEDIUM',
+      tags: ['空气感', '脸周'],
+      description: '更新后的说明',
+      coverUrl: 'https://cdn.example.com/cover.jpg',
+      templateUrl: 'https://cdn.example.com/template.zip',
+    })
+  })
+
+  it('loads template basic info and saves editable fields only', async () => {
+    render(
+      <HelmetProvider>
+        <MemoryRouter initialEntries={['/zh/templates/101/edit?taskId=88']}>
+          <Routes>
+            <Route path="/:locale/templates/:templateId/edit" element={<TemplateEditPage />} />
+          </Routes>
+        </MemoryRouter>
+      </HelmetProvider>,
+    )
+
+    expect(await screen.findByDisplayValue('柔和层次长发')).toBeInTheDocument()
+    expect(screen.getByRole('link', { name: '下载模板包' })).toHaveAttribute(
+      'href',
+      'https://cdn.example.com/template.zip',
+    )
+    expect(screen.getByRole('link', { name: '返回上一页' })).toHaveAttribute(
+      'href',
+      '/zh/templates/tasks/88',
+    )
+    expect(screen.getAllByRole('link', { name: '取消' })[0]).toHaveAttribute(
+      'href',
+      '/zh/templates/tasks/88',
+    )
+
+    fireEvent.change(screen.getByLabelText('模板名称'), {
+      target: { value: '空气感中长发' },
+    })
+    fireEvent.change(screen.getByLabelText('状态'), {
+      target: { value: 'PUBLIC' },
+    })
+    fireEvent.change(screen.getByLabelText('分类'), {
+      target: { value: 'MEDIUM' },
+    })
+    fireEvent.change(screen.getByLabelText('标签'), {
+      target: { value: '空气感, 脸周' },
+    })
+    fireEvent.change(screen.getByLabelText('说明'), {
+      target: { value: '更新后的说明' },
+    })
+
+    fireEvent.click(screen.getAllByRole('button', { name: '保存' })[0])
+
+    await waitFor(() => {
+      expect(updateTemplateBasicInfo).toHaveBeenCalledWith('101', {
+        templateName: '空气感中长发',
+        status: 'PUBLIC',
+        category: 'MEDIUM',
+        tags: ['空气感', '脸周'],
+        description: '更新后的说明',
+      })
+    })
+  })
+
+  it('falls back to the template list when no taskId is provided', async () => {
+    render(
+      <HelmetProvider>
+        <MemoryRouter initialEntries={['/zh/templates/101/edit']}>
+          <Routes>
+            <Route path="/:locale/templates/:templateId/edit" element={<TemplateEditPage />} />
+          </Routes>
+        </MemoryRouter>
+      </HelmetProvider>,
+    )
+
+    expect(await screen.findByDisplayValue('柔和层次长发')).toBeInTheDocument()
+    expect(screen.getByRole('link', { name: '返回上一页' })).toHaveAttribute(
+      'href',
+      '/zh/templates',
+    )
+  })
+})

+ 121 - 100
frontend/src/pages/dashboard/templates/__tests__/TemplateFormPage.test.tsx

@@ -1,29 +1,28 @@
-import { HelmetProvider } from 'react-helmet-async'
 import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { HelmetProvider } from 'react-helmet-async'
 import { MemoryRouter } from 'react-router-dom'
 import type { ReactNode } from 'react'
 import { beforeEach, describe, expect, it, vi } from 'vitest'
 import { TemplateFormPage } from '../TemplateFormPage'
 
-const { uploadMedia, getTemplateAssets, createTemplateAndStartGeneration, navigate } =
-  vi.hoisted(() => ({
-    uploadMedia: vi.fn(),
-    getTemplateAssets: vi.fn(),
-    createTemplateAndStartGeneration: vi.fn(),
-    navigate: vi.fn(),
-  }))
+const { createTemplateMakeTask, getTemplateAssets, uploadMedia, navigate } = vi.hoisted(() => ({
+  createTemplateMakeTask: vi.fn(),
+  getTemplateAssets: vi.fn(),
+  uploadMedia: vi.fn(),
+  navigate: vi.fn(),
+}))
 
 vi.mock('../../../../layouts/DashboardLayout', () => ({
   DashboardLayout: ({ children }: { children: ReactNode }) => <>{children}</>,
 }))
 
-vi.mock('../../../../services/api/media', () => ({
-  uploadMedia,
-}))
-
 vi.mock('../../../../services/api/templates', () => ({
+  createTemplateMakeTask,
   getTemplateAssets,
-  createTemplateAndStartGeneration,
+}))
+
+vi.mock('../../../../services/api/media', () => ({
+  uploadMedia,
 }))
 
 vi.mock('../../../../shared/pageContext', () => ({
@@ -32,100 +31,152 @@ vi.mock('../../../../shared/pageContext', () => ({
     navigate,
     path: (path = '') => `/zh${path}`,
     copy: {
-      common: { cancel: '取消' },
+      common: {
+        cancel: '取消',
+      },
       templateFormPage: {
-        title: '新建发型模板',
+        title: '新建模板',
         description: 'desc',
-        back: '返回发型模板列表',
-        badge: '创建发型模板',
-        lead: 'lead',
+        back: '返回',
         generate: 'AI生成',
-        submitTitle: '生成AI发型模板',
-        submitLead: '提交后生成',
+        badge: '模板',
+        lead: 'lead',
+        submitTitle: '提交任务',
+        submitLead: '提交后进入任务页',
         loadingAssets: '正在加载素材...',
         assetLoadFailed: '素材加载失败,请刷新后重试',
         assetEmpty: '素材未配置完成,请检查服务器配置',
         reference: {
-          badge: '参考图片',
-          title: '参考发型图片',
-          previewAlt: '参考发型图片预览',
-          upload: '选择图片并预览',
+          badge: '参考图',
+          title: '参考发型图',
+          previewAlt: '参考图预览',
+          upload: '上传参考图',
           help: 'help',
         },
         info: {
-          badge: '模板信息',
-          title: '发型模板基本信息',
+          badge: '基本信息',
+          title: '模板信息',
           name: '模板名称',
-          namePlaceholder: 'name',
+          namePlaceholder: '请输入模板名称',
           status: '状态',
           category: '分类',
           tags: '标签',
-          tagsPlaceholder: 'tags',
-          description: '说明',
-          descriptionPlaceholder: 'desc',
+          tagsPlaceholder: '首次建议,层次',
+          description: '描述',
+          descriptionPlaceholder: '请输入描述',
         },
-        model: { badge: 'AI模特', title: '选择AI模特' },
-        outfit: { badge: '服装', title: '选择服装' },
-        background: { badge: '背景', title: '选择背景' },
-        statusOptions: { Draft: '草稿', Published: '公开中' },
-        categoryOptions: {
-          Long: '长发',
-          Medium: '中长发',
-          Short: '短发',
-          Color: '染发',
+        model: {
+          badge: '模特',
+          title: '选择AI模特',
+          preset: '使用预设模特',
+          upload: '上传自定义模特',
+          uploadLabel: '上传模特图片',
+        },
+        outfit: {
+          badge: '服装',
+          title: '选择服装',
+          preset: '使用预设服装',
+          upload: '上传自定义服装',
+          uploadLabel: '上传服装图片',
+        },
+        background: {
+          badge: '背景',
+          title: '选择背景',
+          preset: '使用预设背景',
+          upload: '上传自定义背景',
+          uploadLabel: '上传背景图片',
         },
         validation: {
           referenceRequired: '请先上传参考图',
           assetsRequired: '请选择模特、服装和背景',
         },
-        errors: { saveFailed: '保存失败' },
+        errors: {
+          saveFailed: '保存失败',
+        },
         submitting: {
-          uploading: '正在上传参考图...',
-          generating: '正在提交任务...',
+          uploading: '上传中',
+          generating: '提交中',
         },
+        statusOptions: { Draft: '草稿', Published: '公开中' },
+        categoryOptions: { Long: '长发', Medium: '中长发', Short: '短发', Color: '染发' },
       },
     },
   }),
-  usePreviewUrl: (_file: File | null, fallback: string) => fallback,
+  usePreviewUrl: () => '/preview.png',
 }))
 
 describe('TemplateFormPage', () => {
   beforeEach(() => {
-    uploadMedia.mockReset()
-    getTemplateAssets.mockReset()
-    createTemplateAndStartGeneration.mockReset()
-    navigate.mockReset()
+    vi.clearAllMocks()
+    getTemplateAssets.mockResolvedValue({
+      models: [{ id: 'm1', name: '模特A', url: 'https://cdn.example.com/model-a.jpg', configKey: 'm1' }],
+      clothes: [{ id: 'c1', name: '服装A', url: 'https://cdn.example.com/clothes-a.jpg', configKey: 'c1' }],
+      backgrounds: [{ id: 'b1', name: '背景A', url: 'https://cdn.example.com/background-a.jpg', configKey: 'b1' }],
+    })
+    uploadMedia
+      .mockResolvedValueOnce({ url: 'https://cdn.example.com/reference-uploaded.jpg' })
+      .mockResolvedValueOnce({ url: 'https://cdn.example.com/model-uploaded.jpg' })
+    createTemplateMakeTask.mockResolvedValue({
+      templateId: '101',
+      taskId: '88',
+      totalTaskId: 'tmpl_x',
+      taskStatus: 'PROCESSING',
+    })
   })
 
-  function renderPage() {
-    return render(
+  it('loads dynamic assets, uploads custom model, and redirects to the task page', async () => {
+    render(
       <HelmetProvider>
         <MemoryRouter>
           <TemplateFormPage />
         </MemoryRouter>
       </HelmetProvider>,
     )
-  }
 
-  it('loads model, outfit and background options from api', async () => {
-    getTemplateAssets.mockResolvedValue({
-      models: [{ id: 'm1', name: '模特A', url: 'https://img/model-a.jpg', configKey: 'model-a' }],
-      clothes: [{ id: 'c1', name: '服装A', url: 'https://img/cloth-a.jpg', configKey: 'cloth-a' }],
-      backgrounds: [{ id: 'b1', name: '背景A', url: 'https://img/bg-a.jpg', configKey: 'bg-a' }],
+    expect(await screen.findByText('模特A')).toBeInTheDocument()
+
+    fireEvent.change(screen.getByLabelText('模板名称 *'), {
+      target: { value: '柔和层次长发' },
+    })
+    fireEvent.change(screen.getByLabelText('描述'), {
+      target: { value: '适合脸周柔和修饰' },
     })
 
-    renderPage()
+    fireEvent.change(screen.getByLabelText('上传参考图'), {
+      target: { files: [new File(['ref'], 'reference.jpg', { type: 'image/jpeg' })] },
+    })
 
-    expect(await screen.findByText('模特A')).toBeInTheDocument()
-    expect(screen.getByText('服装A')).toBeInTheDocument()
-    expect(screen.getByText('背景A')).toBeInTheDocument()
-    expect(getTemplateAssets).toHaveBeenCalledTimes(1)
+    fireEvent.click(screen.getByRole('button', { name: '上传自定义模特' }))
+    fireEvent.change(screen.getByLabelText('上传模特图片'), {
+      target: { files: [new File(['model'], 'model.jpg', { type: 'image/jpeg' })] },
+    })
+
+    fireEvent.click(screen.getAllByRole('button', { name: 'AI生成' })[0])
+
+    await waitFor(() => {
+      expect(getTemplateAssets).toHaveBeenCalledTimes(1)
+      expect(uploadMedia).toHaveBeenCalledWith(expect.any(File), 'ai-template-origin-image')
+      expect(createTemplateMakeTask).toHaveBeenCalledWith(
+        expect.objectContaining({
+          templateName: '柔和层次长发',
+          modelUrl: 'https://cdn.example.com/model-uploaded.jpg',
+          refHairUrl: 'https://cdn.example.com/reference-uploaded.jpg',
+        }),
+      )
+      expect(navigate).toHaveBeenCalledWith('/zh/templates/tasks/88')
+    })
   })
 
   it('shows error and disables submit when assets request fails', async () => {
     getTemplateAssets.mockRejectedValue(new Error('network error'))
 
-    renderPage()
+    render(
+      <HelmetProvider>
+        <MemoryRouter>
+          <TemplateFormPage />
+        </MemoryRouter>
+      </HelmetProvider>,
+    )
 
     expect(await screen.findByRole('alert')).toHaveTextContent('素材加载失败,请刷新后重试')
     expect(screen.getAllByRole('button', { name: 'AI生成' })[0]).toBeDisabled()
@@ -133,50 +184,20 @@ describe('TemplateFormPage', () => {
 
   it('disables submit when any asset group is empty', async () => {
     getTemplateAssets.mockResolvedValue({
-      models: [{ id: 'm1', name: '模特A', url: 'https://img/model-a.jpg', configKey: 'model-a' }],
+      models: [{ id: 'm1', name: '模特A', url: 'https://cdn.example.com/model-a.jpg', configKey: 'm1' }],
       clothes: [],
-      backgrounds: [{ id: 'b1', name: '背景A', url: 'https://img/bg-a.jpg', configKey: 'bg-a' }],
+      backgrounds: [{ id: 'b1', name: '背景A', url: 'https://cdn.example.com/background-a.jpg', configKey: 'b1' }],
     })
 
-    renderPage()
+    render(
+      <HelmetProvider>
+        <MemoryRouter>
+          <TemplateFormPage />
+        </MemoryRouter>
+      </HelmetProvider>,
+    )
 
     expect(await screen.findByRole('alert')).toHaveTextContent('素材未配置完成,请检查服务器配置')
     expect(screen.getAllByRole('button', { name: 'AI生成' })[0]).toBeDisabled()
   })
-
-  it('submits api asset urls instead of local static assets', async () => {
-    getTemplateAssets.mockResolvedValue({
-      models: [{ id: 'm1', name: '模特A', url: 'https://img/model-a.jpg', configKey: 'model-a' }],
-      clothes: [{ id: 'c1', name: '服装A', url: 'https://img/cloth-a.jpg', configKey: 'cloth-a' }],
-      backgrounds: [{ id: 'b1', name: '背景A', url: 'https://img/bg-a.jpg', configKey: 'bg-a' }],
-    })
-    uploadMedia.mockResolvedValue({ url: 'https://cdn.example.com/reference.jpg' })
-    createTemplateAndStartGeneration.mockResolvedValue({ id: '88' })
-
-    renderPage()
-
-    await screen.findByText('模特A')
-
-    fireEvent.change(screen.getByLabelText(/模板名称/i), { target: { value: '云感长发' } })
-    fireEvent.change(screen.getByLabelText(/说明/i), { target: { value: '说明内容' } })
-
-    const input = document.querySelector('input[type="file"]') as HTMLInputElement
-    fireEvent.change(input, {
-      target: {
-        files: [new File(['demo'], 'reference.jpg', { type: 'image/jpeg' })],
-      },
-    })
-
-    fireEvent.click(screen.getAllByRole('button', { name: 'AI生成' })[0])
-
-    await waitFor(() => {
-      expect(createTemplateAndStartGeneration).toHaveBeenCalledWith(
-        expect.objectContaining({
-          modelImageUrl: 'https://img/model-a.jpg',
-          outfitImageUrl: 'https://img/cloth-a.jpg',
-          backgroundImageUrl: 'https://img/bg-a.jpg',
-        }),
-      )
-    })
-  })
 })

+ 138 - 0
frontend/src/pages/dashboard/templates/__tests__/TemplateMakeTaskPage.test.tsx

@@ -0,0 +1,138 @@
+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 { TemplateMakeTaskPage } from '../TemplateMakeTaskPage'
+
+const { getTemplateMakeTaskById } = vi.hoisted(() => ({
+  getTemplateMakeTaskById: vi.fn(),
+}))
+
+vi.mock('../../../../layouts/DashboardLayout', () => ({
+  DashboardLayout: ({ children }: { children: ReactNode }) => <>{children}</>,
+}))
+
+vi.mock('../../../../services/api/templates', () => ({
+  getTemplateMakeTaskById,
+}))
+
+vi.mock('../../../../shared/pageContext', () => ({
+  usePageContext: () => ({
+    locale: 'zh',
+    navigate: vi.fn(),
+    path: (path = '') => `/zh${path}`,
+    copy: {
+      templateTaskPage: {
+        title: '模板制作任务',
+        description: 'desc',
+        badge: '任务',
+        lead: 'lead',
+        actions: {
+          editTemplate: '编辑模板信息',
+          backToList: '返回模板列表',
+        },
+      },
+    },
+  }),
+}))
+
+afterEach(() => {
+  vi.useRealTimers()
+})
+
+describe('TemplateMakeTaskPage', () => {
+  it('uses the shared workbench top section so content is not covered by header', async () => {
+    getTemplateMakeTaskById.mockResolvedValue({
+      id: '88',
+      templateId: '101',
+      templateName: '柔和层次长发',
+      taskStatus: 'PROCESSING',
+      errorMessage: null,
+      createTime: '2026-06-28T12:00:00.000Z',
+      refHairUrl: 'https://cdn.example.com/ref.jpg',
+      modelUrl: 'https://cdn.example.com/model.jpg',
+      clothesUrl: 'https://cdn.example.com/clothes.jpg',
+      backgroundUrl: 'https://cdn.example.com/background.jpg',
+      step1: { taskId: 'step1', status: 'PROCESSING', templateUrl: 'https://cdn.example.com/step1.json', resultImage: null },
+      step2: { taskId: '', status: 'WAIT', bundleUrl: null, resultImage: null },
+      template: { coverUrl: '', templateUrl: '', status: 'DRAFT' },
+    })
+
+    const { container } = render(
+      <HelmetProvider>
+        <MemoryRouter initialEntries={['/zh/templates/tasks/88']}>
+          <Routes>
+            <Route path="/:locale/templates/tasks/:taskId" element={<TemplateMakeTaskPage />} />
+          </Routes>
+        </MemoryRouter>
+      </HelmetProvider>,
+    )
+
+    await act(async () => {
+      await Promise.resolve()
+    })
+
+    const topSection = container.querySelector('section.stylist-workbench-top.ipad-top-space-margin')
+    expect(topSection).not.toBeNull()
+  })
+
+  it('polls until the task reaches STEP2_SUCCESS and then shows the edit CTA', async () => {
+    vi.useFakeTimers()
+    getTemplateMakeTaskById
+      .mockResolvedValueOnce({
+        id: '88',
+        templateId: '101',
+        templateName: '柔和层次长发',
+        taskStatus: 'PROCESSING',
+        errorMessage: null,
+        createTime: '2026-06-28T12:00:00.000Z',
+        refHairUrl: 'https://cdn.example.com/ref.jpg',
+        modelUrl: 'https://cdn.example.com/model.jpg',
+        clothesUrl: 'https://cdn.example.com/clothes.jpg',
+        backgroundUrl: 'https://cdn.example.com/background.jpg',
+        step1: { taskId: 'step1', status: 'PROCESSING', templateUrl: 'https://cdn.example.com/step1.json', resultImage: null },
+        step2: { taskId: '', status: 'WAIT', bundleUrl: null, resultImage: null },
+        template: { coverUrl: '', templateUrl: '', status: 'DRAFT' },
+      })
+      .mockResolvedValueOnce({
+        id: '88',
+        templateId: '101',
+        templateName: '柔和层次长发',
+        taskStatus: 'STEP2_SUCCESS',
+        errorMessage: null,
+        createTime: '2026-06-28T12:00:00.000Z',
+        refHairUrl: 'https://cdn.example.com/ref.jpg',
+        modelUrl: 'https://cdn.example.com/model.jpg',
+        clothesUrl: 'https://cdn.example.com/clothes.jpg',
+        backgroundUrl: 'https://cdn.example.com/background.jpg',
+        step1: { taskId: 'step1', status: 'STEP1_SUCCESS', templateUrl: 'https://cdn.example.com/step1.json', resultImage: 'https://cdn.example.com/step1.jpg' },
+        step2: { taskId: 'step2', status: 'STEP2_SUCCESS', bundleUrl: 'https://cdn.example.com/template.zip', resultImage: 'https://cdn.example.com/final.jpg' },
+        template: { coverUrl: 'https://cdn.example.com/final.jpg', templateUrl: 'https://cdn.example.com/template.zip', status: 'DRAFT' },
+      })
+
+    render(
+      <HelmetProvider>
+        <MemoryRouter initialEntries={['/zh/templates/tasks/88']}>
+          <Routes>
+            <Route path="/:locale/templates/tasks/:taskId" element={<TemplateMakeTaskPage />} />
+          </Routes>
+        </MemoryRouter>
+      </HelmetProvider>,
+    )
+
+    await act(async () => {
+      await Promise.resolve()
+    })
+
+    await act(async () => {
+      await vi.advanceTimersByTimeAsync(1500)
+    })
+
+    expect(getTemplateMakeTaskById).toHaveBeenCalledTimes(2)
+    expect(screen.getByRole('link', { name: '编辑模板信息' })).toHaveAttribute(
+      'href',
+      '/zh/templates/101/edit?taskId=88',
+    )
+  })
+})

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

@@ -45,6 +45,8 @@ export type TemplateEntity = {
   description: string
   tags: string[]
   image?: string
+  makeTaskId?: string
+  makeTaskStatus?: 'WAIT' | 'PROCESSING' | 'STEP1_SUCCESS' | 'STEP2_SUCCESS' | 'FAILED'
   generationJobId?: number
   templateUrl?: string
   generationStatus?: string
@@ -70,6 +72,80 @@ export type TemplateAssets = {
   backgrounds: TemplateAssetOption[]
 }
 
+export type PaginatedTemplateList = {
+  items: TemplateEntity[]
+  total: number
+  page: number
+  pageSize: number
+}
+
+export type TemplateBasicInfo = {
+  id: string
+  templateName: string
+  status: 'DRAFT' | 'PUBLIC'
+  category: 'LONG' | 'MEDIUM' | 'SHORT' | 'DYE'
+  tags: string[]
+  description: string
+  coverUrl: string
+  templateUrl: string
+}
+
+export type UpdateTemplateBasicInfoInput = {
+  templateName: string
+  status: 'DRAFT' | 'PUBLIC'
+  category: 'LONG' | 'MEDIUM' | 'SHORT' | 'DYE'
+  tags?: string[]
+  description?: string
+}
+
+export type CreateTemplateMakeTaskInput = {
+  templateName: string
+  category: 'LONG' | 'MEDIUM' | 'SHORT' | 'DYE'
+  status: 'DRAFT' | 'PUBLIC'
+  description?: string
+  tags?: string[]
+  refHairUrl: string
+  modelUrl: string
+  clothesUrl: string
+  backgroundUrl: string
+}
+
+export type TemplateMakeTaskCreateResult = {
+  templateId: string
+  taskId: string
+  totalTaskId: string
+  taskStatus: 'WAIT' | 'PROCESSING' | 'STEP1_SUCCESS' | 'STEP2_SUCCESS' | 'FAILED'
+}
+
+export type TemplateMakeTaskDetail = {
+  id: string
+  templateId: string
+  templateName: string
+  taskStatus: 'WAIT' | 'PROCESSING' | 'STEP1_SUCCESS' | 'STEP2_SUCCESS' | 'FAILED'
+  errorMessage: string | null
+  createTime: string
+  refHairUrl: string
+  modelUrl: string
+  clothesUrl: string
+  backgroundUrl: string
+  step1: {
+    taskId: string
+    status: string
+    templateUrl: string | null
+    resultImage: string | null
+  }
+  step2: {
+    taskId: string
+    status: string
+    bundleUrl: string | null
+    resultImage: string | null
+  }
+  template: {
+    coverUrl: string
+    templateUrl: string
+    status: 'DRAFT' | 'PUBLIC'
+  }
+}
 export type WorkbenchEntity = MockWorkbench
 export type SimulationResultEntity = {
   id: string

+ 4 - 5
frontend/src/services/api/pageData.ts

@@ -33,17 +33,16 @@ export async function listSimulationTemplateGallery(
   page = 1,
   size = 6,
 ): Promise<{ items: SimulationTemplateEntity[]; total: number }> {
-  const templates = await listTemplates({ status: 'Published' })
-  const items = templates.map((item) => ({
+  const templates = await listTemplates({ status: 'PUBLIC', page, pageSize: size })
+  const items = templates.items.map((item) => ({
     id: item.id,
     name: item.name,
     image: item.image,
     category: item.category,
   }))
-  const start = (page - 1) * size
   return {
-    items: items.slice(start, start + size),
-    total: items.length,
+    items,
+    total: templates.total,
   }
 }
 

Some files were not shown because too many files changed in this diff