Browse Source

merge: feature/template-form-config-assets-minimal

ChenYu 4 tháng trước cách đây
mục cha
commit
8068cffacc
52 tập tin đã thay đổi với 8168 bổ sung và 522 xóa
  1. 90 98
      backend/docs/数据库设计.md
  2. 31 1
      backend/package-lock.json
  3. 3 1
      backend/package.json
  4. 0 2
      backend/prisma/migrations/20260628050000_add_customer_follow_content/migration.sql
  5. 0 35
      backend/prisma/migrations/20260628120000_add_customer_table/migration.sql
  6. 153 0
      backend/prisma/migrations/20260628_init_v1/migration.sql
  7. 26 0
      backend/prisma/schema.prisma
  8. 109 0
      backend/src/modules/simulations/simulations.service.spec.ts
  9. 36 13
      backend/src/modules/simulations/simulations.service.ts
  10. 8 0
      backend/src/modules/templates/dto/bulk-delete-templates.dto.ts
  11. 33 0
      backend/src/modules/templates/dto/create-template-make-task.dto.ts
  12. 12 0
      backend/src/modules/templates/dto/get-template-assets.dto.ts
  13. 13 4
      backend/src/modules/templates/dto/list-templates-query.dto.ts
  14. 10 0
      backend/src/modules/templates/dto/template-basic-info.dto.ts
  15. 27 0
      backend/src/modules/templates/dto/template-make-task-detail.dto.ts
  16. 21 0
      backend/src/modules/templates/dto/update-template-basic-info.dto.ts
  17. 60 0
      backend/src/modules/templates/template-ai.client.ts
  18. 123 0
      backend/src/modules/templates/template-task-files.ts
  19. 331 0
      backend/src/modules/templates/template-task-runner.ts
  20. 107 3
      backend/src/modules/templates/templates.controller.ts
  21. 14 1
      backend/src/modules/templates/templates.module.ts
  22. 832 0
      backend/src/modules/templates/templates.service.spec.ts
  23. 555 5
      backend/src/modules/templates/templates.service.ts
  24. 6 0
      backend/src/step2/config.json
  25. 82 0
      backend/src/step2/content.json
  26. 1 0
      backend/src/types/yazl.d.ts
  27. 953 0
      backend/test/templates.e2e-spec.ts
  28. 1 1
      backend/tsconfig.json
  29. 234 0
      docs/superpowers/plans/2026-06-28-prisma-v1-baseline.md
  30. 495 0
      docs/superpowers/plans/2026-06-28-template-form-config-assets-minimal.md
  31. 225 0
      docs/superpowers/specs/2026-06-28-prisma-v1-baseline-design.md
  32. 135 0
      docs/superpowers/specs/2026-06-28-template-form-config-assets-minimal-design.md
  33. 383 0
      docs/superpowers/specs/2026-06-28-template-usage-summary-design.md
  34. 350 6
      frontend/public/css/aitori.css
  35. 4 0
      frontend/src/App.tsx
  36. 28 0
      frontend/src/__tests__/App.test.tsx
  37. 2 0
      frontend/src/config/routes.ts
  38. 125 18
      frontend/src/i18n/locales/ja/translation.json
  39. 125 18
      frontend/src/i18n/locales/zh/translation.json
  40. 234 155
      frontend/src/pages/dashboard/templates/TemplateDetailPage.tsx
  41. 380 0
      frontend/src/pages/dashboard/templates/TemplateEditPage.tsx
  42. 209 75
      frontend/src/pages/dashboard/templates/TemplateFormPage.tsx
  43. 247 68
      frontend/src/pages/dashboard/templates/TemplateListPage.tsx
  44. 272 0
      frontend/src/pages/dashboard/templates/TemplateMakeTaskPage.tsx
  45. 127 0
      frontend/src/pages/dashboard/templates/__tests__/TemplateDetailPage.test.tsx
  46. 176 0
      frontend/src/pages/dashboard/templates/__tests__/TemplateEditPage.test.tsx
  47. 203 0
      frontend/src/pages/dashboard/templates/__tests__/TemplateFormPage.test.tsx
  48. 290 0
      frontend/src/pages/dashboard/templates/__tests__/TemplateListPage.test.tsx
  49. 138 0
      frontend/src/pages/dashboard/templates/__tests__/TemplateMakeTaskPage.test.tsx
  50. 90 0
      frontend/src/services/api/contracts.ts
  51. 4 5
      frontend/src/services/api/pageData.ts
  52. 55 13
      frontend/src/services/api/templates.ts

+ 90 - 98
backend/docs/数据库设计.md

@@ -3,159 +3,151 @@ CREATE TABLE `hairstylist_user` (
   `username` VARCHAR(50) NOT NULL COMMENT '发型师用户名',
   `email` VARCHAR(100) NOT NULL COMMENT '登录邮箱',
   `password` VARCHAR(100) NOT NULL COMMENT '加密后的登录密码',
-  `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
-  `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+  `create_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
+  `update_time` DATETIME(3) NOT NULL COMMENT '更新时间',
   `is_deleted` TINYINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '逻辑删除:0未删除,1已删除',
-  PRIMARY KEY (`id`),
-  UNIQUE KEY `uk_username` (`username`,`is_deleted`) COMMENT '用户名唯一(排除已删除)',
-  UNIQUE KEY `uk_email` (`email`,`is_deleted`) COMMENT '邮箱唯一(排除已删除)',
-  KEY `idx_is_deleted` (`is_deleted`) COMMENT '删除状态索引'
+  PRIMARY KEY (`id`)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='发型师用户信息表';
 
 CREATE TABLE `customer` (
   `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '顾客主键ID',
   `hairstylist_id` BIGINT UNSIGNED NOT NULL COMMENT '所属发型师ID',
   `name` VARCHAR(30) NOT NULL COMMENT '顾客姓名(必填)',
-  `name_pronunciation` VARCHAR(50) DEFAULT '' COMMENT '姓名读音',
+  `name_pronunciation` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '姓名读音',
   `phone` VARCHAR(20) NOT NULL COMMENT '联系电话(必填)',
-  `email` VARCHAR(100) DEFAULT '' COMMENT '邮箱',
+  `email` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '邮箱',
   `birthday` DATE NULL COMMENT '生日',
-  `avatar` VARCHAR(500) DEFAULT '' COMMENT '顾客头像图片URL地址',
-  `expect_contact` ENUM('LINE','PHONE','EMAIL','NONE') NOT NULL DEFAULT 'NONE' COMMENT '期望联系方式:LINE/电话/邮箱/未设置',
-  `hair_length` ENUM('SHORT','MEDIUM','LONG','UNCONFIRM') NOT NULL DEFAULT 'UNCONFIRM' COMMENT '当前头发长度:短发/中长发/长发/未确认',
-  `hair_texture` ENUM('UNCONFIRM','THIN','NORMAL','HARD_CRUDE','CURLY') NOT NULL DEFAULT 'UNCONFIRM' COMMENT '发质:未确认/细/普通/粗硬/有自然卷',
+  `avatar` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '顾客头像图片URL地址',
+  `expect_contact` ENUM('LINE', 'PHONE', 'EMAIL', 'NONE') NOT NULL DEFAULT 'NONE' COMMENT '期望联系方式:LINE/电话/邮箱/未设置',
+  `hair_length` ENUM('SHORT', 'MEDIUM', 'LONG', 'UNCONFIRM') NOT NULL DEFAULT 'UNCONFIRM' COMMENT '当前头发长度:短发/中长发/长发/未确认',
+  `hair_texture` ENUM('UNCONFIRM', 'THIN', 'NORMAL', 'HARD_CRUDE', 'CURLY') NOT NULL DEFAULT 'UNCONFIRM' COMMENT '发质:未确认/细/普通/粗硬/有自然卷',
   `notice` TEXT NULL COMMENT '发型护理注意事项',
-  `allergy` VARCHAR(500) DEFAULT '' COMMENT '过敏史/过敏产品',
+  `allergy` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '过敏史/过敏产品',
   `inner_remark` TEXT NULL COMMENT '门店内部备注(仅内部查看)',
+  `follow_content` TEXT NULL COMMENT '跟进内容',
   `next_follow_date` DATE NULL COMMENT '下次跟进日期',
   `recommend_visit_date` DATE NULL COMMENT '推荐到店日',
-  `first_suggest_hair` ENUM('AUTO','LONG','MEDIUM','SHORT') NOT NULL DEFAULT 'AUTO' COMMENT '首次建议发型模板:自动推荐/长发/中长发/短发',
+  `first_suggest_hair` ENUM('AUTO', 'LONG', 'MEDIUM', 'SHORT') NOT NULL DEFAULT 'AUTO' COMMENT '首次建议发型模板:自动推荐/长发/中长发/短发',
   `plan_date` DATE NULL COMMENT '到店计划日',
   `is_first_register` TINYINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '是否首次登记:0否,1是',
-  `visit_purpose` ENUM('CUT_DYE','CUT','DYE','CONSULT_SIM') NULL COMMENT '到店目的:剪发+染发/剪发/染发/模拟咨询',
+  `visit_purpose` ENUM('CUT_DYE', 'CUT', 'DYE', 'CONSULT_SIM') NULL COMMENT '到店目的:剪发+染发/剪发/染发/模拟咨询',
   `trouble` TEXT NULL COMMENT '发质/发型烦恼',
   `consult_content` TEXT NULL COMMENT '详细咨询内容',
   `expect_look` TEXT NULL COMMENT '期望发型形象描述',
-  `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
-  `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+  `create_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
+  `update_time` DATETIME(3) NOT NULL COMMENT '更新时间',
   `is_deleted` TINYINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '逻辑删除 0正常 1删除',
   PRIMARY KEY (`id`),
-  UNIQUE KEY `uk_phone_del` (`phone`,`is_deleted`) COMMENT '手机号唯一,删除后可重录',
-  KEY `idx_hairstylist_is_deleted` (`hairstylist_id`,`is_deleted`) COMMENT '按发型师隔离顾客数据',
+  UNIQUE KEY `uk_phone_del` (`phone`, `is_deleted`),
   KEY `idx_is_deleted` (`is_deleted`),
-  KEY `idx_next_follow` (`next_follow_date`,`is_deleted`) COMMENT '跟进日期筛选索引',
-  KEY `idx_recommend_visit` (`recommend_visit_date`,`is_deleted`) COMMENT '推荐到店日期索引',
-  KEY `idx_plan_date` (`plan_date`,`is_deleted`) COMMENT '到店计划日筛选索引'
+  KEY `idx_hairstylist_is_deleted` (`hairstylist_id`, `is_deleted`),
+  KEY `idx_next_follow` (`next_follow_date`, `is_deleted`),
+  KEY `idx_recommend_visit` (`recommend_visit_date`, `is_deleted`),
+  KEY `idx_plan_date` (`plan_date`, `is_deleted`)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='顾客信息+咨询登记表(合并单表)';
 
 CREATE TABLE `customer_treatment_record` (
   `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '施术记录主键ID',
   `customer_id` BIGINT UNSIGNED NOT NULL COMMENT '关联顾客ID,customer表主键',
   `hairstylist_id` BIGINT UNSIGNED NOT NULL COMMENT '操作发型师ID,hairstylist_user表主键',
-  `treatment_time` DATETIME NOT NULL COMMENT '施术时间',
-  `treatment_title` VARCHAR(200) NOT NULL DEFAULT '' COMMENT '施术标题(如:剪发+漂染护理)',
+  `treatment_time` DATETIME(3) NOT NULL COMMENT '施术时间',
+  `treatment_title` VARCHAR(200) NOT NULL COMMENT '施术标题(如:剪发+漂染护理)',
   `treatment_detail` TEXT NULL COMMENT '施术详细内容、使用产品、操作备注',
-  `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '记录创建时间',
-  `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '记录更新时间',
+  `create_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录创建时间',
+  `update_time` DATETIME(3) NOT NULL COMMENT '记录更新时间',
   `is_deleted` TINYINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '逻辑删除:0正常 1删除',
-  PRIMARY KEY (`id`),
-  KEY `idx_customer_id` (`customer_id`,`is_deleted`) COMMENT '快速查询单个顾客全部施术历史',
-  KEY `idx_hairstylist_id` (`hairstylist_id`,`is_deleted`) COMMENT '查询发型师负责的所有施术记录',
-  KEY `idx_treatment_time` (`treatment_time`,`is_deleted`) COMMENT '按施术时间筛选统计'
+  PRIMARY KEY (`id`)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='顾客施术历史记录表';
 
 CREATE TABLE `ai_hair_simulation` (
   `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '模拟记录主键ID',
   `customer_id` BIGINT UNSIGNED NULL COMMENT '关联顾客ID,可为NULL(匿名无顾客)',
   `hairstylist_id` BIGINT UNSIGNED NOT NULL COMMENT '操作发型师ID,关联发型师用户表',
-  `origin_image` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '原始照片URL地址',
+  `origin_image` VARCHAR(500) NOT NULL COMMENT '原始照片URL地址',
+  `template_ids` TEXT NOT NULL COMMENT '本次模拟使用的模板ID列表',
   `simulate_remark` TEXT NULL COMMENT '本次发型模拟备注说明',
-  `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '记录创建时间',
-  `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '记录更新时间',
+  `create_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录创建时间',
+  `update_time` DATETIME(3) NOT NULL COMMENT '记录更新时间',
   `is_deleted` TINYINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '逻辑删除:0正常 1删除',
-  PRIMARY KEY (`id`),
-  KEY `idx_customer_id` (`customer_id`,`is_deleted`) COMMENT '查询指定顾客所有模拟记录',
-  KEY `idx_hairstylist_id` (`hairstylist_id`,`is_deleted`) COMMENT '查询发型师制作的模拟方案'
+  PRIMARY KEY (`id`)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='AI发型模拟记录表';
 
 CREATE TABLE `ai_hair_simulation_task` (
   `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '任务主键ID',
   `simulation_id` BIGINT UNSIGNED NOT NULL COMMENT '关联AI发型模拟表id',
-  `template_id` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '发型模板ID',
+  `template_id` VARCHAR(100) NOT NULL COMMENT '发型模板ID',
   `customer_id` BIGINT UNSIGNED NULL COMMENT '关联顾客ID,可为NULL(匿名模拟)',
   `task_id` VARCHAR(200) NOT NULL COMMENT 'AI服务侧唯一任务ID',
-  `task_status` ENUM('PROCESSING','SUCCESSES','FAILED') NOT NULL DEFAULT 'PROCESSING' COMMENT '任务执行状态:处理中/成功/失败',
+  `task_status` ENUM('PROCESSING', 'SUCCESSES', 'FAILED') NOT NULL COMMENT '任务执行状态:处理中/成功/失败',
   `result_images` TEXT NULL COMMENT '任务结果图片URL数组,JSON格式存储',
-  `task_start_time` DATETIME NOT NULL COMMENT '任务发起开始时间',
-  `task_finish_time` DATETIME NULL COMMENT '任务执行完成时间,未结束为NULL',
+  `task_start_time` DATETIME(3) NOT NULL COMMENT '任务发起开始时间',
+  `task_finish_time` DATETIME(3) NULL COMMENT '任务执行完成时间,未结束为NULL',
   `error_msg` TEXT NULL COMMENT '任务失败错误信息,成功则为空',
-  `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '记录创建时间',
-  `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '记录更新时间',
+  `create_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录创建时间',
+  `update_time` DATETIME(3) NOT NULL COMMENT '记录更新时间',
   `is_deleted` TINYINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '逻辑删除:0正常 1删除',
-  PRIMARY KEY (`id`),
-  UNIQUE KEY `uk_task_id` (`task_id`) COMMENT 'AI任务唯一防重复',
-  KEY `idx_simulation_id` (`simulation_id`,`is_deleted`) COMMENT '根据模拟记录查询所有子任务',
-  KEY `idx_customer_id` (`customer_id`,`is_deleted`) COMMENT '顾客维度筛选模拟任务',
-  KEY `idx_task_status` (`task_status`,`is_deleted`) COMMENT '筛选待处理/失败任务重试'
+  PRIMARY KEY (`id`)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='AI发型模拟异步任务表';
--- auto-generated definition
-create table hair_template
-(
-    id                 bigint unsigned auto_increment comment '模板主键ID'
-        primary key,
-    template_name      varchar(100)                                                      not null comment '模板名称(必填)',
-    cover_url          varchar(500)                            default ''                null comment '模板封面图URL',
-    status             enum ('DRAFT', 'PUBLIC')                default 'DRAFT'           not null comment '模板状态:DRAFT草稿 / PUBLIC公开',
-    category           enum ('LONG', 'MEDIUM', 'SHORT', 'DYE') default 'LONG'            not null comment '分类:长发/中长发/短发/染发',
-    tags               varchar(500)                            default ''                null comment '模板标签,逗号分隔存储',
-    description        text                                                              null comment '模板详细说明',
-    model_url          varchar(500)                            default ''                null comment 'AI模特图片URL',
-    clothes_url        varchar(500)                            default ''                null comment '服装素材URL',
-    background_url     varchar(500)                            default ''                null comment '背景图片URL',
-    ref_hair_url       varchar(500)                            default ''                null comment '参考发型图片URL',
-    hairstylist_id     bigint unsigned                                                   null comment '创建发型师ID,系统模板为空',
-    template_url       varchar(500)                            default ''                null comment '完整模板预览页面URL',
-    is_system_template enum ('YES', 'NO')                      default 'NO'              not null comment '是否系统模板:YES系统模板,NO门店自定义模板',
-    create_time        datetime                                default CURRENT_TIMESTAMP not null comment '创建时间',
-    update_time        datetime                                default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '更新时间',
-    is_deleted         tinyint unsigned                        default '0'               not null comment '逻辑删除:0正常 1删除'
-)
-    comment 'AI发型模板表';
-
-create index idx_category
-    on hair_template (category, is_deleted)
-    comment '按发型分类筛选模板';
-
-create index idx_hairstylist_id
-    on hair_template (hairstylist_id, is_deleted)
-    comment '发型师自有模板筛选索引';
-
-create index idx_status
-    on hair_template (status, is_deleted)
-    comment '按模板状态筛选';
-
 
+CREATE TABLE `hair_template` (
+  `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '模板主键ID',
+  `template_name` VARCHAR(100) NOT NULL COMMENT '模板名称(必填)',
+  `cover_url` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '模板封面图URL',
+  `status` ENUM('DRAFT', 'PUBLIC') NOT NULL COMMENT '模板状态:DRAFT草稿 / PUBLIC公开',
+  `category` ENUM('LONG', 'MEDIUM', 'SHORT', 'DYE') NOT NULL COMMENT '分类:长发/中长发/短发/染发',
+  `tags` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '模板标签,逗号分隔存储',
+  `description` TEXT NULL COMMENT '模板详细说明',
+  `model_url` VARCHAR(500) NOT NULL DEFAULT '' COMMENT 'AI模特图片URL',
+  `clothes_url` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '服装素材URL',
+  `background_url` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '背景图片URL',
+  `ref_hair_url` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '参考发型图片URL',
+  `hairstylist_id` BIGINT UNSIGNED NULL COMMENT '创建发型师ID,系统模板为空',
+  `template_url` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '完整模板预览页面URL',
+  `used_count` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '模板累计使用次数',
+  `is_system_template` ENUM('YES', 'NO') NOT NULL DEFAULT 'NO' COMMENT '是否系统模板:YES系统模板,NO门店自定义模板',
+  `create_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
+  `update_time` DATETIME(3) NOT NULL COMMENT '更新时间',
+  `is_deleted` TINYINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '逻辑删除:0正常 1删除',
+  PRIMARY KEY (`id`),
+  KEY `idx_category` (`category`, `is_deleted`),
+  KEY `idx_hairstylist_id` (`hairstylist_id`, `is_deleted`),
+  KEY `idx_status` (`status`, `is_deleted`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='AI发型模板表';
 
 CREATE TABLE `hair_template_make_task` (
   `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '本地任务自增主键',
   `total_task_id` VARCHAR(200) NOT NULL COMMENT '总任务唯一ID',
-  `step1_task_id` VARCHAR(200) DEFAULT '' COMMENT '第一步AI任务ID',
-  `step2_task_id` VARCHAR(200) DEFAULT '' COMMENT '第二步AI任务ID',
-  `task_status` ENUM('WAIT','PROCESSING','STEP1_SUCCESS','STEP2_SUCCESS','FAILED') NOT NULL DEFAULT 'WAIT' COMMENT '任务状态:等待处理/处理中/第一步完成/全部完成/失败',
+  `step1_task_id` VARCHAR(200) NOT NULL DEFAULT '' COMMENT '第一步AI任务ID',
+  `step2_task_id` VARCHAR(200) NOT NULL DEFAULT '' COMMENT '第二步AI任务ID',
+  `task_status` ENUM('WAIT', 'PROCESSING', 'STEP1_SUCCESS', 'STEP2_SUCCESS', 'FAILED') NOT NULL COMMENT '任务状态:等待处理/处理中/第一步完成/全部完成/失败',
   `template_id` BIGINT UNSIGNED NULL COMMENT '关联发型模板ID,模板生成完成后回填',
   `step1_resource` TEXT NULL COMMENT 'step1任务资源包,JSON字符串存储参数/素材信息',
   `step2_resource` TEXT NULL COMMENT 'step2任务资源包,JSON字符串存储参数/素材信息',
-  `ref_hair_url` VARCHAR(500) DEFAULT '' COMMENT '参考发型图片URL',
-  `model_url` VARCHAR(500) DEFAULT '' COMMENT 'AI模特图片URL',
-  `clothes_url` VARCHAR(500) DEFAULT '' COMMENT '服装素材URL',
-  `background_url` VARCHAR(500) DEFAULT '' COMMENT '背景图片URL',
-  `step1_result_img` VARCHAR(500) DEFAULT '' COMMENT 'step1输出结果图',
-  `step2_result_img` VARCHAR(500) DEFAULT '' COMMENT 'step2最终合成结果图',
-  `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '记录创建时间',
-  `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '记录更新时间',
+  `ref_hair_url` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '参考发型图片URL',
+  `model_url` VARCHAR(500) NOT NULL DEFAULT '' COMMENT 'AI模特图片URL',
+  `clothes_url` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '服装素材URL',
+  `background_url` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '背景图片URL',
+  `step1_result_img` VARCHAR(500) NOT NULL DEFAULT '' COMMENT 'step1输出结果图',
+  `step2_result_img` VARCHAR(500) NOT NULL DEFAULT '' COMMENT 'step2最终合成结果图',
+  `create_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录创建时间',
+  `update_time` DATETIME(3) NOT NULL COMMENT '记录更新时间',
   `is_deleted` TINYINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '逻辑删除:0正常,1删除',
-  PRIMARY KEY (`id`),
-  UNIQUE KEY `uk_total_task_id` (`total_task_id`) COMMENT '总任务ID唯一防重复提交',
-  KEY `idx_task_status` (`task_status`,`is_deleted`) COMMENT '筛选待执行、失败任务',
-  KEY `idx_template_id` (`template_id`,`is_deleted`) COMMENT '根据模板查询制作任务记录'
+  PRIMARY KEY (`id`)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='发型模板制作异步任务表';
+
+CREATE TABLE `system_config` (
+  `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '配置主键ID',
+  `config_key` VARCHAR(100) NOT NULL COMMENT '配置唯一键,英文标识',
+  `config_name` VARCHAR(200) NOT NULL COMMENT '配置中文名称',
+  `config_group` VARCHAR(50) NOT NULL COMMENT '配置分组',
+  `config_value` TEXT NULL COMMENT '配置值,文本/数字/JSON都存这里',
+  `value_type` ENUM('STRING', 'NUMBER', 'BOOLEAN', 'JSON') NOT NULL DEFAULT 'STRING' COMMENT '值类型:字符串/数字/布尔/JSON数组对象',
+  `sort` INT NOT NULL DEFAULT 0 COMMENT '排序号,后台展示顺序',
+  `remark` VARCHAR(500) NULL DEFAULT '' COMMENT '配置说明备注',
+  `create_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
+  `update_time` DATETIME(3) NOT NULL COMMENT '更新时间',
+  `is_deleted` TINYINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '逻辑删除:0正常 1删除',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_config_key` (`config_key`, `is_deleted`),
+  KEY `idx_config_group` (`config_group`, `is_deleted`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='系统全局配置表';

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

+ 0 - 2
backend/prisma/migrations/20260628050000_add_customer_follow_content/migration.sql

@@ -1,2 +0,0 @@
-ALTER TABLE `customer`
-  ADD COLUMN `follow_content` TEXT NULL COMMENT '跟进内容';

+ 0 - 35
backend/prisma/migrations/20260628120000_add_customer_table/migration.sql

@@ -1,35 +0,0 @@
-CREATE TABLE `customer` (
-  `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '顾客主键ID',
-  `hairstylist_id` BIGINT UNSIGNED NOT NULL COMMENT '所属发型师ID,JWT 隔离必需字段',
-  `name` VARCHAR(30) NOT NULL COMMENT '顾客姓名(必填)',
-  `name_pronunciation` VARCHAR(50) DEFAULT '' COMMENT '姓名读音',
-  `phone` VARCHAR(20) NOT NULL COMMENT '联系电话(必填)',
-  `email` VARCHAR(100) DEFAULT '' COMMENT '邮箱',
-  `birthday` DATE NULL COMMENT '生日',
-  `avatar` VARCHAR(500) DEFAULT '' COMMENT '顾客头像图片URL地址',
-  `expect_contact` ENUM('LINE','PHONE','EMAIL','NONE') NOT NULL DEFAULT 'NONE' COMMENT '期望联系方式:LINE/电话/邮箱/未设置',
-  `hair_length` ENUM('SHORT','MEDIUM','LONG','UNCONFIRM') NOT NULL DEFAULT 'UNCONFIRM' COMMENT '当前头发长度:短发/中长发/长发/未确认',
-  `hair_texture` ENUM('UNCONFIRM','THIN','NORMAL','HARD_CRUDE','CURLY') NOT NULL DEFAULT 'UNCONFIRM' COMMENT '发质:未确认/细/普通/粗硬/有自然卷',
-  `notice` TEXT NULL COMMENT '发型护理注意事项',
-  `allergy` VARCHAR(500) DEFAULT '' COMMENT '过敏史/过敏产品',
-  `inner_remark` TEXT NULL COMMENT '门店内部备注(仅内部查看)',
-  `next_follow_date` DATE NULL COMMENT '下次跟进日期',
-  `recommend_visit_date` DATE NULL COMMENT '推荐到店日',
-  `first_suggest_hair` ENUM('AUTO','LONG','MEDIUM','SHORT') NOT NULL DEFAULT 'AUTO' COMMENT '首次建议发型模板:自动推荐/长发/中长发/短发',
-  `plan_date` DATE NULL COMMENT '到店计划日',
-  `is_first_register` TINYINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '是否首次登记:0否,1是',
-  `visit_purpose` ENUM('CUT_DYE','CUT','DYE','CONSULT_SIM') NULL COMMENT '到店目的:剪发+染发/剪发/染发/模拟咨询',
-  `trouble` TEXT NULL COMMENT '发质/发型烦恼',
-  `consult_content` TEXT NULL COMMENT '详细咨询内容',
-  `expect_look` TEXT NULL COMMENT '期望发型形象描述',
-  `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
-  `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
-  `is_deleted` TINYINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '逻辑删除 0正常 1删除',
-  PRIMARY KEY (`id`),
-  UNIQUE KEY `uk_phone_del` (`phone`,`is_deleted`) COMMENT '手机号唯一,删除后可重录',
-  KEY `idx_hairstylist_is_deleted` (`hairstylist_id`,`is_deleted`) COMMENT '按发型师隔离顾客数据',
-  KEY `idx_is_deleted` (`is_deleted`) COMMENT '逻辑删除筛选',
-  KEY `idx_next_follow` (`next_follow_date`,`is_deleted`) COMMENT '跟进日期筛选索引',
-  KEY `idx_recommend_visit` (`recommend_visit_date`,`is_deleted`) COMMENT '推荐到店日期索引',
-  KEY `idx_plan_date` (`plan_date`,`is_deleted`) COMMENT '到店计划日筛选索引'
-) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='顾客信息+咨询登记表(合并单表)';

+ 153 - 0
backend/prisma/migrations/20260628_init_v1/migration.sql

@@ -0,0 +1,153 @@
+CREATE TABLE `hairstylist_user` (
+  `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '发型师主键ID',
+  `username` VARCHAR(50) NOT NULL COMMENT '发型师用户名',
+  `email` VARCHAR(100) NOT NULL COMMENT '登录邮箱',
+  `password` VARCHAR(100) NOT NULL COMMENT '加密后的登录密码',
+  `create_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
+  `update_time` DATETIME(3) NOT NULL COMMENT '更新时间',
+  `is_deleted` TINYINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '逻辑删除:0未删除,1已删除',
+  PRIMARY KEY (`id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='发型师用户信息表';
+
+CREATE TABLE `customer` (
+  `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '顾客主键ID',
+  `hairstylist_id` BIGINT UNSIGNED NOT NULL COMMENT '所属发型师ID',
+  `name` VARCHAR(30) NOT NULL COMMENT '顾客姓名(必填)',
+  `name_pronunciation` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '姓名读音',
+  `phone` VARCHAR(20) NOT NULL COMMENT '联系电话(必填)',
+  `email` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '邮箱',
+  `birthday` DATE NULL COMMENT '生日',
+  `avatar` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '顾客头像图片URL地址',
+  `expect_contact` ENUM('LINE', 'PHONE', 'EMAIL', 'NONE') NOT NULL DEFAULT 'NONE' COMMENT '期望联系方式:LINE/电话/邮箱/未设置',
+  `hair_length` ENUM('SHORT', 'MEDIUM', 'LONG', 'UNCONFIRM') NOT NULL DEFAULT 'UNCONFIRM' COMMENT '当前头发长度:短发/中长发/长发/未确认',
+  `hair_texture` ENUM('UNCONFIRM', 'THIN', 'NORMAL', 'HARD_CRUDE', 'CURLY') NOT NULL DEFAULT 'UNCONFIRM' COMMENT '发质:未确认/细/普通/粗硬/有自然卷',
+  `notice` TEXT NULL COMMENT '发型护理注意事项',
+  `allergy` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '过敏史/过敏产品',
+  `inner_remark` TEXT NULL COMMENT '门店内部备注(仅内部查看)',
+  `follow_content` TEXT NULL COMMENT '跟进内容',
+  `next_follow_date` DATE NULL COMMENT '下次跟进日期',
+  `recommend_visit_date` DATE NULL COMMENT '推荐到店日',
+  `first_suggest_hair` ENUM('AUTO', 'LONG', 'MEDIUM', 'SHORT') NOT NULL DEFAULT 'AUTO' COMMENT '首次建议发型模板:自动推荐/长发/中长发/短发',
+  `plan_date` DATE NULL COMMENT '到店计划日',
+  `is_first_register` TINYINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '是否首次登记:0否,1是',
+  `visit_purpose` ENUM('CUT_DYE', 'CUT', 'DYE', 'CONSULT_SIM') NULL COMMENT '到店目的:剪发+染发/剪发/染发/模拟咨询',
+  `trouble` TEXT NULL COMMENT '发质/发型烦恼',
+  `consult_content` TEXT NULL COMMENT '详细咨询内容',
+  `expect_look` TEXT NULL COMMENT '期望发型形象描述',
+  `create_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
+  `update_time` DATETIME(3) NOT NULL COMMENT '更新时间',
+  `is_deleted` TINYINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '逻辑删除 0正常 1删除',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_phone_del` (`phone`, `is_deleted`),
+  KEY `idx_is_deleted` (`is_deleted`),
+  KEY `idx_hairstylist_is_deleted` (`hairstylist_id`, `is_deleted`),
+  KEY `idx_next_follow` (`next_follow_date`, `is_deleted`),
+  KEY `idx_recommend_visit` (`recommend_visit_date`, `is_deleted`),
+  KEY `idx_plan_date` (`plan_date`, `is_deleted`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='顾客信息+咨询登记表(合并单表)';
+
+CREATE TABLE `customer_treatment_record` (
+  `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '施术记录主键ID',
+  `customer_id` BIGINT UNSIGNED NOT NULL COMMENT '关联顾客ID,customer表主键',
+  `hairstylist_id` BIGINT UNSIGNED NOT NULL COMMENT '操作发型师ID,hairstylist_user表主键',
+  `treatment_time` DATETIME(3) NOT NULL COMMENT '施术时间',
+  `treatment_title` VARCHAR(200) NOT NULL COMMENT '施术标题(如:剪发+漂染护理)',
+  `treatment_detail` TEXT NULL COMMENT '施术详细内容、使用产品、操作备注',
+  `create_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录创建时间',
+  `update_time` DATETIME(3) NOT NULL COMMENT '记录更新时间',
+  `is_deleted` TINYINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '逻辑删除:0正常 1删除',
+  PRIMARY KEY (`id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='顾客施术历史记录表';
+
+CREATE TABLE `ai_hair_simulation` (
+  `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '模拟记录主键ID',
+  `customer_id` BIGINT UNSIGNED NULL COMMENT '关联顾客ID,可为NULL(匿名无顾客)',
+  `hairstylist_id` BIGINT UNSIGNED NOT NULL COMMENT '操作发型师ID,关联发型师用户表',
+  `origin_image` VARCHAR(500) NOT NULL COMMENT '原始照片URL地址',
+  `template_ids` TEXT NOT NULL COMMENT '本次模拟使用的模板ID列表',
+  `simulate_remark` TEXT NULL COMMENT '本次发型模拟备注说明',
+  `create_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录创建时间',
+  `update_time` DATETIME(3) NOT NULL COMMENT '记录更新时间',
+  `is_deleted` TINYINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '逻辑删除:0正常 1删除',
+  PRIMARY KEY (`id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='AI发型模拟记录表';
+
+CREATE TABLE `ai_hair_simulation_task` (
+  `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '任务主键ID',
+  `simulation_id` BIGINT UNSIGNED NOT NULL COMMENT '关联AI发型模拟表id',
+  `template_id` VARCHAR(100) NOT NULL COMMENT '发型模板ID',
+  `customer_id` BIGINT UNSIGNED NULL COMMENT '关联顾客ID,可为NULL(匿名模拟)',
+  `task_id` VARCHAR(200) NOT NULL COMMENT 'AI服务侧唯一任务ID',
+  `task_status` ENUM('PROCESSING', 'SUCCESSES', 'FAILED') NOT NULL COMMENT '任务执行状态:处理中/成功/失败',
+  `result_images` TEXT NULL COMMENT '任务结果图片URL数组,JSON格式存储',
+  `task_start_time` DATETIME(3) NOT NULL COMMENT '任务发起开始时间',
+  `task_finish_time` DATETIME(3) NULL COMMENT '任务执行完成时间,未结束为NULL',
+  `error_msg` TEXT NULL COMMENT '任务失败错误信息,成功则为空',
+  `create_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录创建时间',
+  `update_time` DATETIME(3) NOT NULL COMMENT '记录更新时间',
+  `is_deleted` TINYINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '逻辑删除:0正常 1删除',
+  PRIMARY KEY (`id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='AI发型模拟异步任务表';
+
+CREATE TABLE `hair_template` (
+  `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '模板主键ID',
+  `template_name` VARCHAR(100) NOT NULL COMMENT '模板名称(必填)',
+  `cover_url` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '模板封面图URL',
+  `status` ENUM('DRAFT', 'PUBLIC') NOT NULL COMMENT '模板状态:DRAFT草稿 / PUBLIC公开',
+  `category` ENUM('LONG', 'MEDIUM', 'SHORT', 'DYE') NOT NULL COMMENT '分类:长发/中长发/短发/染发',
+  `tags` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '模板标签,逗号分隔存储',
+  `description` TEXT NULL COMMENT '模板详细说明',
+  `model_url` VARCHAR(500) NOT NULL DEFAULT '' COMMENT 'AI模特图片URL',
+  `clothes_url` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '服装素材URL',
+  `background_url` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '背景图片URL',
+  `ref_hair_url` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '参考发型图片URL',
+  `hairstylist_id` BIGINT UNSIGNED NULL COMMENT '创建发型师ID,系统模板为空',
+  `template_url` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '完整模板预览页面URL',
+  `used_count` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '模板累计使用次数',
+  `is_system_template` ENUM('YES', 'NO') NOT NULL DEFAULT 'NO' COMMENT '是否系统模板:YES系统模板,NO门店自定义模板',
+  `create_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
+  `update_time` DATETIME(3) NOT NULL COMMENT '更新时间',
+  `is_deleted` TINYINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '逻辑删除:0正常 1删除',
+  PRIMARY KEY (`id`),
+  KEY `idx_category` (`category`, `is_deleted`),
+  KEY `idx_hairstylist_id` (`hairstylist_id`, `is_deleted`),
+  KEY `idx_status` (`status`, `is_deleted`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='AI发型模板表';
+
+CREATE TABLE `hair_template_make_task` (
+  `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '本地任务自增主键',
+  `total_task_id` VARCHAR(200) NOT NULL COMMENT '总任务唯一ID',
+  `step1_task_id` VARCHAR(200) NOT NULL DEFAULT '' COMMENT '第一步AI任务ID',
+  `step2_task_id` VARCHAR(200) NOT NULL DEFAULT '' COMMENT '第二步AI任务ID',
+  `task_status` ENUM('WAIT', 'PROCESSING', 'STEP1_SUCCESS', 'STEP2_SUCCESS', 'FAILED') NOT NULL COMMENT '任务状态:等待处理/处理中/第一步完成/全部完成/失败',
+  `template_id` BIGINT UNSIGNED NULL COMMENT '关联发型模板ID,模板生成完成后回填',
+  `step1_resource` TEXT NULL COMMENT 'step1任务资源包,JSON字符串存储参数/素材信息',
+  `step2_resource` TEXT NULL COMMENT 'step2任务资源包,JSON字符串存储参数/素材信息',
+  `ref_hair_url` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '参考发型图片URL',
+  `model_url` VARCHAR(500) NOT NULL DEFAULT '' COMMENT 'AI模特图片URL',
+  `clothes_url` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '服装素材URL',
+  `background_url` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '背景图片URL',
+  `step1_result_img` VARCHAR(500) NOT NULL DEFAULT '' COMMENT 'step1输出结果图',
+  `step2_result_img` VARCHAR(500) NOT NULL DEFAULT '' COMMENT 'step2最终合成结果图',
+  `create_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录创建时间',
+  `update_time` DATETIME(3) NOT NULL COMMENT '记录更新时间',
+  `is_deleted` TINYINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '逻辑删除:0正常,1删除',
+  PRIMARY KEY (`id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='发型模板制作异步任务表';
+
+CREATE TABLE `system_config` (
+  `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '配置主键ID',
+  `config_key` VARCHAR(100) NOT NULL COMMENT '配置唯一键,英文标识',
+  `config_name` VARCHAR(200) NOT NULL COMMENT '配置中文名称',
+  `config_group` VARCHAR(50) NOT NULL COMMENT '配置分组',
+  `config_value` TEXT NULL COMMENT '配置值,文本/数字/JSON都存这里',
+  `value_type` ENUM('STRING', 'NUMBER', 'BOOLEAN', 'JSON') NOT NULL DEFAULT 'STRING' COMMENT '值类型:字符串/数字/布尔/JSON数组对象',
+  `sort` INT NOT NULL DEFAULT 0 COMMENT '排序号,后台展示顺序',
+  `remark` VARCHAR(500) NULL DEFAULT '' COMMENT '配置说明备注',
+  `create_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
+  `update_time` DATETIME(3) NOT NULL COMMENT '更新时间',
+  `is_deleted` TINYINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '逻辑删除:0正常 1删除',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_config_key` (`config_key`, `is_deleted`),
+  KEY `idx_config_group` (`config_group`, `is_deleted`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='系统全局配置表';

+ 26 - 0
backend/prisma/schema.prisma

@@ -38,6 +38,13 @@ enum HairTemplateSystemFlag {
   NO
 }
 
+enum SystemConfigValueType {
+  STRING
+  NUMBER
+  BOOLEAN
+  JSON
+}
+
 enum CustomerExpectContact {
   LINE
   PHONE
@@ -184,6 +191,7 @@ model HairTemplate {
   refHairUrl       String                 @default("") @map("ref_hair_url") @db.VarChar(500)
   hairstylistId    BigInt?                @map("hairstylist_id") @db.UnsignedBigInt
   templateUrl      String                 @default("") @map("template_url") @db.VarChar(500)
+  usedCount        Int                    @default(0) @map("used_count") @db.UnsignedInt
   isSystemTemplate HairTemplateSystemFlag @default(NO) @map("is_system_template")
   createTime       DateTime               @default(now()) @map("create_time")
   updateTime       DateTime               @updatedAt @map("update_time")
@@ -216,3 +224,21 @@ model HairTemplateMakeTask {
 
   @@map("hair_template_make_task")
 }
+
+model SystemConfig {
+  id          BigInt                @id @default(autoincrement()) @db.UnsignedBigInt
+  configKey   String                @map("config_key") @db.VarChar(100)
+  configName  String                @map("config_name") @db.VarChar(200)
+  configGroup String                @map("config_group") @db.VarChar(50)
+  configValue String?               @map("config_value") @db.Text
+  valueType   SystemConfigValueType @default(STRING) @map("value_type")
+  sort        Int                   @default(0)
+  remark      String?               @default("") @db.VarChar(500)
+  createTime  DateTime              @default(now()) @map("create_time")
+  updateTime  DateTime              @updatedAt @map("update_time")
+  isDeleted   Int                   @default(0) @map("is_deleted") @db.UnsignedTinyInt
+
+  @@unique([configKey, isDeleted], map: "uk_config_key")
+  @@index([configGroup, isDeleted], map: "idx_config_group")
+  @@map("system_config")
+}

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

@@ -34,6 +34,7 @@ describe('SimulationsService', () => {
     hairTemplate: {
       findMany: jest.fn(),
       count: jest.fn(),
+      update: jest.fn(),
     },
     $transaction: jest.fn(),
   };
@@ -313,6 +314,114 @@ describe('SimulationsService', () => {
     expect(result.overallStatus).toBe('PARTIAL_SUCCESS');
   });
 
+  it('increments template usedCount when a processing task first becomes successes', async () => {
+    prisma.aiHairSimulation.findFirst.mockResolvedValue({
+      id: BigInt(201),
+      customerId: null,
+      hairstylistId: BigInt(7),
+      originImage: 'https://cdn.example.com/origin.jpg',
+      simulateRemark: null,
+      createTime: new Date('2026-06-28T09:00:00Z'),
+      isDeleted: 0,
+    });
+    prisma.aiHairSimulationTask.findMany
+      .mockResolvedValueOnce([
+        {
+          id: BigInt(301),
+          templateId: '101',
+          customerId: null,
+          taskId: 'task-101',
+          taskStatus: 'PROCESSING',
+          resultImages: null,
+          errorMsg: null,
+          taskStartTime: new Date('2026-06-28T10:00:00Z'),
+          taskFinishTime: null,
+          updateTime: new Date(Date.now() - 20_000),
+          isDeleted: 0,
+        },
+      ])
+      .mockResolvedValueOnce([
+        {
+          id: BigInt(301),
+          templateId: '101',
+          customerId: null,
+          taskId: 'task-101',
+          taskStatus: 'SUCCESSES',
+          resultImages: JSON.stringify(['https://cdn.example.com/result.png']),
+          errorMsg: null,
+          taskStartTime: new Date('2026-06-28T10:00:00Z'),
+          taskFinishTime: new Date('2026-06-28T10:01:00Z'),
+          updateTime: new Date('2026-06-28T10:01:00Z'),
+          isDeleted: 0,
+        },
+      ]);
+    prisma.hairTemplate.findMany.mockResolvedValue([
+      {
+        id: BigInt(101),
+        templateName: 'Layer Bob',
+        coverUrl: '',
+        category: 'SHORT',
+        isSystemTemplate: 'NO',
+      },
+    ]);
+    aiClient.getResult.mockResolvedValue({
+      status: 'successes',
+      images: ['https://cdn.example.com/result.png'],
+      message: '',
+    });
+
+    await service.getSimulationDetail(BigInt(7), '201');
+
+    expect(prisma.hairTemplate.update).toHaveBeenCalledWith({
+      where: { id: BigInt(101) },
+      data: {
+        usedCount: {
+          increment: 1,
+        },
+      },
+    });
+  });
+
+  it('does not increment template usedCount for tasks already marked as successes', async () => {
+    prisma.aiHairSimulation.findFirst.mockResolvedValue({
+      id: BigInt(201),
+      customerId: null,
+      hairstylistId: BigInt(7),
+      originImage: 'https://cdn.example.com/origin.jpg',
+      simulateRemark: null,
+      createTime: new Date('2026-06-28T09:00:00Z'),
+      isDeleted: 0,
+    });
+    prisma.aiHairSimulationTask.findMany.mockResolvedValue([
+      {
+        id: BigInt(301),
+        templateId: '101',
+        customerId: null,
+        taskId: 'task-101',
+        taskStatus: 'SUCCESSES',
+        resultImages: JSON.stringify(['https://cdn.example.com/result.png']),
+        errorMsg: null,
+        taskStartTime: new Date('2026-06-28T10:00:00Z'),
+        taskFinishTime: new Date('2026-06-28T10:01:00Z'),
+        updateTime: new Date('2026-06-28T10:01:00Z'),
+        isDeleted: 0,
+      },
+    ]);
+    prisma.hairTemplate.findMany.mockResolvedValue([
+      {
+        id: BigInt(101),
+        templateName: 'Layer Bob',
+        coverUrl: '',
+        category: 'SHORT',
+        isSystemTemplate: 'NO',
+      },
+    ]);
+
+    await service.getSimulationDetail(BigInt(7), '201');
+
+    expect(prisma.hairTemplate.update).not.toHaveBeenCalled();
+  });
+
   it('lists flattened customer gallery cards in descending simulation time order', async () => {
     prisma.customer.findFirst.mockResolvedValue({ id: BigInt(5) });
     prisma.aiHairSimulation.findMany.mockResolvedValue([

+ 36 - 13
backend/src/modules/simulations/simulations.service.ts

@@ -257,21 +257,44 @@ export class SimulationsService {
           continue;
         }
 
+        if (result.status === 'successes') {
+          await this.prisma.aiHairSimulationTask.update({
+            where: { id: task.id },
+            data: {
+              taskStatus: 'SUCCESSES',
+              resultImages: JSON.stringify(result.images),
+              taskFinishTime: new Date(),
+              errorMsg: null,
+            },
+          });
+
+          try {
+            const templateId = BigInt(task.templateId);
+            await this.prisma.hairTemplate.update({
+              where: { id: templateId },
+              data: {
+                usedCount: {
+                  increment: 1,
+                },
+              },
+            });
+          } catch (error) {
+            const reason = error instanceof Error ? error.message : 'unknown error';
+            this.logger.warn(
+              `Failed to increment template usage for task ${task.taskId}: ${reason}`,
+            );
+          }
+
+          continue;
+        }
+
         await this.prisma.aiHairSimulationTask.update({
           where: { id: task.id },
-          data:
-            result.status === 'successes'
-              ? {
-                  taskStatus: 'SUCCESSES',
-                  resultImages: JSON.stringify(result.images),
-                  taskFinishTime: new Date(),
-                  errorMsg: null,
-                }
-              : {
-                  taskStatus: 'FAILED',
-                  errorMsg: result.message || 'AI 模拟失败',
-                  taskFinishTime: new Date(),
-                },
+          data: {
+            taskStatus: 'FAILED',
+            errorMsg: result.message || 'AI 模拟失败',
+            taskFinishTime: new Date(),
+          },
         });
       } catch (error) {
         const reason = error instanceof Error ? error.message : 'unknown error';

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

+ 107 - 3
backend/src/modules/templates/templates.controller.ts

@@ -1,15 +1,119 @@
-import { Body, Controller, Get, Post, Query } from '@nestjs/common';
+import {
+  Body,
+  Controller,
+  Delete,
+  Get,
+  Patch,
+  Param,
+  Post,
+  Query,
+  Req,
+  UseGuards,
+} from '@nestjs/common';
+import type { Request } from 'express';
+import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
+import { BulkDeleteTemplatesDto } from './dto/bulk-delete-templates.dto';
+import { CreateTemplateMakeTaskDto } from './dto/create-template-make-task.dto';
 import { CreateTemplateDto } from './dto/create-template.dto';
 import { ListTemplatesQueryDto } from './dto/list-templates-query.dto';
+import { UpdateTemplateBasicInfoDto } from './dto/update-template-basic-info.dto';
 import { TemplatesService } from './templates.service';
 
+type AuthenticatedRequest = Request & {
+  user: {
+    stylistId: string | number;
+  };
+};
+
+@UseGuards(JwtAuthGuard)
 @Controller('templates')
 export class TemplatesController {
   constructor(private readonly templatesService: TemplatesService) {}
 
+  @Get('assets')
+  assets() {
+    return this.templatesService.getTemplateAssets();
+  }
+
+  @Post('make-tasks')
+  createMakeTask(
+    @Req() request: AuthenticatedRequest,
+    @Body() body: CreateTemplateMakeTaskDto,
+  ) {
+    return this.templatesService.createTemplateMakeTask(
+      BigInt(request.user.stylistId),
+      body,
+    );
+  }
+
+  @Get('make-tasks/:taskId')
+  detail(@Req() request: AuthenticatedRequest, @Param('taskId') taskId: string) {
+    return this.templatesService.getTemplateMakeTaskDetail(
+      BigInt(request.user.stylistId),
+      taskId,
+    );
+  }
+
+  @Get('summary')
+  summary(@Req() request: AuthenticatedRequest) {
+    return this.templatesService.getSummary(BigInt(request.user.stylistId));
+  }
+
+  @Get(':templateId')
+  getById(
+    @Req() request: AuthenticatedRequest,
+    @Param('templateId') templateId: string,
+  ) {
+    return this.templatesService.getBasicInfo(
+      BigInt(request.user.stylistId),
+      templateId,
+    );
+  }
+
+  @Patch(':templateId')
+  updateById(
+    @Req() request: AuthenticatedRequest,
+    @Param('templateId') templateId: string,
+    @Body() body: UpdateTemplateBasicInfoDto,
+  ) {
+    return this.templatesService.updateBasicInfo(
+      BigInt(request.user.stylistId),
+      templateId,
+      body,
+    );
+  }
+
+  @Delete(':templateId')
+  deleteById(
+    @Req() request: AuthenticatedRequest,
+    @Param('templateId') templateId: string,
+  ) {
+    return this.templatesService.deleteTemplate(
+      BigInt(request.user.stylistId),
+      templateId,
+    );
+  }
+
+  @Post('bulk-delete')
+  bulkDelete(
+    @Req() request: AuthenticatedRequest,
+    @Body() body: BulkDeleteTemplatesDto,
+  ) {
+    return this.templatesService.deleteTemplates(
+      BigInt(request.user.stylistId),
+      body.ids,
+    );
+  }
+
   @Get()
-  list(@Query() query: ListTemplatesQueryDto) {
-    return this.templatesService.list(query);
+  list(
+    @Req() request: AuthenticatedRequest,
+    @Query() query: ListTemplatesQueryDto,
+  ) {
+    return this.templatesService.list({
+      ...query,
+      stylistId: BigInt(request.user.stylistId),
+    });
   }
 
   @Post()

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

+ 832 - 0
backend/src/modules/templates/templates.service.spec.ts

@@ -0,0 +1,832 @@
+import { Test } from '@nestjs/testing';
+import { ConfigService } from '@nestjs/config';
+import { PrismaService } from '../../prisma/prisma.service';
+import { StorageService } from '../storage/storage.service';
+import { TemplateAiClient } from './template-ai.client';
+import { TemplateTaskFiles } from './template-task-files';
+import { TemplateTaskRunner } from './template-task-runner';
+import { TemplatesService } from './templates.service';
+
+describe('TemplatesService', () => {
+  let service: TemplatesService;
+  const prisma = {
+    systemConfig: {
+      findMany: jest.fn(),
+    },
+    aiHairSimulationTask: {
+      count: jest.fn(),
+    },
+    hairTemplate: {
+      findFirst: jest.fn(),
+      findMany: jest.fn(),
+      count: jest.fn(),
+      update: jest.fn(),
+      updateMany: jest.fn(),
+    },
+    hairTemplateMakeTask: {
+      findFirst: jest.fn(),
+      findMany: jest.fn(),
+      update: jest.fn(),
+      updateMany: jest.fn(),
+    },
+    $transaction: jest.fn(),
+  };
+  const templateAiClient = {
+    executeTemplate: jest.fn(),
+    getResult: jest.fn(),
+  };
+  const storageService = {
+    uploadBuffer: jest.fn(),
+  };
+  const configService = {
+    get: jest.fn().mockReturnValue(0),
+  };
+  const templateTaskFiles = {
+    downloadToTempFile: jest.fn(),
+    prepareStep2Bundle: jest.fn(),
+    cleanup: jest.fn(),
+  };
+
+  beforeEach(async () => {
+    jest.resetAllMocks();
+
+    const moduleRef = await Test.createTestingModule({
+      providers: [
+        TemplatesService,
+        TemplateTaskRunner,
+        {
+          provide: PrismaService,
+          useValue: prisma,
+        },
+        {
+          provide: TemplateAiClient,
+          useValue: templateAiClient,
+        },
+        {
+          provide: StorageService,
+          useValue: storageService,
+        },
+        {
+          provide: TemplateTaskFiles,
+          useValue: templateTaskFiles,
+        },
+        {
+          provide: ConfigService,
+          useValue: configService,
+        },
+      ],
+    }).compile();
+
+    service = moduleRef.get(TemplatesService);
+  });
+
+  it('lists grouped template assets from system_config', async () => {
+    prisma.systemConfig.findMany.mockResolvedValue([
+      {
+        id: BigInt(11),
+        configKey: 'model-natural',
+        configName: '自然模特',
+        configGroup: 'ai-model',
+        configValue: 'https://cdn.example.com/model.jpg',
+      },
+      {
+        id: BigInt(12),
+        configKey: 'clothing-shirt',
+        configName: '白衬衫',
+        configGroup: 'clothing',
+        configValue: 'https://cdn.example.com/clothes.jpg',
+      },
+      {
+        id: BigInt(13),
+        configKey: 'background-studio',
+        configName: '影棚背景',
+        configGroup: 'background',
+        configValue: 'https://cdn.example.com/background.jpg',
+      },
+    ]);
+
+    const result = await service.getTemplateAssets();
+
+    expect(prisma.systemConfig.findMany).toHaveBeenCalledWith({
+      where: {
+        isDeleted: 0,
+        configGroup: {
+          in: ['ai-model', 'clothing', 'background'],
+        },
+      },
+      orderBy: [{ sort: 'asc' }, { id: 'asc' }],
+    });
+    expect(result).toEqual({
+      models: [
+        {
+          id: '11',
+          name: '自然模特',
+          url: 'https://cdn.example.com/model.jpg',
+          configKey: 'model-natural',
+        },
+      ],
+      clothes: [
+        {
+          id: '12',
+          name: '白衬衫',
+          url: 'https://cdn.example.com/clothes.jpg',
+          configKey: 'clothing-shirt',
+        },
+      ],
+      backgrounds: [
+        {
+          id: '13',
+          name: '影棚背景',
+          url: 'https://cdn.example.com/background.jpg',
+          configKey: 'background-studio',
+        },
+      ],
+    });
+  });
+
+  it('creates hair_template and hair_template_make_task, then starts step1', async () => {
+    prisma.systemConfig.findMany.mockResolvedValue([
+      {
+        id: BigInt(21),
+        configKey: 'template-step1-default',
+        configName: 'step1 template',
+        configGroup: 'template-step1',
+        configValue: 'https://cdn.example.com/template-step1.json',
+      },
+    ]);
+    templateAiClient.executeTemplate.mockResolvedValue({ taskId: 'step1-task-1' });
+
+    const createTemplate = jest.fn().mockResolvedValue({ id: BigInt(101) });
+    const createTask = jest.fn().mockResolvedValue({ id: BigInt(88) });
+    prisma.$transaction.mockImplementation(async (callback: (tx: {
+      hairTemplate: { create: typeof createTemplate };
+      hairTemplateMakeTask: { create: typeof createTask };
+    }) => Promise<unknown>) =>
+      callback({
+        hairTemplate: { create: createTemplate },
+        hairTemplateMakeTask: { create: createTask },
+      }),
+    );
+
+    const result = await service.createTemplateMakeTask(BigInt(1), {
+      templateName: '柔和层次长发',
+      category: 'LONG',
+      status: 'DRAFT',
+      description: '适合脸周柔和修饰',
+      tags: ['首次建议', '层次'],
+      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',
+    });
+
+    expect(createTemplate).toHaveBeenCalledWith({
+      data: expect.objectContaining({
+        templateName: '柔和层次长发',
+        category: 'LONG',
+        status: 'DRAFT',
+        tags: '首次建议,层次',
+        hairstylistId: BigInt(1),
+        refHairUrl: 'https://cdn.example.com/ref.jpg',
+      }),
+    });
+    expect(createTask).toHaveBeenCalledWith({
+      data: expect.objectContaining({
+        templateId: BigInt(101),
+        taskStatus: 'WAIT',
+        refHairUrl: 'https://cdn.example.com/ref.jpg',
+      }),
+    });
+    expect(templateAiClient.executeTemplate).toHaveBeenCalledWith(
+      'https://cdn.example.com/template-step1.json',
+      {
+        images: ['https://cdn.example.com/ref.jpg'],
+      },
+    );
+    expect(prisma.hairTemplateMakeTask.update).toHaveBeenCalledWith({
+      where: { id: BigInt(88) },
+      data: expect.objectContaining({
+        step1TaskId: 'step1-task-1',
+        taskStatus: 'PROCESSING',
+      }),
+    });
+    expect(result).toEqual({
+      templateId: '101',
+      taskId: '88',
+      totalTaskId: expect.any(String),
+      taskStatus: 'PROCESSING',
+    });
+  });
+
+  it('promotes a task from PROCESSING to STEP1_SUCCESS when step1 finishes', async () => {
+    prisma.hairTemplateMakeTask.findFirst.mockResolvedValue({
+      id: BigInt(88),
+      totalTaskId: 'tmpl_123',
+      taskStatus: 'PROCESSING',
+      templateId: BigInt(101),
+      step1TaskId: 'step1-task-1',
+      step2TaskId: '',
+      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',
+      step1ResultImg: '',
+      step2ResultImg: '',
+      step1Resource: JSON.stringify({
+        templateUrl: 'https://cdn.example.com/template-step1.json',
+      }),
+      step2Resource: JSON.stringify({}),
+      createTime: new Date('2026-06-28T12:00:00.000Z'),
+      updateTime: new Date(Date.now() - 20_000),
+      isDeleted: 0,
+    });
+    prisma.hairTemplate.findFirst.mockResolvedValue({
+      id: BigInt(101),
+      templateName: '柔和层次长发',
+      coverUrl: '',
+      templateUrl: '',
+      status: 'DRAFT',
+      hairstylistId: BigInt(1),
+      isDeleted: 0,
+    });
+    templateAiClient.getResult.mockResolvedValue({
+      status: 'successes',
+      message: '',
+      images: ['https://cdn.example.com/step1-result.jpg'],
+    });
+    templateTaskFiles.downloadToTempFile.mockResolvedValue(
+      '/tmp/template-make/tmpl_123/hairstyle.jpg',
+    );
+    prisma.hairTemplateMakeTask.update.mockResolvedValue({
+      id: BigInt(88),
+      totalTaskId: 'tmpl_123',
+      taskStatus: 'STEP1_SUCCESS',
+      templateId: BigInt(101),
+      step1TaskId: 'step1-task-1',
+      step2TaskId: '',
+      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',
+      step1ResultImg: 'https://cdn.example.com/step1-result.jpg',
+      step2ResultImg: '',
+      step1Resource: JSON.stringify({
+        templateUrl: 'https://cdn.example.com/template-step1.json',
+        localFile: '/tmp/template-make/tmpl_123/hairstyle.jpg',
+      }),
+      step2Resource: JSON.stringify({}),
+      createTime: new Date('2026-06-28T12:00:00.000Z'),
+      updateTime: new Date('2026-06-28T12:01:00.000Z'),
+      isDeleted: 0,
+    });
+
+    const detail = await service.getTemplateMakeTaskDetail(BigInt(1), '88');
+
+    expect(templateAiClient.getResult).toHaveBeenCalledWith('step1-task-1');
+    expect(prisma.hairTemplateMakeTask.update).toHaveBeenCalledWith({
+      where: { id: BigInt(88) },
+      data: expect.objectContaining({
+        taskStatus: 'STEP1_SUCCESS',
+        step1ResultImg: 'https://cdn.example.com/step1-result.jpg',
+      }),
+    });
+    expect(detail).toEqual(
+      expect.objectContaining({
+        id: '88',
+        templateId: '101',
+        taskStatus: 'STEP1_SUCCESS',
+        step1: expect.objectContaining({
+          taskId: 'step1-task-1',
+          status: 'STEP1_SUCCESS',
+          resultImage: 'https://cdn.example.com/step1-result.jpg',
+        }),
+      }),
+    );
+  });
+
+  it('promotes a task from PROCESSING to STEP2_SUCCESS when step2 finishes', async () => {
+    prisma.hairTemplateMakeTask.findFirst.mockResolvedValue({
+      id: BigInt(89),
+      totalTaskId: 'tmpl_456',
+      taskStatus: 'PROCESSING',
+      templateId: BigInt(102),
+      step1TaskId: 'step1-task-2',
+      step2TaskId: 'step2-task-2',
+      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',
+      step1ResultImg: 'https://cdn.example.com/step1-result.jpg',
+      step2ResultImg: '',
+      step1Resource: JSON.stringify({
+        templateUrl: 'https://cdn.example.com/template-step1.json',
+        localFile: '/tmp/template-make/tmpl_456/hairstyle.jpg',
+      }),
+      step2Resource: JSON.stringify({
+        uploadedZipUrl: 'https://cdn.example.com/template.zip',
+        workDir: '/tmp/template-make/tmpl_456',
+      }),
+      createTime: new Date('2026-06-28T12:00:00.000Z'),
+      updateTime: new Date(Date.now() - 20_000),
+      isDeleted: 0,
+    });
+    prisma.hairTemplate.findFirst.mockResolvedValue({
+      id: BigInt(102),
+      templateName: '柔和层次长发',
+      coverUrl: '',
+      templateUrl: '',
+      status: 'DRAFT',
+      hairstylistId: BigInt(1),
+      isDeleted: 0,
+    });
+    templateAiClient.getResult.mockResolvedValue({
+      status: 'successes',
+      message: '',
+      images: ['https://cdn.example.com/final-cover.jpg'],
+    });
+    prisma.hairTemplate.update.mockResolvedValue({
+      id: BigInt(102),
+      templateName: '柔和层次长发',
+      coverUrl: 'https://cdn.example.com/final-cover.jpg',
+      templateUrl: 'https://cdn.example.com/template.zip',
+      status: 'DRAFT',
+      hairstylistId: BigInt(1),
+      isDeleted: 0,
+    });
+    prisma.hairTemplateMakeTask.update.mockResolvedValue({
+      id: BigInt(89),
+      totalTaskId: 'tmpl_456',
+      taskStatus: 'STEP2_SUCCESS',
+      templateId: BigInt(102),
+      step1TaskId: 'step1-task-2',
+      step2TaskId: 'step2-task-2',
+      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',
+      step1ResultImg: 'https://cdn.example.com/step1-result.jpg',
+      step2ResultImg: 'https://cdn.example.com/final-cover.jpg',
+      step1Resource: JSON.stringify({}),
+      step2Resource: JSON.stringify({
+        uploadedZipUrl: 'https://cdn.example.com/template.zip',
+        resultImage: 'https://cdn.example.com/final-cover.jpg',
+        workDir: '/tmp/template-make/tmpl_456',
+      }),
+      createTime: new Date('2026-06-28T12:00:00.000Z'),
+      updateTime: new Date('2026-06-28T12:05:00.000Z'),
+      isDeleted: 0,
+    });
+
+    const detail = await service.getTemplateMakeTaskDetail(BigInt(1), '89');
+
+    expect(templateAiClient.getResult).toHaveBeenCalledWith('step2-task-2');
+    expect(prisma.hairTemplate.update).toHaveBeenCalledWith({
+      where: { id: BigInt(102) },
+      data: {
+        coverUrl: 'https://cdn.example.com/final-cover.jpg',
+        templateUrl: 'https://cdn.example.com/template.zip',
+      },
+    });
+    expect(prisma.hairTemplateMakeTask.update).toHaveBeenCalledWith({
+      where: { id: BigInt(89) },
+      data: expect.objectContaining({
+        taskStatus: 'STEP2_SUCCESS',
+        step2ResultImg: 'https://cdn.example.com/final-cover.jpg',
+      }),
+    });
+    expect(templateTaskFiles.cleanup).toHaveBeenCalledWith(
+      '/tmp/template-make/tmpl_456',
+    );
+    expect(detail).toEqual(
+      expect.objectContaining({
+        id: '89',
+        templateId: '102',
+        taskStatus: 'STEP2_SUCCESS',
+        template: expect.objectContaining({
+          coverUrl: 'https://cdn.example.com/final-cover.jpg',
+          templateUrl: 'https://cdn.example.com/template.zip',
+        }),
+      }),
+    );
+  });
+
+  it('starts step2 with modelUrl as inputs.images when step1 has succeeded', async () => {
+    prisma.hairTemplateMakeTask.findFirst.mockResolvedValue({
+      id: BigInt(90),
+      totalTaskId: 'tmpl_789',
+      taskStatus: 'STEP1_SUCCESS',
+      templateId: BigInt(103),
+      step1TaskId: 'step1-task-3',
+      step2TaskId: '',
+      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',
+      step1ResultImg: 'https://cdn.example.com/step1-result.jpg',
+      step2ResultImg: '',
+      step1Resource: JSON.stringify({
+        templateUrl: 'https://cdn.example.com/template-step1.json',
+        localFile: '/tmp/template-make/tmpl_789/hairstyle.jpg',
+      }),
+      step2Resource: JSON.stringify({}),
+      createTime: new Date('2026-06-28T12:00:00.000Z'),
+      updateTime: new Date(Date.now() - 20_000),
+      isDeleted: 0,
+    });
+    prisma.hairTemplate.findFirst.mockResolvedValue({
+      id: BigInt(103),
+      templateName: '空气感中长发',
+      coverUrl: '',
+      templateUrl: '',
+      status: 'DRAFT',
+      hairstylistId: BigInt(1),
+      isDeleted: 0,
+    });
+    templateTaskFiles.prepareStep2Bundle.mockResolvedValue({
+      uploadedZipUrl: 'https://cdn.example.com/template-bundle.zip',
+      workDir: '/tmp/template-make/tmpl_789',
+      localFiles: {
+        step1Image: '/tmp/template-make/tmpl_789/hairstyle.jpg',
+        clothes: '/tmp/template-make/tmpl_789/input-outfit.jpg',
+        background: '/tmp/template-make/tmpl_789/input-background.jpg',
+        zip: '/tmp/template-make/tmpl_789/template.zip',
+      },
+    });
+    templateAiClient.executeTemplate.mockResolvedValue({
+      taskId: 'step2-task-3',
+    });
+    prisma.hairTemplateMakeTask.update.mockResolvedValue({
+      id: BigInt(90),
+      totalTaskId: 'tmpl_789',
+      taskStatus: 'PROCESSING',
+      templateId: BigInt(103),
+      step1TaskId: 'step1-task-3',
+      step2TaskId: 'step2-task-3',
+      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',
+      step1ResultImg: 'https://cdn.example.com/step1-result.jpg',
+      step2ResultImg: '',
+      step1Resource: JSON.stringify({
+        templateUrl: 'https://cdn.example.com/template-step1.json',
+        localFile: '/tmp/template-make/tmpl_789/hairstyle.jpg',
+      }),
+      step2Resource: JSON.stringify({
+        uploadedZipUrl: 'https://cdn.example.com/template-bundle.zip',
+        workDir: '/tmp/template-make/tmpl_789',
+        localFiles: {
+          step1Image: '/tmp/template-make/tmpl_789/hairstyle.jpg',
+          clothes: '/tmp/template-make/tmpl_789/input-outfit.jpg',
+          background: '/tmp/template-make/tmpl_789/input-background.jpg',
+          zip: '/tmp/template-make/tmpl_789/template.zip',
+        },
+        lastError: null,
+      }),
+      createTime: new Date('2026-06-28T12:00:00.000Z'),
+      updateTime: new Date('2026-06-28T12:01:00.000Z'),
+      isDeleted: 0,
+    });
+
+    await service.getTemplateMakeTaskDetail(BigInt(1), '90');
+
+    expect(templateTaskFiles.prepareStep2Bundle).toHaveBeenCalledWith({
+      totalTaskId: 'tmpl_789',
+      step1ResultImg: 'https://cdn.example.com/step1-result.jpg',
+      clothesUrl: 'https://cdn.example.com/clothes.jpg',
+      backgroundUrl: 'https://cdn.example.com/background.jpg',
+    });
+    expect(templateAiClient.executeTemplate).toHaveBeenCalledWith(
+      'https://cdn.example.com/template-bundle.zip',
+      {
+        images: ['https://cdn.example.com/model.jpg'],
+      },
+    );
+  });
+
+  it('returns template basic info for the owning stylist', async () => {
+    prisma.hairTemplate.findFirst.mockResolvedValue({
+      id: BigInt(43),
+      templateName: '柔和层次长发',
+      coverUrl: 'https://cdn.example.com/cover.jpg',
+      templateUrl: 'https://cdn.example.com/template.zip',
+      status: 'DRAFT',
+      category: 'LONG',
+      tags: '层次,首次建议',
+      description: '适合轻盈感修饰',
+      hairstylistId: BigInt(1),
+      isDeleted: 0,
+    });
+
+    await expect(service.getBasicInfo(BigInt(1), '43')).resolves.toEqual({
+      id: '43',
+      templateName: '柔和层次长发',
+      status: 'DRAFT',
+      category: 'LONG',
+      tags: ['层次', '首次建议'],
+      description: '适合轻盈感修饰',
+      coverUrl: 'https://cdn.example.com/cover.jpg',
+      templateUrl: 'https://cdn.example.com/template.zip',
+    });
+  });
+
+  it('updates only editable template basic info fields', async () => {
+    prisma.hairTemplate.findFirst.mockResolvedValue({
+      id: BigInt(43),
+      templateName: '旧模板名',
+      coverUrl: 'https://cdn.example.com/cover.jpg',
+      templateUrl: 'https://cdn.example.com/template.zip',
+      status: 'DRAFT',
+      category: 'LONG',
+      tags: '旧标签',
+      description: '旧描述',
+      hairstylistId: BigInt(1),
+      isDeleted: 0,
+    });
+    prisma.hairTemplate.update.mockResolvedValue({
+      id: BigInt(43),
+      templateName: '新模板名',
+      coverUrl: 'https://cdn.example.com/cover.jpg',
+      templateUrl: 'https://cdn.example.com/template.zip',
+      status: 'PUBLIC',
+      category: 'MEDIUM',
+      tags: '空气感,层次',
+      description: '新描述',
+      hairstylistId: BigInt(1),
+      isDeleted: 0,
+    });
+
+    await expect(
+      service.updateBasicInfo(BigInt(1), '43', {
+        templateName: '新模板名',
+        status: 'PUBLIC',
+        category: 'MEDIUM',
+        tags: ['空气感', '层次'],
+        description: '新描述',
+      }),
+    ).resolves.toEqual({
+      id: '43',
+      templateName: '新模板名',
+      status: 'PUBLIC',
+      category: 'MEDIUM',
+      tags: ['空气感', '层次'],
+      description: '新描述',
+      coverUrl: 'https://cdn.example.com/cover.jpg',
+      templateUrl: 'https://cdn.example.com/template.zip',
+    });
+
+    expect(prisma.hairTemplate.update).toHaveBeenCalledWith({
+      where: { id: BigInt(43) },
+      data: {
+        templateName: '新模板名',
+        status: 'PUBLIC',
+        category: 'MEDIUM',
+        tags: '空气感,层次',
+        description: '新描述',
+      },
+    });
+  });
+
+  it('soft deletes a single owned template and its tasks', async () => {
+    prisma.hairTemplate.findFirst.mockResolvedValue({
+      id: BigInt(43),
+      templateName: '旧模板名',
+      coverUrl: 'https://cdn.example.com/cover.jpg',
+      templateUrl: 'https://cdn.example.com/template.zip',
+      status: 'DRAFT',
+      category: 'LONG',
+      tags: '旧标签',
+      description: '旧描述',
+      hairstylistId: BigInt(1),
+      isDeleted: 0,
+    });
+    prisma.hairTemplate.updateMany.mockResolvedValue({ count: 1 });
+    prisma.hairTemplateMakeTask.updateMany.mockResolvedValue({ count: 1 });
+
+    await expect(service.deleteTemplate(BigInt(1), '43')).resolves.toEqual({
+      success: true,
+    });
+
+    expect(prisma.hairTemplate.updateMany).toHaveBeenCalledWith({
+      where: {
+        id: BigInt(43),
+        hairstylistId: BigInt(1),
+        isDeleted: 0,
+      },
+      data: {
+        isDeleted: 1,
+      },
+    });
+    expect(prisma.hairTemplateMakeTask.updateMany).toHaveBeenCalledWith({
+      where: {
+        templateId: BigInt(43),
+        isDeleted: 0,
+      },
+      data: {
+        isDeleted: 1,
+      },
+    });
+  });
+
+  it('soft deletes only owned templates in bulk', async () => {
+    prisma.hairTemplate.findMany.mockResolvedValue([
+      {
+        id: BigInt(43),
+        hairstylistId: BigInt(1),
+        isDeleted: 0,
+      },
+      {
+        id: BigInt(44),
+        hairstylistId: BigInt(1),
+        isDeleted: 0,
+      },
+    ]);
+    prisma.hairTemplate.updateMany.mockResolvedValue({ count: 2 });
+    prisma.hairTemplateMakeTask.updateMany.mockResolvedValue({ count: 2 });
+
+    await expect(
+      service.deleteTemplates(BigInt(1), ['43', '44', '99']),
+    ).resolves.toEqual({
+      deletedCount: 2,
+    });
+
+    expect(prisma.hairTemplate.findMany).toHaveBeenCalledWith({
+      where: {
+        id: {
+          in: [BigInt(43), BigInt(44), BigInt(99)],
+        },
+        hairstylistId: BigInt(1),
+        isDeleted: 0,
+      },
+      select: {
+        id: true,
+      },
+    });
+    expect(prisma.hairTemplate.updateMany).toHaveBeenCalledWith({
+      where: {
+        id: {
+          in: [BigInt(43), BigInt(44)],
+        },
+        hairstylistId: BigInt(1),
+        isDeleted: 0,
+      },
+      data: {
+        isDeleted: 1,
+      },
+    });
+    expect(prisma.hairTemplateMakeTask.updateMany).toHaveBeenCalledWith({
+      where: {
+        templateId: {
+          in: [BigInt(43), BigInt(44)],
+        },
+        isDeleted: 0,
+      },
+      data: {
+        isDeleted: 1,
+      },
+    });
+  });
+
+  it('lists templates with q/category/status filters and pagination', async () => {
+    prisma.hairTemplate.findMany.mockResolvedValue([
+      {
+        id: BigInt(102),
+        templateName: '空气感中长发',
+        category: 'MEDIUM',
+        status: 'PUBLIC',
+        description: '适合脸周轻盈修饰',
+        tags: '空气感,脸周',
+        coverUrl: 'https://cdn.example.com/cover-102.jpg',
+        usedCount: 7,
+        templateUrl: 'https://cdn.example.com/template-102.zip',
+        hairstylistId: BigInt(1),
+        isDeleted: 0,
+      },
+    ]);
+    prisma.hairTemplate.count.mockResolvedValue(1);
+    prisma.hairTemplateMakeTask.findMany.mockResolvedValue([
+      {
+        id: BigInt(88),
+        templateId: BigInt(102),
+        taskStatus: 'STEP2_SUCCESS',
+        isDeleted: 0,
+      },
+    ]);
+
+    const result = await service.list({
+      stylistId: BigInt(1),
+      q: '空气感',
+      category: 'MEDIUM',
+      status: 'PUBLIC',
+      page: 2,
+      pageSize: 8,
+    });
+
+    expect(prisma.hairTemplate.count).toHaveBeenCalledWith({
+      where: {
+        hairstylistId: BigInt(1),
+        isDeleted: 0,
+        category: 'MEDIUM',
+        status: 'PUBLIC',
+        OR: [
+          { templateName: { contains: '空气感' } },
+          { tags: { contains: '空气感' } },
+          { description: { contains: '空气感' } },
+        ],
+      },
+    });
+
+    expect(prisma.hairTemplate.findMany).toHaveBeenCalledWith({
+      where: {
+        hairstylistId: BigInt(1),
+        isDeleted: 0,
+        category: 'MEDIUM',
+        status: 'PUBLIC',
+        OR: [
+          { templateName: { contains: '空气感' } },
+          { tags: { contains: '空气感' } },
+          { description: { contains: '空气感' } },
+        ],
+      },
+      orderBy: [{ id: 'desc' }],
+      skip: 8,
+      take: 8,
+      select: {
+        id: true,
+        templateName: true,
+        category: true,
+        status: true,
+        description: true,
+        tags: true,
+        coverUrl: true,
+        usedCount: true,
+      },
+    });
+
+    expect(result).toEqual({
+      items: [
+        {
+          id: '102',
+          name: '空气感中长发',
+          category: 'MEDIUM',
+          status: 'PUBLIC',
+          description: '适合脸周轻盈修饰',
+          tags: ['空气感', '脸周'],
+          image: 'https://cdn.example.com/cover-102.jpg',
+          makeTaskId: '88',
+          makeTaskStatus: 'STEP2_SUCCESS',
+          usedCount: 7,
+        },
+      ],
+      total: 1,
+      page: 2,
+      pageSize: 8,
+    });
+  });
+
+  it('returns template summary for the current stylist', async () => {
+    prisma.hairTemplate.count
+      .mockResolvedValueOnce(6)
+      .mockResolvedValueOnce(4)
+      .mockResolvedValueOnce(2);
+    prisma.aiHairSimulationTask.count.mockResolvedValue(9);
+    prisma.hairTemplate.findMany.mockResolvedValue([
+      { id: BigInt(101) },
+      { id: BigInt(102) },
+    ]);
+
+    const summaryService = service as TemplatesService & {
+      getSummary: (stylistId: bigint) => Promise<{
+        total: number;
+        published: number;
+        weeklyUsed: number;
+        draft: number;
+      }>;
+    };
+
+    await expect(summaryService.getSummary(BigInt(1))).resolves.toEqual({
+      total: 6,
+      published: 4,
+      weeklyUsed: 9,
+      draft: 2,
+    });
+
+    expect(prisma.hairTemplate.findMany).toHaveBeenCalledWith({
+      where: {
+        hairstylistId: BigInt(1),
+        isDeleted: 0,
+      },
+      select: {
+        id: true,
+      },
+    });
+
+    expect(prisma.aiHairSimulationTask.count).toHaveBeenCalledWith({
+      where: expect.objectContaining({
+        taskStatus: 'SUCCESSES',
+        isDeleted: 0,
+        templateId: { in: ['101', '102'] },
+      }),
+    });
+  });
+});

+ 555 - 5
backend/src/modules/templates/templates.service.ts

@@ -1,16 +1,566 @@
-import { Injectable, NotImplementedException } from '@nestjs/common';
+import { Injectable, NotFoundException, NotImplementedException } from '@nestjs/common';
+import { randomUUID } from 'crypto';
+import { PrismaService } from '../../prisma/prisma.service';
 import { CreateTemplateDto } from './dto/create-template.dto';
+import { CreateTemplateMakeTaskDto } from './dto/create-template-make-task.dto';
+import { TemplateAssetsDto } from './dto/get-template-assets.dto';
 import { ListTemplatesQueryDto } from './dto/list-templates-query.dto';
+import { TemplateBasicInfoDto } from './dto/template-basic-info.dto';
+import { UpdateTemplateBasicInfoDto } from './dto/update-template-basic-info.dto';
+import { TemplateAiClient } from './template-ai.client';
+import { TemplateTaskRunner } from './template-task-runner';
+
+type TemplateListQuery = ListTemplatesQueryDto & {
+  stylistId: bigint;
+};
+
+const TEMPLATE_CATEGORIES = ['LONG', 'MEDIUM', 'SHORT', 'DYE'] as const;
+const TEMPLATE_STATUSES = ['DRAFT', 'PUBLIC'] as const;
 
 @Injectable()
 export class TemplatesService {
-  async list(_query: ListTemplatesQueryDto): Promise<{
-    items: never[];
+  private get systemConfigDelegate(): {
+    findMany: (args: Record<string, unknown>) => Promise<
+      Array<{
+        id: bigint;
+        configKey: string;
+        configName: string;
+        configGroup: string;
+        configValue: string | null;
+      }>
+    >;
+  } {
+    return (
+      this.prisma as PrismaService & {
+        systemConfig: {
+          findMany: (args: Record<string, unknown>) => Promise<
+            Array<{
+              id: bigint;
+              configKey: string;
+              configName: string;
+              configGroup: string;
+              configValue: string | null;
+            }>
+          >;
+        };
+      }
+    ).systemConfig;
+  }
+
+  constructor(
+    private readonly prisma: PrismaService,
+    private readonly templateAiClient: TemplateAiClient,
+    private readonly templateTaskRunner: TemplateTaskRunner,
+  ) {}
+
+  private async getOwnedTemplate(stylistId: bigint, templateId: string) {
+    const template = await this.prisma.hairTemplate.findFirst({
+      where: {
+        id: BigInt(templateId),
+        hairstylistId: stylistId,
+        isDeleted: 0,
+      },
+    });
+
+    if (!template) {
+      throw new NotFoundException('模板不存在');
+    }
+
+    return template;
+  }
+
+  private toBasicInfoDto(template: {
+    id: bigint;
+    templateName: string;
+    status: 'DRAFT' | 'PUBLIC';
+    category: 'LONG' | 'MEDIUM' | 'SHORT' | 'DYE';
+    tags: string;
+    description: string | null;
+    coverUrl: string;
+    templateUrl: string;
+  }): TemplateBasicInfoDto {
+    return {
+      id: template.id.toString(),
+      templateName: template.templateName,
+      status: template.status,
+      category: template.category,
+      tags: template.tags
+        ? template.tags
+            .split(',')
+            .map((tag) => tag.trim())
+            .filter(Boolean)
+        : [],
+      description: template.description ?? '',
+      coverUrl: template.coverUrl,
+      templateUrl: template.templateUrl,
+    };
+  }
+
+  private toTemplateListItem(
+    template: {
+      id: bigint;
+      templateName: string;
+      category: 'LONG' | 'MEDIUM' | 'SHORT' | 'DYE';
+      status: 'DRAFT' | 'PUBLIC';
+      description: string | null;
+      tags: string;
+      coverUrl: string;
+      usedCount: number;
+    },
+    taskMap: Map<string, { id: bigint; taskStatus: string }>,
+  ) {
+    const task = taskMap.get(template.id.toString());
+
+    return {
+      id: template.id.toString(),
+      name: template.templateName,
+      category: template.category,
+      status: template.status,
+      description: template.description ?? '',
+      tags: template.tags
+        ? template.tags
+            .split(',')
+            .map((tag) => tag.trim())
+            .filter(Boolean)
+        : [],
+      image: template.coverUrl,
+      makeTaskId: task?.id.toString(),
+      makeTaskStatus: task?.taskStatus,
+      usedCount: template.usedCount ?? 0,
+    };
+  }
+
+  async getTemplateAssets(): Promise<TemplateAssetsDto> {
+    type AssetConfigRow = {
+      id: bigint;
+      configKey: string;
+      configName: string;
+      configGroup: string;
+      configValue: string | null;
+    };
+
+    const rows = await this.systemConfigDelegate.findMany({
+      where: {
+        isDeleted: 0,
+        configGroup: {
+          in: ['ai-model', 'clothing', 'background'],
+        },
+      },
+      orderBy: [{ sort: 'asc' }, { id: 'asc' }],
+    });
+
+    const mapRow = (row: AssetConfigRow) => ({
+      id: row.id.toString(),
+      name: row.configName,
+      url: row.configValue ?? '',
+      configKey: row.configKey,
+    });
+
+    return {
+      models: rows.filter((row: AssetConfigRow) => row.configGroup === 'ai-model').map(mapRow),
+      clothes: rows.filter((row: AssetConfigRow) => row.configGroup === 'clothing').map(mapRow),
+      backgrounds: rows
+        .filter((row: AssetConfigRow) => row.configGroup === 'background')
+        .map(mapRow),
+    };
+  }
+
+  private async getRequiredStep1TemplateUrl(): Promise<string> {
+    const rows = await this.systemConfigDelegate.findMany({
+      where: {
+        isDeleted: 0,
+        configGroup: 'template-step1',
+      },
+      orderBy: [{ sort: 'asc' }, { id: 'asc' }],
+      take: 1,
+    });
+
+    const templateUrl = rows[0]?.configValue?.trim() ?? '';
+    if (!templateUrl) {
+      throw new NotFoundException('缺少 template-step1 配置');
+    }
+
+    return templateUrl;
+  }
+
+  async createTemplateMakeTask(
+    stylistId: bigint,
+    dto: CreateTemplateMakeTaskDto,
+  ): Promise<{
+    templateId: string;
+    taskId: string;
+    totalTaskId: string;
+    taskStatus: 'PROCESSING';
+  }> {
+    const step1TemplateUrl = await this.getRequiredStep1TemplateUrl();
+    const totalTaskId = `tmpl_${Date.now()}_${randomUUID()}`;
+
+    const created = await this.prisma.$transaction(async (tx) => {
+      const template = await tx.hairTemplate.create({
+        data: {
+          templateName: dto.templateName,
+          coverUrl: '',
+          status: dto.status,
+          category: dto.category,
+          tags: (dto.tags ?? []).join(','),
+          description: dto.description ?? null,
+          modelUrl: dto.modelUrl,
+          clothesUrl: dto.clothesUrl,
+          backgroundUrl: dto.backgroundUrl,
+          refHairUrl: dto.refHairUrl,
+          hairstylistId: stylistId,
+          templateUrl: '',
+          isSystemTemplate: 'NO',
+        },
+      });
+
+      const task = await tx.hairTemplateMakeTask.create({
+        data: {
+          totalTaskId,
+          step1TaskId: '',
+          step2TaskId: '',
+          taskStatus: 'WAIT',
+          templateId: template.id,
+          step1Resource: JSON.stringify({
+            templateUrl: step1TemplateUrl,
+            lastError: null,
+          }),
+          step2Resource: JSON.stringify({
+            uploadedZipUrl: null,
+            lastError: null,
+          }),
+          refHairUrl: dto.refHairUrl,
+          modelUrl: dto.modelUrl,
+          clothesUrl: dto.clothesUrl,
+          backgroundUrl: dto.backgroundUrl,
+        },
+      });
+
+      return { template, task };
+    });
+
+    const step1 = await this.templateAiClient.executeTemplate(step1TemplateUrl, {
+      images: [dto.refHairUrl],
+    });
+
+    await this.prisma.hairTemplateMakeTask.update({
+      where: { id: created.task.id },
+      data: {
+        step1TaskId: step1.taskId,
+        taskStatus: 'PROCESSING',
+        step1Resource: JSON.stringify({
+          templateUrl: step1TemplateUrl,
+          lastError: null,
+        }),
+      },
+    });
+
+    return {
+      templateId: created.template.id.toString(),
+      taskId: created.task.id.toString(),
+      totalTaskId,
+      taskStatus: 'PROCESSING',
+    };
+  }
+
+  async getTemplateMakeTaskDetail(
+    hairstylistId: bigint,
+    taskId: string,
+  ) {
+    return this.templateTaskRunner.refreshTask(hairstylistId, taskId);
+  }
+
+  async getBasicInfo(
+    stylistId: bigint,
+    templateId: string,
+  ): Promise<TemplateBasicInfoDto> {
+    const template = await this.getOwnedTemplate(stylistId, templateId);
+    return this.toBasicInfoDto(template);
+  }
+
+  async updateBasicInfo(
+    stylistId: bigint,
+    templateId: string,
+    dto: UpdateTemplateBasicInfoDto,
+  ): Promise<TemplateBasicInfoDto> {
+    const template = await this.getOwnedTemplate(stylistId, templateId);
+    const updated = await this.prisma.hairTemplate.update({
+      where: { id: template.id },
+      data: {
+        templateName: dto.templateName,
+        status: dto.status,
+        category: dto.category,
+        tags: (dto.tags ?? []).join(','),
+        description: dto.description ?? '',
+      },
+    });
+
+    return this.toBasicInfoDto(updated);
+  }
+
+  async deleteTemplate(
+    stylistId: bigint,
+    templateId: string,
+  ): Promise<{ success: true }> {
+    const template = await this.getOwnedTemplate(stylistId, templateId);
+
+    await this.prisma.hairTemplate.updateMany({
+      where: {
+        id: template.id,
+        hairstylistId: stylistId,
+        isDeleted: 0,
+      },
+      data: {
+        isDeleted: 1,
+      },
+    });
+
+    await this.prisma.hairTemplateMakeTask.updateMany({
+      where: {
+        templateId: template.id,
+        isDeleted: 0,
+      },
+      data: {
+        isDeleted: 1,
+      },
+    });
+
+    return { success: true };
+  }
+
+  async deleteTemplates(
+    stylistId: bigint,
+    templateIds: string[],
+  ): Promise<{ deletedCount: number }> {
+    const parsedIds = Array.from(
+      new Set(
+        templateIds
+          .map((id) => id.trim())
+          .filter(Boolean)
+          .map((id) => BigInt(id)),
+      ),
+    );
+
+    if (parsedIds.length === 0) {
+      return { deletedCount: 0 };
+    }
+
+    const ownedTemplates = await this.prisma.hairTemplate.findMany({
+      where: {
+        id: {
+          in: parsedIds,
+        },
+        hairstylistId: stylistId,
+        isDeleted: 0,
+      },
+      select: {
+        id: true,
+      },
+    });
+
+    const ownedIds = ownedTemplates.map((item) => item.id);
+
+    if (ownedIds.length === 0) {
+      return { deletedCount: 0 };
+    }
+
+    const { count } = await this.prisma.hairTemplate.updateMany({
+      where: {
+        id: {
+          in: ownedIds,
+        },
+        hairstylistId: stylistId,
+        isDeleted: 0,
+      },
+      data: {
+        isDeleted: 1,
+      },
+    });
+
+    await this.prisma.hairTemplateMakeTask.updateMany({
+      where: {
+        templateId: {
+          in: ownedIds,
+        },
+        isDeleted: 0,
+      },
+      data: {
+        isDeleted: 1,
+      },
+    });
+
+    return {
+      deletedCount: count,
+    };
+  }
+
+  async getSummary(stylistId: bigint): Promise<{
     total: number;
+    published: number;
+    weeklyUsed: number;
+    draft: number;
   }> {
+    const now = new Date();
+    const utcDay = now.getUTCDay();
+    const diffToMonday = utcDay === 0 ? 6 : utcDay - 1;
+    const startOfWeek = new Date(
+      Date.UTC(
+        now.getUTCFullYear(),
+        now.getUTCMonth(),
+        now.getUTCDate() - diffToMonday,
+      ),
+    );
+    const endOfWeek = new Date(
+      Date.UTC(
+        now.getUTCFullYear(),
+        now.getUTCMonth(),
+        now.getUTCDate() - diffToMonday + 7,
+      ),
+    );
+
+    const [total, published, draft, templates] = await Promise.all([
+      this.prisma.hairTemplate.count({
+        where: {
+          hairstylistId: stylistId,
+          isDeleted: 0,
+        },
+      }),
+      this.prisma.hairTemplate.count({
+        where: {
+          hairstylistId: stylistId,
+          isDeleted: 0,
+          status: 'PUBLIC',
+        },
+      }),
+      this.prisma.hairTemplate.count({
+        where: {
+          hairstylistId: stylistId,
+          isDeleted: 0,
+          status: 'DRAFT',
+        },
+      }),
+      this.prisma.hairTemplate.findMany({
+        where: {
+          hairstylistId: stylistId,
+          isDeleted: 0,
+        },
+        select: {
+          id: true,
+        },
+      }),
+    ]);
+
+    const templateIds = templates.map((item) => item.id.toString());
+    const weeklyUsed = templateIds.length
+      ? await this.prisma.aiHairSimulationTask.count({
+          where: {
+            taskStatus: 'SUCCESSES',
+            isDeleted: 0,
+            templateId: {
+              in: templateIds,
+            },
+            taskFinishTime: {
+              gte: startOfWeek,
+              lt: endOfWeek,
+            },
+          },
+        })
+      : 0;
+
+    return {
+      total,
+      published,
+      weeklyUsed,
+      draft,
+    };
+  }
+
+  async list(query: TemplateListQuery): Promise<{
+    items: Array<{
+      id: string;
+      name: string;
+      category: 'LONG' | 'MEDIUM' | 'SHORT' | 'DYE';
+      status: 'DRAFT' | 'PUBLIC';
+      description: string;
+      tags: string[];
+      image: string;
+      makeTaskId?: string;
+      makeTaskStatus?: string;
+      usedCount: number;
+    }>;
+    total: number;
+    page: number;
+    pageSize: number;
+  }> {
+    const page = query.page && query.page > 0 ? query.page : 1;
+    const pageSize = query.pageSize && query.pageSize > 0 ? query.pageSize : 8;
+    const keyword = query.q?.trim();
+    const category = TEMPLATE_CATEGORIES.find((item) => item === query.category);
+    const status = TEMPLATE_STATUSES.find((item) => item === query.status);
+
+    const where = {
+      hairstylistId: query.stylistId,
+      isDeleted: 0,
+      ...(category ? { category } : {}),
+      ...(status ? { status } : {}),
+      ...(keyword
+        ? {
+            OR: [
+              { templateName: { contains: keyword } },
+              { tags: { contains: keyword } },
+              { description: { contains: keyword } },
+            ],
+          }
+        : {}),
+    };
+
+    const [total, templates] = await Promise.all([
+      this.prisma.hairTemplate.count({ where }),
+      this.prisma.hairTemplate.findMany({
+        where,
+        orderBy: [{ id: 'desc' }],
+        skip: (page - 1) * pageSize,
+        take: pageSize,
+        select: {
+          id: true,
+          templateName: true,
+          category: true,
+          status: true,
+          description: true,
+          tags: true,
+          coverUrl: true,
+          usedCount: true,
+        },
+      }),
+    ]);
+
+    const templateIds = templates.map((item) => item.id);
+    const tasks = templateIds.length
+      ? await this.prisma.hairTemplateMakeTask.findMany({
+          where: {
+            isDeleted: 0,
+            templateId: { in: templateIds },
+          },
+          orderBy: [{ id: 'desc' }],
+        })
+      : [];
+
+    const taskMap = new Map<string, { id: bigint; taskStatus: string }>();
+    for (const task of tasks) {
+      if (!task.templateId) {
+        continue;
+      }
+      const key = task.templateId.toString();
+      if (!taskMap.has(key)) {
+        taskMap.set(key, { id: task.id, taskStatus: task.taskStatus });
+      }
+    }
+
     return {
-      items: [],
-      total: 0,
+      items: templates.map((template) => this.toTemplateListItem(template, taskMap)),
+      total,
+      page,
+      pageSize,
     };
   }
 

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

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

Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 82 - 0
backend/src/step2/content.json


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

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

+ 953 - 0
backend/test/templates.e2e-spec.ts

@@ -0,0 +1,953 @@
+import { INestApplication } from '@nestjs/common';
+import { JwtService } from '@nestjs/jwt';
+import { Test } from '@nestjs/testing';
+import * as request from 'supertest';
+import { configureApp } from '../src/app.setup';
+import { TemplateAiClient } from '../src/modules/templates/template-ai.client';
+import { TemplateTaskFiles } from '../src/modules/templates/template-task-files';
+import { PrismaService } from '../src/prisma/prisma.service';
+
+process.env.GCS_BUCKET ??= 'test-bucket';
+process.env.GCS_PROJECT_ID ??= 'test-project';
+process.env.GCS_CLIENT_EMAIL ??= 'test@example.com';
+process.env.GCS_PRIVATE_KEY ??= 'test-key';
+
+describe('Templates (e2e)', () => {
+  let app: INestApplication;
+  let jwtService: JwtService;
+  const executeTemplate = jest.fn();
+  const getResult = jest.fn();
+  const templateTaskFiles = {
+    downloadToTempFile: jest.fn(),
+    prepareStep2Bundle: jest.fn(),
+    cleanup: jest.fn(),
+  };
+  const systemConfigs: Array<Record<string, unknown>> = [];
+  const hairTemplates: Array<Record<string, unknown>> = [];
+  const hairTemplateMakeTasks: Array<Record<string, unknown>> = [];
+  const aiHairSimulationTasks: Array<Record<string, unknown>> = [];
+  let nextSystemConfigId = 1n;
+  let nextTemplateId = 101n;
+  let nextTaskId = 88n;
+
+  function filterHairTemplates(params: {
+    where?: Record<string, unknown>;
+    orderBy?: Record<string, unknown>;
+    skip?: number;
+    take?: number;
+  }) {
+    let items = [...hairTemplates];
+    const typedWhere = (params.where ?? {}) as {
+      hairstylistId?: bigint;
+      isDeleted?: number;
+      category?: string;
+      status?: string;
+      OR?: Array<{
+        templateName?: { contains: string };
+        tags?: { contains: string };
+        description?: { contains: string };
+      }>;
+    };
+
+    if (typeof typedWhere.isDeleted === 'number') {
+      items = items.filter((item) => item.isDeleted === typedWhere.isDeleted);
+    }
+
+    if (typeof typedWhere.hairstylistId === 'bigint') {
+      items = items.filter((item) => item.hairstylistId === typedWhere.hairstylistId);
+    }
+
+    if (typedWhere.category) {
+      items = items.filter((item) => item.category === typedWhere.category);
+    }
+
+    if (typedWhere.status) {
+      items = items.filter((item) => item.status === typedWhere.status);
+    }
+
+    if (Array.isArray(typedWhere.OR) && typedWhere.OR.length > 0) {
+      const keyword =
+        typedWhere.OR[0]?.templateName?.contains ??
+        typedWhere.OR[1]?.tags?.contains ??
+        typedWhere.OR[2]?.description?.contains ??
+        '';
+
+      items = items.filter((item) => {
+        return (
+          String(item.templateName ?? '').includes(keyword) ||
+          String(item.tags ?? '').includes(keyword) ||
+          String(item.description ?? '').includes(keyword)
+        );
+      });
+    }
+
+    if (
+      Array.isArray(params.orderBy) &&
+      params.orderBy[0] &&
+      'id' in params.orderBy[0] &&
+      params.orderBy[0].id === 'desc'
+    ) {
+      items.sort((a, b) => Number(b.id) - Number(a.id));
+    }
+
+    return typeof params.take === 'number'
+      ? items.slice(Number(params.skip ?? 0), Number(params.skip ?? 0) + params.take)
+      : items;
+  }
+
+  const prisma = {
+    systemConfig: {
+      createMany: jest.fn(async ({ data }: { data: Array<Record<string, unknown>> }) => {
+        for (const item of data) {
+          systemConfigs.push({
+            id: nextSystemConfigId,
+            isDeleted: 0,
+            createTime: new Date(),
+            updateTime: new Date(),
+            remark: '',
+            ...item,
+          });
+          nextSystemConfigId += 1n;
+        }
+
+        return { count: data.length };
+      }),
+      create: jest.fn(async ({ data }: { data: Record<string, unknown> }) => {
+        const created = {
+          id: nextSystemConfigId,
+          isDeleted: 0,
+          createTime: new Date(),
+          updateTime: new Date(),
+          remark: '',
+          ...data,
+        };
+        systemConfigs.push(created);
+        nextSystemConfigId += 1n;
+        return created;
+      }),
+      findMany: jest.fn(async ({ where, orderBy, take }: Record<string, unknown>) => {
+        let items = [...systemConfigs];
+
+        if (where && typeof where === 'object') {
+          const typedWhere = where as {
+            isDeleted?: number;
+            configGroup?: string | { in: string[] };
+          };
+
+          if (typeof typedWhere.isDeleted === 'number') {
+            items = items.filter((item) => item.isDeleted === typedWhere.isDeleted);
+          }
+
+          if (typeof typedWhere.configGroup === 'string') {
+            items = items.filter((item) => item.configGroup === typedWhere.configGroup);
+          }
+
+          const configGroupFilter = typedWhere.configGroup;
+          if (
+            configGroupFilter &&
+            typeof configGroupFilter === 'object' &&
+            'in' in configGroupFilter &&
+            Array.isArray(configGroupFilter.in)
+          ) {
+            items = items.filter((item) =>
+              configGroupFilter.in.includes(String(item.configGroup)),
+            );
+          }
+        }
+
+        if (Array.isArray(orderBy)) {
+          items.sort((a, b) => {
+            const sortA = Number(a.sort ?? 0);
+            const sortB = Number(b.sort ?? 0);
+            if (sortA !== sortB) {
+              return sortA - sortB;
+            }
+
+            return Number(a.id) - Number(b.id);
+          });
+        }
+
+        if (typeof take === 'number') {
+          items = items.slice(0, take);
+        }
+
+        return items;
+      }),
+      deleteMany: jest.fn(async () => ({ count: 0 })),
+    },
+    hairTemplate: {
+      findFirst: jest.fn(async ({ where }: { where: Record<string, unknown> }) => {
+        return (
+          hairTemplates.find((item) => {
+            return (
+              item.id === where.id &&
+              item.isDeleted === where.isDeleted &&
+              item.hairstylistId === where.hairstylistId
+            );
+          }) ?? null
+        );
+      }),
+      findMany: jest.fn(async ({ where, orderBy, skip = 0, take }: Record<string, unknown>) => {
+        return filterHairTemplates({
+          where: where as Record<string, unknown> | undefined,
+          orderBy: orderBy as Record<string, unknown> | undefined,
+          skip: Number(skip),
+          take: typeof take === 'number' ? Number(take) : undefined,
+        });
+      }),
+      count: jest.fn(async ({ where }: { where: Record<string, unknown> }) => {
+        return filterHairTemplates({ where }).length;
+      }),
+      update: jest.fn(async ({ where, data }: { where: { id: bigint }; data: Record<string, unknown> }) => {
+        const target = hairTemplates.find((item) => item.id === where.id);
+        if (!target) {
+          throw new Error('template not found');
+        }
+        Object.assign(target, data);
+        return target;
+      }),
+      updateMany: jest.fn(async ({
+        where,
+        data,
+      }: {
+        where: {
+          id?: bigint | { in?: bigint[] };
+          hairstylistId?: bigint;
+          isDeleted?: number;
+        };
+        data: Record<string, unknown>;
+      }) => {
+        let items = [...hairTemplates];
+        if (typeof where.isDeleted === 'number') {
+          items = items.filter((item) => item.isDeleted === where.isDeleted);
+        }
+        if (typeof where.hairstylistId === 'bigint') {
+          items = items.filter((item) => item.hairstylistId === where.hairstylistId);
+        }
+        if (typeof where.id === 'bigint') {
+          items = items.filter((item) => item.id === where.id);
+        } else {
+          const idFilter = where.id;
+          if (
+            idFilter &&
+            typeof idFilter === 'object' &&
+            'in' in idFilter &&
+            Array.isArray(idFilter.in)
+          ) {
+            const ids = idFilter.in;
+            items = items.filter((item) => ids.includes(item.id as bigint));
+          }
+        }
+        items.forEach((item) => Object.assign(item, data));
+        return { count: items.length };
+      }),
+      deleteMany: jest.fn(async () => ({ count: 0 })),
+    },
+    hairTemplateMakeTask: {
+      findFirst: jest.fn(async ({ where }: { where: Record<string, unknown> }) => {
+        return (
+          hairTemplateMakeTasks.find((item) => {
+            return item.id === where.id && item.isDeleted === where.isDeleted;
+          }) ?? null
+        );
+      }),
+      findMany: jest.fn(async ({ where, orderBy }: Record<string, unknown>) => {
+        const typedWhere = (where ?? {}) as {
+          isDeleted?: number;
+          templateId?: { in?: bigint[] };
+        };
+        let items = [...hairTemplateMakeTasks];
+
+        if (typeof typedWhere.isDeleted === 'number') {
+          items = items.filter((item) => item.isDeleted === typedWhere.isDeleted);
+        }
+
+        if (typedWhere.templateId?.in) {
+          items = items.filter((item) => typedWhere.templateId?.in?.includes(item.templateId as bigint));
+        }
+
+        if (Array.isArray(orderBy) && orderBy[0] && 'id' in orderBy[0] && orderBy[0].id === 'desc') {
+          items.sort((a, b) => Number(b.id) - Number(a.id));
+        }
+
+        return items;
+      }),
+      update: jest.fn(async ({ where, data }: { where: { id: bigint }; data: Record<string, unknown> }) => {
+        const target = hairTemplateMakeTasks.find((item) => item.id === where.id);
+        if (!target) {
+          throw new Error('task not found');
+        }
+        Object.assign(target, data);
+        return target;
+      }),
+      updateMany: jest.fn(async ({
+        where,
+        data,
+      }: {
+        where: {
+          templateId?: bigint | { in?: bigint[] };
+          isDeleted?: number;
+        };
+        data: Record<string, unknown>;
+      }) => {
+        let items = [...hairTemplateMakeTasks];
+        if (typeof where.isDeleted === 'number') {
+          items = items.filter((item) => item.isDeleted === where.isDeleted);
+        }
+        if (typeof where.templateId === 'bigint') {
+          items = items.filter((item) => item.templateId === where.templateId);
+        } else {
+          const templateIdFilter = where.templateId;
+          if (
+            templateIdFilter &&
+            typeof templateIdFilter === 'object' &&
+            'in' in templateIdFilter &&
+            Array.isArray(templateIdFilter.in)
+          ) {
+            const templateIds = templateIdFilter.in;
+            items = items.filter((item) =>
+              templateIds.includes(item.templateId as bigint),
+            );
+          }
+        }
+        items.forEach((item) => Object.assign(item, data));
+        return { count: items.length };
+      }),
+      deleteMany: jest.fn(async () => ({ count: 0 })),
+    },
+    aiHairSimulationTask: {
+      count: jest.fn(async ({ where }: { where: Record<string, unknown> }) => {
+        const typedWhere = where as {
+          taskStatus?: string;
+          isDeleted?: number;
+          templateId?: { in?: string[] };
+          taskFinishTime?: { gte?: Date; lt?: Date };
+        };
+        let items = [...aiHairSimulationTasks];
+
+        if (typeof typedWhere.taskStatus === 'string') {
+          items = items.filter((item) => item.taskStatus === typedWhere.taskStatus);
+        }
+        if (typeof typedWhere.isDeleted === 'number') {
+          items = items.filter((item) => item.isDeleted === typedWhere.isDeleted);
+        }
+        if (typedWhere.templateId?.in) {
+          items = items.filter((item) =>
+            typedWhere.templateId?.in?.includes(String(item.templateId)),
+          );
+        }
+        if (typedWhere.taskFinishTime?.gte instanceof Date) {
+          items = items.filter(
+            (item) =>
+              item.taskFinishTime instanceof Date &&
+              item.taskFinishTime >= typedWhere.taskFinishTime!.gte!,
+          );
+        }
+        if (typedWhere.taskFinishTime?.lt instanceof Date) {
+          items = items.filter(
+            (item) =>
+              item.taskFinishTime instanceof Date &&
+              item.taskFinishTime < typedWhere.taskFinishTime!.lt!,
+          );
+        }
+
+        return items.length;
+      }),
+    },
+    $transaction: jest.fn(async (callback: (tx: {
+      hairTemplate: { create: (args: { data: Record<string, unknown> }) => Promise<Record<string, unknown>> };
+      hairTemplateMakeTask: { create: (args: { data: Record<string, unknown> }) => Promise<Record<string, unknown>> };
+    }) => Promise<unknown>) =>
+      callback({
+        hairTemplate: {
+          create: async ({ data }) => {
+            const created = {
+              id: nextTemplateId,
+              createTime: new Date(),
+              updateTime: new Date(),
+              isDeleted: 0,
+              ...data,
+            };
+            hairTemplates.push(created);
+            nextTemplateId += 1n;
+            return created;
+          },
+        },
+        hairTemplateMakeTask: {
+          create: async ({ data }) => {
+            const created = {
+              id: nextTaskId,
+              createTime: new Date(),
+              updateTime: new Date(),
+              isDeleted: 0,
+              ...data,
+            };
+            hairTemplateMakeTasks.push(created);
+            nextTaskId += 1n;
+            return created;
+          },
+        },
+      })),
+  };
+
+  async function signToken() {
+    return jwtService.signAsync({
+      sub: 1,
+      stylistId: '1',
+      email: 'stylist@example.com',
+      username: 'Ogawa',
+    });
+  }
+
+  beforeAll(async () => {
+    const { AppModule } = await import('../src/app.module');
+    const moduleRef = await Test.createTestingModule({
+      imports: [AppModule],
+    })
+      .overrideProvider(TemplateAiClient)
+      .useValue({ executeTemplate, getResult })
+      .overrideProvider(TemplateTaskFiles)
+      .useValue(templateTaskFiles)
+      .overrideProvider(PrismaService)
+      .useValue(prisma)
+      .compile();
+
+    app = moduleRef.createNestApplication();
+    configureApp(app);
+    await app.init();
+    jwtService = app.get(JwtService);
+  });
+
+  beforeEach(() => {
+    executeTemplate.mockReset();
+    getResult.mockReset();
+    templateTaskFiles.downloadToTempFile.mockReset();
+    templateTaskFiles.prepareStep2Bundle.mockReset();
+    templateTaskFiles.cleanup.mockReset();
+    systemConfigs.length = 0;
+    hairTemplates.length = 0;
+    hairTemplateMakeTasks.length = 0;
+    aiHairSimulationTasks.length = 0;
+    nextSystemConfigId = 1n;
+    nextTemplateId = 101n;
+    nextTaskId = 88n;
+  });
+
+  afterAll(async () => {
+    await app.close();
+  });
+
+  it('/api/v1/templates/assets (GET) returns grouped asset options', async () => {
+    const token = await signToken();
+    const uniqueSuffix = `${Date.now()}-assets`;
+
+    await prisma.systemConfig.createMany({
+      data: [
+        {
+          configKey: `e2e-template-model-${uniqueSuffix}`,
+          configName: '模特',
+          configGroup: 'ai-model',
+          configValue: 'https://cdn.example.com/model.jpg',
+          valueType: 'STRING',
+          sort: 1,
+        },
+        {
+          configKey: `e2e-template-clothes-${uniqueSuffix}`,
+          configName: '服装',
+          configGroup: 'clothing',
+          configValue: 'https://cdn.example.com/clothes.jpg',
+          valueType: 'STRING',
+          sort: 2,
+        },
+        {
+          configKey: `e2e-template-background-${uniqueSuffix}`,
+          configName: '背景',
+          configGroup: 'background',
+          configValue: 'https://cdn.example.com/background.jpg',
+          valueType: 'STRING',
+          sort: 3,
+        },
+      ] as never,
+    });
+
+    const response = await request(app.getHttpServer())
+      .get('/api/v1/templates/assets')
+      .set('Authorization', `Bearer ${token}`)
+      .expect(200);
+
+    expect(response.body.data.models).toEqual(
+      expect.arrayContaining([
+        expect.objectContaining({
+          name: '模特',
+          url: 'https://cdn.example.com/model.jpg',
+        }),
+      ]),
+    );
+    expect(response.body.data.clothes).toEqual(
+      expect.arrayContaining([
+        expect.objectContaining({
+          name: '服装',
+          url: 'https://cdn.example.com/clothes.jpg',
+        }),
+      ]),
+    );
+    expect(response.body.data.backgrounds).toEqual(
+      expect.arrayContaining([
+        expect.objectContaining({
+          name: '背景',
+          url: 'https://cdn.example.com/background.jpg',
+        }),
+      ]),
+    );
+  });
+
+  it('/api/v1/templates/make-tasks (POST) creates template and task rows', async () => {
+    const token = await signToken();
+    const uniqueSuffix = `${Date.now()}-create`;
+
+    await prisma.systemConfig.create({
+      data: {
+        configKey: `e2e-template-step1-${uniqueSuffix}`,
+        configName: 'step1',
+        configGroup: 'template-step1',
+        configValue: 'https://cdn.example.com/template-step1.json',
+        valueType: 'STRING',
+        sort: 1,
+      } as never,
+    });
+
+    executeTemplate.mockResolvedValueOnce({
+      taskId: `e2e-template-step1-task-${uniqueSuffix}`,
+    });
+
+    const response = await request(app.getHttpServer())
+      .post('/api/v1/templates/make-tasks')
+      .set('Authorization', `Bearer ${token}`)
+      .send({
+        templateName: `e2e-template-${uniqueSuffix}`,
+        category: 'LONG',
+        status: 'DRAFT',
+        description: 'e2e description',
+        tags: ['首次建议', '层次'],
+        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',
+      })
+      .expect(201);
+
+    expect(response.body.data).toEqual({
+      templateId: expect.any(String),
+      taskId: expect.any(String),
+      totalTaskId: expect.stringContaining('tmpl_'),
+      taskStatus: 'PROCESSING',
+    });
+    expect(executeTemplate).toHaveBeenCalledWith(
+      'https://cdn.example.com/template-step1.json',
+      {
+        images: ['https://cdn.example.com/ref.jpg'],
+      },
+    );
+  });
+
+  it('/api/v1/templates/:templateId (GET) returns template basic info', async () => {
+    const token = await signToken();
+
+    hairTemplates.push({
+      id: 43n,
+      templateName: '柔和层次长发',
+      coverUrl: 'https://cdn.example.com/cover.jpg',
+      templateUrl: 'https://cdn.example.com/template.zip',
+      status: 'DRAFT',
+      category: 'LONG',
+      tags: '层次,首次建议',
+      description: '适合轻盈感修饰',
+      hairstylistId: 1n,
+      isDeleted: 0,
+    });
+
+    const response = await request(app.getHttpServer())
+      .get('/api/v1/templates/43')
+      .set('Authorization', `Bearer ${token}`)
+      .expect(200);
+
+    expect(response.body.data).toEqual({
+      id: '43',
+      templateName: '柔和层次长发',
+      status: 'DRAFT',
+      category: 'LONG',
+      tags: ['层次', '首次建议'],
+      description: '适合轻盈感修饰',
+      coverUrl: 'https://cdn.example.com/cover.jpg',
+      templateUrl: 'https://cdn.example.com/template.zip',
+    });
+  });
+
+  it('/api/v1/templates/:templateId (PATCH) updates only template basic info', async () => {
+    const token = await signToken();
+
+    hairTemplates.push({
+      id: 43n,
+      templateName: '旧模板名',
+      coverUrl: 'https://cdn.example.com/cover.jpg',
+      templateUrl: 'https://cdn.example.com/template.zip',
+      status: 'DRAFT',
+      category: 'LONG',
+      tags: '旧标签',
+      description: '旧描述',
+      modelUrl: 'https://cdn.example.com/model.jpg',
+      clothesUrl: 'https://cdn.example.com/clothes.jpg',
+      backgroundUrl: 'https://cdn.example.com/background.jpg',
+      refHairUrl: 'https://cdn.example.com/ref.jpg',
+      hairstylistId: 1n,
+      isDeleted: 0,
+    });
+
+    const response = await request(app.getHttpServer())
+      .patch('/api/v1/templates/43')
+      .set('Authorization', `Bearer ${token}`)
+      .send({
+        templateName: '空气感中长发',
+        status: 'PUBLIC',
+        category: 'MEDIUM',
+        tags: ['空气感', '层次'],
+        description: '新描述',
+      })
+      .expect(200);
+
+    expect(response.body.data).toEqual({
+      id: '43',
+      templateName: '空气感中长发',
+      status: 'PUBLIC',
+      category: 'MEDIUM',
+      tags: ['空气感', '层次'],
+      description: '新描述',
+      coverUrl: 'https://cdn.example.com/cover.jpg',
+      templateUrl: 'https://cdn.example.com/template.zip',
+    });
+    expect(hairTemplates[0]).toEqual(
+      expect.objectContaining({
+        templateName: '空气感中长发',
+        status: 'PUBLIC',
+        category: 'MEDIUM',
+        tags: '空气感,层次',
+        description: '新描述',
+        modelUrl: 'https://cdn.example.com/model.jpg',
+        clothesUrl: 'https://cdn.example.com/clothes.jpg',
+        backgroundUrl: 'https://cdn.example.com/background.jpg',
+        refHairUrl: 'https://cdn.example.com/ref.jpg',
+      }),
+    );
+  });
+
+  it('/api/v1/templates (GET) filters and paginates stylist templates', async () => {
+    const token = await signToken();
+
+    hairTemplates.push(
+      {
+        id: 101n,
+        templateName: '空气感中长发',
+        category: 'MEDIUM',
+        status: 'PUBLIC',
+        description: '空气感提案',
+        tags: '空气感,脸周',
+        coverUrl: 'https://cdn.example.com/101.jpg',
+        templateUrl: 'https://cdn.example.com/101.zip',
+        usedCount: 5,
+        hairstylistId: 1n,
+        isDeleted: 0,
+      },
+      {
+        id: 102n,
+        templateName: '厚重短发',
+        category: 'SHORT',
+        status: 'DRAFT',
+        description: '短发提案',
+        tags: '短发',
+        coverUrl: 'https://cdn.example.com/102.jpg',
+        templateUrl: 'https://cdn.example.com/102.zip',
+        usedCount: 1,
+        hairstylistId: 1n,
+        isDeleted: 0,
+      },
+      {
+        id: 103n,
+        templateName: '其他美发师模板',
+        category: 'MEDIUM',
+        status: 'PUBLIC',
+        description: '不应出现',
+        tags: '空气感',
+        coverUrl: 'https://cdn.example.com/103.jpg',
+        templateUrl: 'https://cdn.example.com/103.zip',
+        usedCount: 3,
+        hairstylistId: 2n,
+        isDeleted: 0,
+      },
+    );
+    hairTemplateMakeTasks.push({
+      id: 88n,
+      templateId: 101n,
+      taskStatus: 'STEP2_SUCCESS',
+      isDeleted: 0,
+    });
+
+    const response = await request(app.getHttpServer())
+      .get('/api/v1/templates?q=空气感&category=MEDIUM&status=PUBLIC&page=1&pageSize=1')
+      .set('Authorization', `Bearer ${token}`)
+      .expect(200);
+
+    expect(response.body.data).toEqual({
+      items: [
+        {
+          id: '101',
+          name: '空气感中长发',
+          category: 'MEDIUM',
+          status: 'PUBLIC',
+          description: '空气感提案',
+          tags: ['空气感', '脸周'],
+          image: 'https://cdn.example.com/101.jpg',
+          makeTaskId: '88',
+          makeTaskStatus: 'STEP2_SUCCESS',
+          usedCount: 5,
+        },
+      ],
+      total: 1,
+      page: 1,
+      pageSize: 1,
+    });
+  });
+
+  it('/api/v1/templates/summary (GET) returns real template dashboard summary', async () => {
+    const token = await signToken();
+
+    hairTemplates.push(
+      {
+        id: 101n,
+        templateName: '空气感中长发',
+        category: 'MEDIUM',
+        status: 'PUBLIC',
+        description: '空气感提案',
+        tags: '空气感,脸周',
+        coverUrl: 'https://cdn.example.com/101.jpg',
+        templateUrl: 'https://cdn.example.com/101.zip',
+        usedCount: 5,
+        hairstylistId: 1n,
+        isDeleted: 0,
+      },
+      {
+        id: 102n,
+        templateName: '厚重短发',
+        category: 'SHORT',
+        status: 'DRAFT',
+        description: '短发提案',
+        tags: '短发',
+        coverUrl: 'https://cdn.example.com/102.jpg',
+        templateUrl: 'https://cdn.example.com/102.zip',
+        usedCount: 1,
+        hairstylistId: 1n,
+        isDeleted: 0,
+      },
+    );
+    aiHairSimulationTasks.push(
+      {
+        id: 501n,
+        templateId: '101',
+        taskStatus: 'SUCCESSES',
+        taskFinishTime: new Date(),
+        isDeleted: 0,
+      },
+      {
+        id: 502n,
+        templateId: '999',
+        taskStatus: 'SUCCESSES',
+        taskFinishTime: new Date(),
+        isDeleted: 0,
+      },
+    );
+
+    const response = await request(app.getHttpServer())
+      .get('/api/v1/templates/summary')
+      .set('Authorization', `Bearer ${token}`)
+      .expect(200);
+
+    expect(response.body.data).toEqual({
+      total: 2,
+      published: 1,
+      weeklyUsed: 1,
+      draft: 1,
+    });
+  });
+
+  it('/api/v1/templates/:templateId (DELETE) soft deletes owned template', async () => {
+    const token = await signToken();
+
+    hairTemplates.push({
+      id: 101n,
+      templateName: '空气感中长发',
+      category: 'MEDIUM',
+      status: 'PUBLIC',
+      description: '空气感提案',
+      tags: '空气感,脸周',
+      coverUrl: 'https://cdn.example.com/101.jpg',
+      templateUrl: 'https://cdn.example.com/101.zip',
+      hairstylistId: 1n,
+      isDeleted: 0,
+    });
+    hairTemplateMakeTasks.push({
+      id: 88n,
+      templateId: 101n,
+      taskStatus: 'STEP2_SUCCESS',
+      isDeleted: 0,
+    });
+
+    const response = await request(app.getHttpServer())
+      .delete('/api/v1/templates/101')
+      .set('Authorization', `Bearer ${token}`)
+      .expect(200);
+
+    expect(response.body.data).toEqual({ success: true });
+    expect(hairTemplates.find((item) => item.id === 101n)?.isDeleted).toBe(1);
+    expect(hairTemplateMakeTasks.find((item) => item.id === 88n)?.isDeleted).toBe(1);
+  });
+
+  it('/api/v1/templates/bulk-delete (POST) soft deletes current stylist templates only', async () => {
+    const token = await signToken();
+
+    hairTemplates.push(
+      {
+        id: 101n,
+        templateName: '空气感中长发',
+        category: 'MEDIUM',
+        status: 'PUBLIC',
+        description: '空气感提案',
+        tags: '空气感,脸周',
+        coverUrl: 'https://cdn.example.com/101.jpg',
+        templateUrl: 'https://cdn.example.com/101.zip',
+        hairstylistId: 1n,
+        isDeleted: 0,
+      },
+      {
+        id: 102n,
+        templateName: '厚重短发',
+        category: 'SHORT',
+        status: 'DRAFT',
+        description: '短发提案',
+        tags: '短发',
+        coverUrl: 'https://cdn.example.com/102.jpg',
+        templateUrl: 'https://cdn.example.com/102.zip',
+        hairstylistId: 1n,
+        isDeleted: 0,
+      },
+      {
+        id: 103n,
+        templateName: '其他美发师模板',
+        category: 'MEDIUM',
+        status: 'PUBLIC',
+        description: '不应删除',
+        tags: '空气感',
+        coverUrl: 'https://cdn.example.com/103.jpg',
+        templateUrl: 'https://cdn.example.com/103.zip',
+        hairstylistId: 2n,
+        isDeleted: 0,
+      },
+    );
+    hairTemplateMakeTasks.push(
+      {
+        id: 88n,
+        templateId: 101n,
+        taskStatus: 'STEP2_SUCCESS',
+        isDeleted: 0,
+      },
+      {
+        id: 89n,
+        templateId: 102n,
+        taskStatus: 'PROCESSING',
+        isDeleted: 0,
+      },
+      {
+        id: 90n,
+        templateId: 103n,
+        taskStatus: 'STEP2_SUCCESS',
+        isDeleted: 0,
+      },
+    );
+
+    const response = await request(app.getHttpServer())
+      .post('/api/v1/templates/bulk-delete')
+      .set('Authorization', `Bearer ${token}`)
+      .send({
+        ids: ['101', '102', '103'],
+      })
+      .expect(201);
+
+    expect(response.body.data).toEqual({ deletedCount: 2 });
+    expect(hairTemplates.find((item) => item.id === 101n)?.isDeleted).toBe(1);
+    expect(hairTemplates.find((item) => item.id === 102n)?.isDeleted).toBe(1);
+    expect(hairTemplates.find((item) => item.id === 103n)?.isDeleted).toBe(0);
+    expect(hairTemplateMakeTasks.find((item) => item.id === 88n)?.isDeleted).toBe(1);
+    expect(hairTemplateMakeTasks.find((item) => item.id === 89n)?.isDeleted).toBe(1);
+    expect(hairTemplateMakeTasks.find((item) => item.id === 90n)?.isDeleted).toBe(0);
+  });
+
+  it('/api/v1/templates/make-tasks/:taskId (GET) promotes step1 success and returns detail', async () => {
+    const token = await signToken();
+
+    hairTemplates.push({
+      id: 101n,
+      templateName: '柔和层次长发',
+      coverUrl: '',
+      templateUrl: '',
+      status: 'DRAFT',
+      hairstylistId: 1n,
+      isDeleted: 0,
+    });
+    hairTemplateMakeTasks.push({
+      id: 88n,
+      totalTaskId: 'tmpl_123',
+      taskStatus: 'PROCESSING',
+      templateId: 101n,
+      step1TaskId: 'step1-task-1',
+      step2TaskId: '',
+      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',
+      step1ResultImg: '',
+      step2ResultImg: '',
+      step1Resource: JSON.stringify({
+        templateUrl: 'https://cdn.example.com/template-step1.json',
+      }),
+      step2Resource: JSON.stringify({}),
+      createTime: new Date('2026-06-28T12:00:00.000Z'),
+      updateTime: new Date(Date.now() - 20_000),
+      isDeleted: 0,
+    });
+    getResult.mockResolvedValueOnce({
+      status: 'successes',
+      message: '',
+      images: ['https://cdn.example.com/step1-result.jpg'],
+    });
+    templateTaskFiles.downloadToTempFile.mockResolvedValue(
+      '/tmp/template-make/tmpl_123/hairstyle.jpg',
+    );
+
+    const response = await request(app.getHttpServer())
+      .get('/api/v1/templates/make-tasks/88')
+      .set('Authorization', `Bearer ${token}`)
+      .expect(200);
+
+    expect(getResult).toHaveBeenCalledWith('step1-task-1');
+    expect(response.body.data).toEqual(
+      expect.objectContaining({
+        id: '88',
+        templateId: '101',
+        taskStatus: 'STEP1_SUCCESS',
+        step1: expect.objectContaining({
+          taskId: 'step1-task-1',
+          status: 'STEP1_SUCCESS',
+          resultImage: 'https://cdn.example.com/step1-result.jpg',
+        }),
+      }),
+    );
+  });
+});

+ 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"]
 }

+ 234 - 0
docs/superpowers/plans/2026-06-28-prisma-v1-baseline.md

@@ -0,0 +1,234 @@
+# Prisma V1 Baseline Migration Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 将当前稳定版数据库结构收敛为单一的 `v1 baseline migration`,删除旧增量 migration,并让 Prisma 后续从该基线继续演进。
+
+**Architecture:** 保持 `backend/prisma/schema.prisma` 不变,把 `backend/prisma/migrations` 重置为仅包含一条 `20260628_init_v1/migration.sql` 的完整建表基线;现有数据库不重建、不重新执行 baseline SQL,而是通过 Prisma 的已应用状态对齐这条基线。实现过程中同步校验 baseline SQL 与 `schema.prisma`、`数据库设计.md` 一致,并移除旧 migration 目录。
+
+**Tech Stack:** Prisma, MySQL, SQL, Node.js
+
+## Global Constraints
+
+- `schema.prisma` 继续作为当前稳定数据库结构的唯一事实来源。
+- `backend/prisma/migrations` 目录只保留一条 `v1 baseline migration`。
+- baseline SQL 必须包含当前稳定结构的完整建表、索引与注释。
+- 旧 migration 全部移除,不保留历史增量。
+- 不修改当前稳定数据库结构语义,只做基线重置。
+- 现有数据库不重新执行 baseline SQL,采用 Prisma 已应用状态对齐。
+- 后续数据库改动必须在 `v1 baseline` 之上继续新增 migration。
+
+---
+
+### Task 1: 生成并校验 V1 Baseline SQL
+
+**Files:**
+- Create: `backend/prisma/migrations/20260628_init_v1/migration.sql`
+- Read: `backend/prisma/schema.prisma`
+- Read: `backend/docs/数据库设计.md`
+
+**Interfaces:**
+- Consumes: `backend/prisma/schema.prisma`
+- Consumes: `backend/docs/数据库设计.md`
+- Produces: `backend/prisma/migrations/20260628_init_v1/migration.sql`
+
+- [ ] **Step 1: 基于当前 schema 与数据库设计文档列出 baseline 必须覆盖的表**
+
+```text
+hairstylist_user
+customer
+customer_treatment_record
+ai_hair_simulation
+ai_hair_simulation_task
+hair_template
+hair_template_make_task
+system_config
+```
+
+- [ ] **Step 2: 先写 baseline SQL 文件,包含完整建表与索引定义**
+
+```sql
+CREATE TABLE `hairstylist_user` (
+  `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '发型师主键ID',
+  `username` VARCHAR(50) NOT NULL COMMENT '发型师用户名',
+  `email` VARCHAR(100) NOT NULL COMMENT '登录邮箱',
+  `password` VARCHAR(100) NOT NULL COMMENT '加密后的登录密码',
+  `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+  `is_deleted` TINYINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '逻辑删除:0未删除,1已删除',
+  PRIMARY KEY (`id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='发型师用户信息表';
+
+CREATE TABLE `hair_template` (
+  `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '模板主键ID',
+  `template_name` VARCHAR(100) NOT NULL COMMENT '模板名称(必填)',
+  `cover_url` VARCHAR(500) DEFAULT '' COMMENT '模板封面图URL',
+  `status` ENUM('DRAFT','PUBLIC') NOT NULL DEFAULT 'DRAFT' COMMENT '模板状态:DRAFT草稿 / PUBLIC公开',
+  `category` ENUM('LONG','MEDIUM','SHORT','DYE') NOT NULL DEFAULT 'LONG' COMMENT '分类:长发/中长发/短发/染发',
+  `tags` VARCHAR(500) DEFAULT '' COMMENT '模板标签,逗号分隔存储',
+  `description` TEXT NULL COMMENT '模板详细说明',
+  `model_url` VARCHAR(500) DEFAULT '' COMMENT 'AI模特图片URL',
+  `clothes_url` VARCHAR(500) DEFAULT '' COMMENT '服装素材URL',
+  `background_url` VARCHAR(500) DEFAULT '' COMMENT '背景图片URL',
+  `ref_hair_url` VARCHAR(500) DEFAULT '' COMMENT '参考发型图片URL',
+  `hairstylist_id` BIGINT UNSIGNED NULL COMMENT '创建发型师ID,系统模板为空',
+  `template_url` VARCHAR(500) DEFAULT '' COMMENT '完整模板预览页面URL',
+  `used_count` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '模板累计使用次数',
+  `is_system_template` ENUM('YES','NO') NOT NULL DEFAULT 'NO' COMMENT '是否系统模板:YES系统模板,NO门店自定义模板',
+  `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+  `is_deleted` TINYINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '逻辑删除:0正常 1删除',
+  PRIMARY KEY (`id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='AI发型模板表';
+```
+
+- [ ] **Step 3: 补齐剩余 6 张表、索引和全部注释**
+
+```sql
+CREATE INDEX `idx_category` ON `hair_template` (`category`, `is_deleted`);
+CREATE INDEX `idx_hairstylist_id` ON `hair_template` (`hairstylist_id`, `is_deleted`);
+CREATE INDEX `idx_status` ON `hair_template` (`status`, `is_deleted`);
+```
+
+- [ ] **Step 4: 自查 baseline SQL 是否包含 `used_count`、索引和注释**
+
+Run: `grep -n "used_count\\|COMMENT\\|CREATE INDEX" backend/prisma/migrations/20260628_init_v1/migration.sql | head -n 20`
+
+Expected: 输出包含 `used_count`、`COMMENT`、`CREATE INDEX`
+
+- [ ] **Step 5: 提交 baseline SQL 初稿**
+
+```bash
+git add backend/prisma/migrations/20260628_init_v1/migration.sql
+git commit -m "feat: add prisma v1 baseline migration"
+```
+
+### Task 2: 清理旧 migration 并校验目录基线
+
+**Files:**
+- Delete: `backend/prisma/migrations/20260628050000_add_customer_follow_content/migration.sql`
+- Delete: `backend/prisma/migrations/20260628120000_add_customer_table/migration.sql`
+- Delete: `backend/prisma/migrations/20260628173000_add_template_used_count/migration.sql`
+- Modify: `backend/prisma/migrations/`
+
+**Interfaces:**
+- Consumes: `backend/prisma/migrations/20260628_init_v1/migration.sql`
+- Produces: `backend/prisma/migrations/` 目录中仅保留 `20260628_init_v1`
+
+- [ ] **Step 1: 删除旧 migration 目录**
+
+```text
+backend/prisma/migrations/20260628050000_add_customer_follow_content
+backend/prisma/migrations/20260628120000_add_customer_table
+backend/prisma/migrations/20260628173000_add_template_used_count
+```
+
+- [ ] **Step 2: 列出 migration 目录,确认只剩 baseline**
+
+Run: `find backend/prisma/migrations -maxdepth 2 -type f | sort`
+
+Expected:
+
+```text
+backend/prisma/migrations/20260628_init_v1/migration.sql
+```
+
+- [ ] **Step 3: 再次校验 baseline 文件存在且旧目录已移除**
+
+Run: `git status --short backend/prisma/migrations`
+
+Expected: 只出现新 baseline 与旧目录删除记录
+
+- [ ] **Step 4: 提交 migration 目录重置**
+
+```bash
+git add backend/prisma/migrations
+git commit -m "chore: reset prisma migrations to v1 baseline"
+```
+
+### Task 3: 对齐现有数据库与 Prisma migration 状态
+
+**Files:**
+- Modify: `backend/prisma/migration_lock.toml` (if generated by Prisma tooling)
+- Read: current database state via Prisma CLI
+
+**Interfaces:**
+- Consumes: `backend/prisma/migrations/20260628_init_v1/migration.sql`
+- Produces: Prisma recognizes `20260628_init_v1` as applied for the current database
+
+- [ ] **Step 1: 先确认当前数据库不需要重新执行 baseline SQL**
+
+Run: `npx prisma db pull --print`
+
+Expected: 输出的结构与 `backend/prisma/schema.prisma` 主要一致,不出现缺失 `used_count` 等明显差异
+
+- [ ] **Step 2: 将 baseline migration 标记为已应用**
+
+Run: `npx prisma migrate resolve --applied 20260628_init_v1`
+
+Expected: 输出包含 `marked as applied` 或等价成功提示
+
+- [ ] **Step 3: 查看 Prisma migration 状态**
+
+Run: `npx prisma migrate status`
+
+Expected: 输出包含 `20260628_init_v1`,且当前数据库没有 pending migration
+
+- [ ] **Step 4: 如果 Prisma 生成了额外锁文件或状态文件,确认其已纳入版本控制策略**
+
+```text
+backend/prisma/migration_lock.toml
+```
+
+- [ ] **Step 5: 提交 migration 状态对齐改动**
+
+```bash
+git add backend/prisma
+git commit -m "chore: align prisma baseline migration state"
+```
+
+### Task 4: 最终一致性校验与文档收口
+
+**Files:**
+- Read: `backend/prisma/schema.prisma`
+- Read: `backend/prisma/migrations/20260628_init_v1/migration.sql`
+- Read: `backend/docs/数据库设计.md`
+
+**Interfaces:**
+- Consumes: `schema.prisma`, `数据库设计.md`, `20260628_init_v1/migration.sql`
+- Produces: verified v1 baseline layout ready for future migrations
+
+- [ ] **Step 1: 对照 `schema.prisma` 与 baseline SQL 逐项检查核心表和字段**
+
+```text
+重点校验:
+- customer.follow_content
+- ai_hair_simulation.template_ids
+- hair_template.used_count
+- system_config.value_type
+```
+
+- [ ] **Step 2: 运行 Prisma 客户端生成,确认 schema 与 migration 目录在当前仓库状态下可工作**
+
+Run: `npx prisma generate`
+
+Expected: PASS,输出包含 `Generated Prisma Client`
+
+- [ ] **Step 3: 运行 migration 状态检查,确认没有残留旧目录引用**
+
+Run: `npx prisma migrate status`
+
+Expected: 不再出现旧 migration 名称,只识别 `20260628_init_v1`
+
+- [ ] **Step 4: 查看最终工作区状态,只确认本次相关文件**
+
+Run: `git status --short backend/prisma backend/docs/数据库设计.md`
+
+Expected: 仅包含本次 baseline 重置相关改动,或已 clean
+
+- [ ] **Step 5: 提交最终收口**
+
+```bash
+git add backend/prisma backend/docs/数据库设计.md
+git commit -m "chore: finalize prisma v1 baseline"
+```

+ 495 - 0
docs/superpowers/plans/2026-06-28-template-form-config-assets-minimal.md

@@ -0,0 +1,495 @@
+# 模板新建页素材数据源切换 Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 将模板新建页中的 AI 模特、服装、背景三组素材从本地静态配置切换为服务端配置接口,同时保留现有页面结构和旧提交流程。
+
+**Architecture:** 前端在模板 API 层新增素材读取方法和类型定义,`TemplateFormPage` 在挂载时请求服务端素材接口并将返回结果映射为现有卡片 UI 所需的选项结构。页面不再依赖本地 `hairstyleTemplateAssets`,接口失败或返回空组时直接显示错误并禁用提交,不做静态兜底。
+
+**Tech Stack:** React, TypeScript, React Router, Vitest, Testing Library, i18n JSON 文案, 现有 `http` API client
+
+## Global Constraints
+
+- 只替换这三个区块的数据来源。
+- 其他页面结构和旧提交流程先不改。
+- 接口失败时不回退本地静态数据。
+- 不改当前 `TemplateFormPage` 的其余表单结构。
+- 不改当前提交接口 `createTemplateAndStartGeneration()`。
+- 不在这次改动中接入模板制作任务页。
+- 不保留本地静态素材作为降级兜底。
+- 只增加与本次目标直接相关的定向前端测试。
+
+---
+
+### Task 1: 定义素材接口与失败测试
+
+**Files:**
+- Create: `frontend/src/pages/dashboard/templates/__tests__/TemplateFormPage.test.tsx`
+- Modify: `frontend/src/services/api/contracts.ts`
+- Modify: `frontend/src/services/api/templates.ts`
+- Modify: `frontend/src/i18n/locales/zh/translation.json`
+- Modify: `frontend/src/i18n/locales/ja/translation.json`
+
+**Interfaces:**
+- Consumes: `http.get<T>()` from `frontend/src/services/http/client.ts`
+- Produces: `TemplateAssetOption`, `TemplateAssets`, `getTemplateAssets(): Promise<TemplateAssets>`
+
+- [ ] **Step 1: 写失败测试,锁定页面必须通过接口加载素材并在失败时禁用提交**
+
+```tsx
+import { describe, expect, it, beforeEach, vi } from 'vitest'
+import { render, screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { MemoryRouter } from 'react-router-dom'
+import { HelmetProvider } from 'react-helmet-async'
+import { TemplateFormPage } from '../TemplateFormPage'
+
+const navigateMock = vi.fn()
+
+vi.mock('../../../../shared/pageContext', async () => {
+  const actual = await vi.importActual<object>('../../../../shared/pageContext')
+  return {
+    ...actual,
+    usePreviewUrl: () => '/preview.jpg',
+    usePageContext: () => ({
+      locale: 'zh',
+      navigate: navigateMock,
+      path: (value: string) => `/zh${value}`,
+      copy: {
+        common: { cancel: '取消' },
+        templateFormPage: {
+          title: '新建发型模板',
+          description: 'desc',
+          back: '返回发型模板列表',
+          badge: '创建发型模板',
+          lead: 'lead',
+          generate: 'AI生成',
+          submitTitle: '生成AI发型模板',
+          submitLead: '提交后生成',
+          loadingAssets: '正在加载素材...',
+          assetLoadFailed: '素材加载失败,请刷新后重试',
+          assetEmpty: '素材未配置完成,请检查服务器配置',
+          reference: {
+            badge: '参考图片',
+            title: '参考发型图片',
+            previewAlt: '参考发型图片预览',
+            upload: '选择图片并预览',
+            help: 'help',
+          },
+          info: {
+            badge: '模板信息',
+            title: '发型模板基本信息',
+            name: '模板名称',
+            namePlaceholder: 'name',
+            status: '状态',
+            category: '分类',
+            tags: '标签',
+            tagsPlaceholder: 'tags',
+            description: '说明',
+            descriptionPlaceholder: 'desc',
+          },
+          model: { badge: 'AI模特', title: '选择AI模特' },
+          outfit: { badge: '服装', title: '选择服装' },
+          background: { badge: '背景', title: '选择背景' },
+          statusOptions: { Draft: '草稿', Published: '公开中' },
+          categoryOptions: { Long: '长发', Medium: '中长发', Short: '短发', Color: '染发' },
+          validation: {
+            referenceRequired: '请先上传参考图',
+            assetsRequired: '请选择模特、服装和背景',
+          },
+          errors: { saveFailed: '保存失败' },
+          submitting: { uploading: '正在上传参考图...', generating: '正在提交任务...' },
+        },
+      },
+    }),
+  }
+})
+
+const getTemplateAssetsMock = vi.fn()
+const createTemplateAndStartGenerationMock = vi.fn()
+
+vi.mock('../../../../services/api/templates', () => ({
+  getTemplateAssets: (...args: unknown[]) => getTemplateAssetsMock(...args),
+  createTemplateAndStartGeneration: (...args: unknown[]) =>
+    createTemplateAndStartGenerationMock(...args),
+}))
+
+vi.mock('../../../../services/api/media', () => ({
+  uploadMedia: vi.fn().mockResolvedValue({ url: 'https://cdn.example.com/reference.jpg' }),
+}))
+
+describe('TemplateFormPage', () => {
+  beforeEach(() => {
+    navigateMock.mockReset()
+    getTemplateAssetsMock.mockReset()
+    createTemplateAndStartGenerationMock.mockReset()
+  })
+
+  function renderPage() {
+    return render(
+      <HelmetProvider>
+        <MemoryRouter>
+          <TemplateFormPage />
+        </MemoryRouter>
+      </HelmetProvider>,
+    )
+  }
+
+  it('loads model, outfit and background options from api', async () => {
+    getTemplateAssetsMock.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' }],
+    })
+
+    renderPage()
+
+    expect(await screen.findByText('模特A')).toBeInTheDocument()
+    expect(screen.getByText('服装A')).toBeInTheDocument()
+    expect(screen.getByText('背景A')).toBeInTheDocument()
+    expect(getTemplateAssetsMock).toHaveBeenCalledTimes(1)
+  })
+
+  it('shows error and disables submit when assets request fails', async () => {
+    getTemplateAssetsMock.mockRejectedValue(new Error('network error'))
+
+    renderPage()
+
+    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 () => {
+    const user = userEvent.setup()
+    getTemplateAssetsMock.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' }],
+    })
+    createTemplateAndStartGenerationMock.mockResolvedValue({ id: '88' })
+
+    renderPage()
+
+    await screen.findByText('模特A')
+    await user.type(screen.getByLabelText('模板名称 *'), '云感长发')
+    await user.type(screen.getByLabelText('说明'), '说明内容')
+    await user.upload(
+      screen.getByLabelText('选择图片并预览'),
+      new File(['demo'], 'reference.jpg', { type: 'image/jpeg' }),
+    )
+    await user.click(screen.getAllByRole('button', { name: 'AI生成' })[0])
+
+    await waitFor(() => {
+      expect(createTemplateAndStartGenerationMock).toHaveBeenCalledWith(
+        expect.objectContaining({
+          modelImageUrl: 'https://img/model-a.jpg',
+          outfitImageUrl: 'https://img/cloth-a.jpg',
+          backgroundImageUrl: 'https://img/bg-a.jpg',
+        }),
+      )
+    })
+  })
+})
+```
+
+- [ ] **Step 2: 运行测试并确认当前失败**
+
+Run: `npm test -- src/pages/dashboard/templates/__tests__/TemplateFormPage.test.tsx`
+
+Expected:
+- FAIL,原因包括 `getTemplateAssets` 尚未导出
+- FAIL,原因包括页面仍然从 `hairstyleTemplateAssets` 读取静态素材
+- FAIL,原因包括页面尚未显示素材加载失败文案
+
+- [ ] **Step 3: 在 API contracts 中补素材类型**
+
+```ts
+export type TemplateAssetOption = {
+  id: string
+  name: string
+  url: string
+  configKey: string
+}
+
+export type TemplateAssets = {
+  models: TemplateAssetOption[]
+  clothes: TemplateAssetOption[]
+  backgrounds: TemplateAssetOption[]
+}
+```
+
+- [ ] **Step 4: 在模板 API 文件中新增素材读取方法**
+
+```ts
+import type {
+  GetTemplateByIdParams,
+  TemplateAssetOption,
+  TemplateAssets,
+  TemplateDashboardSummary,
+  TemplateDetailStats,
+  TemplateEntity,
+} from './contracts'
+
+export type {
+  GetTemplateByIdParams,
+  TemplateAssetOption,
+  TemplateAssets,
+  TemplateDashboardSummary,
+  TemplateDetailStats,
+  TemplateEntity,
+} from './contracts'
+
+export async function getTemplateAssets(): Promise<TemplateAssets> {
+  const data = await http.get<{
+    models?: TemplateAssetOption[]
+    clothes?: TemplateAssetOption[]
+    backgrounds?: TemplateAssetOption[]
+  }>('/templates/assets')
+
+  return {
+    models: data.models ?? [],
+    clothes: data.clothes ?? [],
+    backgrounds: data.backgrounds ?? [],
+  }
+}
+```
+
+- [ ] **Step 5: 在中日文文案中补充素材加载失败与空态文案**
+
+```json
+"templateFormPage": {
+  "loadingAssets": "正在加载素材...",
+  "assetLoadFailed": "素材加载失败,请刷新后重试",
+  "assetEmpty": "素材未配置完成,请检查服务器配置"
+}
+```
+
+```json
+"templateFormPage": {
+  "loadingAssets": "素材を読み込み中です...",
+  "assetLoadFailed": "素材の読み込みに失敗しました。再読み込みしてもう一度お試しください",
+  "assetEmpty": "素材設定が未完了です。サーバー設定を確認してください"
+}
+```
+
+- [ ] **Step 6: 重新运行定向测试,确认仍然只剩页面实现失败**
+
+Run: `npm test -- src/pages/dashboard/templates/__tests__/TemplateFormPage.test.tsx`
+
+Expected:
+- 仍然 FAIL
+- 错误收敛到 `TemplateFormPage` 仍未调用 `getTemplateAssets`
+
+- [ ] **Step 7: 提交**
+
+```bash
+git add frontend/src/pages/dashboard/templates/__tests__/TemplateFormPage.test.tsx frontend/src/services/api/contracts.ts frontend/src/services/api/templates.ts frontend/src/i18n/locales/zh/translation.json frontend/src/i18n/locales/ja/translation.json
+git commit -m "test: lock template form asset loading contract"
+```
+
+### Task 2: 改造模板新建页为服务端素材来源
+
+**Files:**
+- Modify: `frontend/src/pages/dashboard/templates/TemplateFormPage.tsx`
+- Test: `frontend/src/pages/dashboard/templates/__tests__/TemplateFormPage.test.tsx`
+
+**Interfaces:**
+- Consumes: `getTemplateAssets(): Promise<TemplateAssets>`, `createTemplateAndStartGeneration(input: CreateTemplateAndGenerateInput): Promise<TemplateEntity>`, `uploadMedia(file: File): Promise<{ url: string }>`
+- Produces: 页面级素材加载状态 `assetsLoading: boolean`, 页面级素材错误状态 `assetsError: string`, 由 API 返回值映射出的 `modelOptions`, `outfitOptions`, `backgroundOptions`
+
+- [ ] **Step 1: 在页面测试中再补一个“空数据禁用提交”的失败用例**
+
+```tsx
+it('disables submit when any asset group is empty', async () => {
+  getTemplateAssetsMock.mockResolvedValue({
+    models: [{ id: 'm1', name: '模特A', url: 'https://img/model-a.jpg', configKey: 'model-a' }],
+    clothes: [],
+    backgrounds: [{ id: 'b1', name: '背景A', url: 'https://img/bg-a.jpg', configKey: 'bg-a' }],
+  })
+
+  renderPage()
+
+  expect(await screen.findByRole('alert')).toHaveTextContent('素材未配置完成,请检查服务器配置')
+  expect(screen.getAllByRole('button', { name: 'AI生成' })[0]).toBeDisabled()
+})
+```
+
+- [ ] **Step 2: 运行测试,确认新用例失败**
+
+Run: `npm test -- src/pages/dashboard/templates/__tests__/TemplateFormPage.test.tsx`
+
+Expected:
+- FAIL,页面还没有“空组即禁用提交”的逻辑
+
+- [ ] **Step 3: 最小实现页面加载状态、错误状态与 API 映射**
+
+```tsx
+import React, { useEffect, useMemo, useState } from 'react'
+import { createTemplateAndStartGeneration, getTemplateAssets } from '../../../services/api/templates'
+import type { TemplateAssetOption } from '../../../services/api/contracts'
+
+type TemplateOption = {
+  id: string
+  label: string
+  url: string
+  previewUrl: string
+}
+
+function toTemplateOptions(items: TemplateAssetOption[]): TemplateOption[] {
+  return items.map((item) => ({
+    id: item.id,
+    label: item.name,
+    url: item.url,
+    previewUrl: item.url,
+  }))
+}
+
+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 [assetsLoading, setAssetsLoading] = useState(true)
+  const [assetsError, setAssetsError] = useState('')
+  const [selectedModelId, setSelectedModelId] = useState('')
+  const [selectedOutfitId, setSelectedOutfitId] = useState('')
+  const [selectedBackgroundId, setSelectedBackgroundId] = useState('')
+
+  useEffect(() => {
+    let cancelled = false
+
+    async function loadAssets() {
+      setAssetsLoading(true)
+      setAssetsError('')
+      try {
+        const data = await getTemplateAssets()
+        if (cancelled) return
+
+        const nextModels = toTemplateOptions(data.models)
+        const nextClothes = toTemplateOptions(data.clothes)
+        const nextBackgrounds = toTemplateOptions(data.backgrounds)
+
+        setModelOptions(nextModels)
+        setOutfitOptions(nextClothes)
+        setBackgroundOptions(nextBackgrounds)
+
+        if (nextModels.length === 0 || nextClothes.length === 0 || nextBackgrounds.length === 0) {
+          setAssetsError(pageCopy.assetEmpty || '素材未配置完成,请检查服务器配置')
+          return
+        }
+
+        setSelectedModelId((current) => current || nextModels[0].id)
+        setSelectedOutfitId((current) => current || nextClothes[0].id)
+        setSelectedBackgroundId((current) => current || nextBackgrounds[0].id)
+      } catch {
+        if (!cancelled) {
+          setAssetsError(pageCopy.assetLoadFailed || '素材加载失败,请刷新后重试')
+        }
+      } finally {
+        if (!cancelled) {
+          setAssetsLoading(false)
+        }
+      }
+    }
+
+    void loadAssets()
+
+    return () => {
+      cancelled = true
+    }
+  }, [pageCopy.assetEmpty, pageCopy.assetLoadFailed])
+
+  const hasReadyAssets = useMemo(
+    () =>
+      !assetsLoading &&
+      !assetsError &&
+      modelOptions.length > 0 &&
+      outfitOptions.length > 0 &&
+      backgroundOptions.length > 0,
+    [assetsLoading, assetsError, modelOptions, outfitOptions, backgroundOptions],
+  )
+```
+
+- [ ] **Step 4: 删除页面对本地静态素材的依赖,并把状态展示改为加载/失败/空态**
+
+```tsx
+- import {
+-   getHairstyleTemplateAssetsByCategory,
+- } from '../../../config/hairstyleTemplateAssets'
+
+{assetsLoading ? (
+  <p className="fs-14 text-medium-gray mb-20px" role="status">
+    {pageCopy.loadingAssets || '正在加载素材...'}
+  </p>
+) : null}
+
+{!assetsLoading && assetsError ? (
+  <p className="fs-14 text-red mb-20px" role="alert">
+    {assetsError}
+  </p>
+) : null}
+```
+
+- [ ] **Step 5: 保持原提交流程不变,只确保提交使用接口返回的 URL**
+
+```tsx
+const selectedModel = modelOptions.find((item) => item.id === selectedModelId)
+const selectedOutfit = outfitOptions.find((item) => item.id === selectedOutfitId)
+const selectedBackground = backgroundOptions.find((item) => item.id === selectedBackgroundId)
+
+if (!selectedModel || !selectedOutfit || !selectedBackground) {
+  throw new Error(pageCopy.validation?.assetsRequired || '请选择模特、服装和背景')
+}
+
+const template = await createTemplateAndStartGeneration({
+  name,
+  category,
+  status,
+  description,
+  tags: tags.split(',').map((tag) => tag.trim()).filter(Boolean),
+  referenceImage,
+  modelImageUrl: selectedModel.url,
+  outfitImageUrl: selectedOutfit.url,
+  backgroundImageUrl: selectedBackground.url,
+})
+```
+
+- [ ] **Step 6: 运行定向测试确认通过**
+
+Run: `npm test -- src/pages/dashboard/templates/__tests__/TemplateFormPage.test.tsx`
+
+Expected:
+- PASS
+- 覆盖素材加载成功、接口失败、空组禁用提交、提交使用服务端 URL
+
+- [ ] **Step 7: 运行类型检查**
+
+Run: `npm run typecheck`
+
+Expected:
+- PASS
+- 无新增 TypeScript 错误
+
+- [ ] **Step 8: 检查最近编辑文件诊断**
+
+Use: `GetDiagnostics` for:
+- `frontend/src/pages/dashboard/templates/TemplateFormPage.tsx`
+- `frontend/src/services/api/templates.ts`
+- `frontend/src/services/api/contracts.ts`
+
+Expected:
+- 无新增诊断;若有简单错误,先修复再继续
+
+- [ ] **Step 9: 提交**
+
+```bash
+git add frontend/src/pages/dashboard/templates/TemplateFormPage.tsx frontend/src/pages/dashboard/templates/__tests__/TemplateFormPage.test.tsx frontend/src/services/api/templates.ts frontend/src/services/api/contracts.ts frontend/src/i18n/locales/zh/translation.json frontend/src/i18n/locales/ja/translation.json
+git commit -m "feat: load template form assets from server config"
+```
+
+## Self-Review
+
+- **Spec coverage:** 已覆盖三组素材改为服务端接口、失败不回退静态数据、空组禁用提交、保持现有页面结构和旧提交接口不变、补充必要文案与定向测试。
+- **Placeholder scan:** 计划中没有 `TODO`、`TBD` 或“稍后实现”类占位语句;每个任务都给了具体文件、命令和代码片段。
+- **Type consistency:** `TemplateAssetOption` / `TemplateAssets` / `getTemplateAssets()` 在 Task 1 中定义,并在 Task 2 中按相同命名消费;页面内部映射结构 `TemplateOption` 只在 `TemplateFormPage` 内部使用,不向外暴露。

+ 225 - 0
docs/superpowers/specs/2026-06-28-prisma-v1-baseline-design.md

@@ -0,0 +1,225 @@
+# Prisma V1 Baseline Migration 设计
+
+## 1. 背景
+
+当前项目的 `backend/prisma/schema.prisma` 已经基本代表了当前数据库的稳定结构,可以视为第一版数据库模型。但仓库中的 `backend/prisma/migrations` 目录存在以下问题:
+
+- 历史 migration 数量很少,但依赖顺序已经不健康。
+- `20260628050000_add_customer_follow_content` 依赖 `customer` 表存在,却早于 `20260628120000_add_customer_table`。
+- `20260628173000_add_template_used_count` 是后补增量,而用户已经手动执行过对应 SQL。
+- 当前 migration 历史更像是开发过程痕迹,而不是可长期维护的基线。
+
+用户希望把现在这一版数据库正式收敛为“第一个稳定版本”,后续新功能再继续追加新的 migration。
+
+## 2. 目标
+
+本次调整后,需要满足以下目标:
+
+1. `schema.prisma` 继续作为当前稳定数据库结构的唯一事实来源。
+2. `backend/prisma/migrations` 目录只保留一条 `v1 baseline migration`。
+3. 这条 baseline migration 能用于新环境从零初始化数据库。
+4. 现有旧增量 migration 全部移除,不再保留开发阶段的历史痕迹。
+5. 后续数据库改动从 `v1 baseline` 继续追加新的增量 migration。
+
+## 3. 非目标
+
+以下内容不在本次范围内:
+
+1. 不保留旧 migration 的增量演进历史。
+2. 不再尝试修复旧 migration 的依赖顺序。
+3. 不修改当前数据库表结构语义,只做基线重置。
+4. 不做业务字段增删改设计。
+5. 不把 `schema.prisma` 回退到旧版本状态。
+
+## 4. 方案选择
+
+### 方案 A:保留旧 migration,只新增一个 baseline
+
+做法:
+
+- 保留当前 3 条旧 migration。
+- 再新增一条 `init_v1` 作为“以后认这个版本”的基线。
+
+优点:
+
+- 旧历史还在。
+
+缺点:
+
+- 目录仍然不干净。
+- 新成员会被旧历史误导。
+- 无法真正达到“第一版稳定基线”的目标。
+
+### 方案 B:删除全部旧 migration,只保留一个新的 baseline
+
+做法:
+
+- 删除当前所有旧 migration。
+- 基于当前稳定 `schema.prisma` 与数据库设计,新增一条 `init_v1`。
+- 后续所有数据库变更从该基线继续新增。
+
+优点:
+
+- 目录结构最干净。
+- 与“当前稳定结构就是 v1”完全一致。
+- 未来维护成本最低。
+
+缺点:
+
+- 旧增量历史不再保留。
+
+### 结论
+
+采用 **方案 B:删除全部旧 migration,只保留一个新的 baseline**。
+
+## 5. 目标目录结构
+
+调整后,`backend/prisma` 的目标结构如下:
+
+```text
+backend/prisma/
+  schema.prisma
+  migrations/
+    20260628_init_v1/
+      migration.sql
+```
+
+要求:
+
+- 不再保留:
+  - `20260628050000_add_customer_follow_content`
+  - `20260628120000_add_customer_table`
+  - `20260628173000_add_template_used_count`
+- `migrations` 目录内只保留一个 `init_v1`
+
+## 6. Baseline 定义
+
+### 6.1 基线语义
+
+`20260628_init_v1` 表示当前项目第一版稳定数据库结构。
+
+它不是增量修改脚本,而是一个完整初始化脚本,语义是:
+
+- 在一个空数据库中,直接创建当前版本所需的全部表结构、字段、索引和约束。
+
+### 6.2 基线内容来源
+
+baseline migration 的 SQL 内容必须与以下两处保持一致:
+
+1. `backend/prisma/schema.prisma`
+2. `backend/docs/数据库设计.md`
+
+其中:
+
+- `schema.prisma` 是代码层的事实来源。
+- `数据库设计.md` 用于校验字段注释、表注释和索引注释是否完整。
+
+### 6.3 基线必须包含的结构
+
+至少包含当前稳定版本中的全部核心表:
+
+- `hairstylist_user`
+- `customer`
+- `customer_treatment_record`
+- `ai_hair_simulation`
+- `ai_hair_simulation_task`
+- `hair_template`
+- `hair_template_make_task`
+- `system_config`
+
+并包含:
+
+- 当前稳定字段定义
+- 当前稳定索引
+- 当前稳定注释
+- `hair_template.used_count`
+
+## 7. 现有环境的处理策略
+
+### 7.1 已存在数据库的前提
+
+当前开发环境数据库已经存在,且用户已手动执行过部分 SQL。此时不能把新的 `init_v1` 当成一条要“重新执行建表”的 migration 直接运行到现有数据库上,否则会和已有表冲突。
+
+### 7.2 仓库层与数据库层分开处理
+
+本次设计把处理分成两层:
+
+- 仓库层:
+  - 清理旧 migration
+  - 新增 `20260628_init_v1/migration.sql`
+- 数据库层:
+  - 将当前数据库视为已经处于 `v1 baseline` 状态
+  - 用 Prisma 的 migration 状态对齐手段把这条 baseline 标记为已应用
+
+### 7.3 对齐原则
+
+原则上,现有开发库不需要重建,也不需要重新执行 baseline SQL。
+
+需要做的是:
+
+- 确保当前数据库结构与 `schema.prisma` 一致
+- 确保 Prisma 侧知道 `20260628_init_v1` 已经是“已应用状态”
+
+## 8. 实施约束
+
+### 8.1 不修改稳定结构
+
+本次重置 migration 历史时,不允许顺手修改当前数据库结构定义。`schema.prisma` 中现有的稳定结构应保持不变。
+
+### 8.2 保留注释完整性
+
+因为用户对 SQL 质量有明确要求,所以 baseline SQL 必须保留:
+
+- 表注释
+- 字段注释
+- 索引注释
+
+不能只生成“无注释的最小 SQL”。
+
+### 8.3 baseline 只作为起点
+
+一旦 `init_v1` 建立完成,后续新增字段或表时:
+
+- 不能回头修改 `init_v1`
+- 必须新增新的 migration
+
+除非未来再次明确决定整体重置数据库基线。
+
+## 9. 风险与规避
+
+### 风险 1:仓库中的 baseline 与当前数据库不一致
+
+如果 `migration.sql` 漏了字段或索引,新环境初始化会和当前开发环境不一致。
+
+规避方式:
+
+- 生成 baseline 后,对照 `schema.prisma` 和 `数据库设计.md` 做逐项校验。
+
+### 风险 2:现有环境误执行 baseline SQL
+
+如果在已有数据库上直接执行 `init_v1`,会因为表已存在而失败。
+
+规避方式:
+
+- 本次只把 baseline 作为仓库中的初始化基线。
+- 对现有环境采用“标记为已应用”的方式对齐,而不是重新执行完整建表 SQL。
+
+### 风险 3:后续开发者继续改 baseline
+
+如果未来继续把字段直接改进 `init_v1`,新的迁移历史会再次失真。
+
+规避方式:
+
+- 明确规定 `init_v1` 只代表第一版稳定结构。
+- 后续所有变更必须新增新的 migration。
+
+## 10. 验收标准
+
+本次完成后,应满足以下验收标准:
+
+1. `backend/prisma/migrations` 中只剩一个 `20260628_init_v1` 目录。
+2. `migration.sql` 能完整表达当前稳定数据库结构。
+3. `migration.sql` 包含 `used_count` 等当前稳定字段。
+4. `schema.prisma` 与 baseline SQL 一致。
+5. 旧 3 条 migration 不再保留。
+6. 后续数据库开发可以在 `v1 baseline` 之上继续新增 migration。

+ 135 - 0
docs/superpowers/specs/2026-06-28-template-form-config-assets-minimal-design.md

@@ -0,0 +1,135 @@
+# 模板新建页素材数据源切换最小设计
+
+## 背景
+
+当前主仓库 `main` 分支上的 [TemplateFormPage.tsx](file:///Users/yuchen/Documents/hairstylist/frontend/src/pages/dashboard/templates/TemplateFormPage.tsx) 仍然使用本地静态素材:
+
+- `选择AI模特`
+- `选择服装`
+- `选择背景`
+
+这三块目前通过 `getHairstyleTemplateAssetsByCategory()` 从本地 `hairstyleTemplateAssets` 读取,而不是从服务端的 `config` 表读取。
+
+用户本次要求只做最小改动:
+
+- 只替换这三个区块的数据来源
+- 其他页面结构和旧提交流程先不改
+- 接口失败时不回退本地静态数据
+
+## 目标
+
+- 让模板新建页中的模特、服装、背景三组素材改为从服务端配置接口读取
+- 前端展示仍保持现有卡片式 UI,不做结构重写
+- 如果配置接口失败或返回为空,页面明确显示异常状态,并阻止继续提交
+
+## 非目标
+
+- 不改当前 `TemplateFormPage` 的其余表单结构
+- 不改当前提交接口 `createTemplateAndStartGeneration()`
+- 不在这次改动中接入模板制作任务页
+- 不保留本地静态素材作为降级兜底
+
+## 推荐方案
+
+采用最小替换方案:
+
+1. 在前端模板 API 层新增一个读取模板素材的方法
+2. 页面初始化时请求服务端素材接口
+3. 将返回结果映射为当前页面已有卡片 UI 所需的数据结构
+4. 删除页面对本地 `hairstyleTemplateAssets` 的依赖
+5. 当素材未加载成功时,显示错误提示并禁用提交按钮
+
+这样可以只清理用户明确指出的旧逻辑,不扩大到整条模板创建链路。
+
+## 接口与数据约定
+
+前端读取服务端模板素材接口,返回三组数据:
+
+- `models`
+- `clothes`
+- `backgrounds`
+
+每个选项至少包含:
+
+- `id`
+- `name`
+- `url`
+- `configKey`
+
+服务端数据来源约定如下:
+
+- `config_group = 'ai-model'` -> `models`
+- `config_group = 'clothing'` -> `clothes`
+- `config_group = 'background'` -> `backgrounds`
+
+前端页面映射规则如下:
+
+- `id` 直接作为选中值
+- `name` 映射为卡片标题
+- `url` 映射为当前卡片图片地址
+
+## 页面行为
+
+### 1. 初始化加载
+
+页面挂载时请求一次模板素材接口。
+
+成功时:
+
+- 将三组接口结果写入本地状态
+- 默认选中每组的第一项
+
+失败时:
+
+- 不回退到本地静态素材
+- 显示“素材加载失败”类提示
+- 禁用顶部和底部的提交按钮
+
+### 2. 空数据处理
+
+如果接口返回成功但某一组为空,也视为“资源未准备完成”:
+
+- 显示提示文案
+- 页面不崩溃
+- 禁用提交按钮
+
+### 3. 提交流程
+
+本次不改旧提交流程,只替换素材来源。
+
+因此提交时仍然:
+
+- 上传参考图
+- 读取当前选中的模特、服装、背景 URL
+- 调用现有 `createTemplateAndStartGeneration()`
+
+也就是说,提交 payload 中使用的三组 URL 仍来自当前选中的服务端配置素材,而不是本地静态素材。
+
+## 影响文件
+
+- 修改 [TemplateFormPage.tsx](file:///Users/yuchen/Documents/hairstylist/frontend/src/pages/dashboard/templates/TemplateFormPage.tsx)
+  - 删除对 `hairstyleTemplateAssets` 的依赖
+  - 新增页面级素材加载状态、错误状态和接口映射
+- 修改 [templates.ts](file:///Users/yuchen/Documents/hairstylist/frontend/src/services/api/templates.ts)
+  - 新增读取模板素材的 API 方法
+- 修改 [contracts.ts](file:///Users/yuchen/Documents/hairstylist/frontend/src/services/api/contracts.ts)
+  - 新增模板素材类型定义
+- 可选修改 [translation.json](file:///Users/yuchen/Documents/hairstylist/frontend/src/i18n/locales/zh/translation.json) / [translation.json](file:///Users/yuchen/Documents/hairstylist/frontend/src/i18n/locales/ja/translation.json)
+  - 如果现有提示文案不够准确,则补充“素材加载失败”相关文案
+
+## 测试策略
+
+本次测试只覆盖真实回归风险:
+
+1. 页面能从接口读取三组素材并渲染
+2. 页面不再依赖本地 `hairstyleTemplateAssets`
+3. 接口失败时显示错误提示并禁用提交
+4. 选择素材后提交时使用接口返回的 URL
+
+不增加与本次目标无关的重构测试。
+
+## 风险与边界
+
+- 因为当前页面仍保留旧提交接口,所以这次只是“清理三组素材来源”,不是整页真实链路切换
+- 如果后续要切到模板制作任务流,应在下一次改动中再统一收敛 `TemplateFormPage`
+- 本次明确不做静态兜底,因此如果服务端配置没准备好,页面将无法提交,这是符合用户当前要求的

+ 383 - 0
docs/superpowers/specs/2026-06-28-template-usage-summary-design.md

@@ -0,0 +1,383 @@
+# 模板使用量与顶部统计设计
+
+## 1. 背景
+
+当前“我的发型模板”页面已经具备模板列表卡片和顶部 4 个统计卡片的 UI 骨架,但这部分数据还不是真实业务数据:
+
+- 模板列表接口 `GET /api/v1/templates` 当前返回的 `usedCount` 仍是固定值 `0`。
+- 前端 `TemplateListPage` 中顶部统计使用的是本地默认值,且请求的 `/api/v1/stylist/dashboard/template-summary` 在后端并没有真实实现。
+- 用户希望在当前模板表中新增“使用量”字段,用于支撑:
+  - 列表卡片中的“使用 x 次”
+  - 顶部统计中的“本周使用”
+  - 其余 4 个统计卡片的真实展示
+
+本轮目标是在尽量不改动现有模板列表 UI 结构的前提下,把模板使用量和顶部统计接成真实、可持续维护的数据链路。
+
+## 2. 目标
+
+本轮交付后,需要满足以下目标:
+
+1. `hair_template` 表新增累计使用量字段。
+2. 模板列表中的 `usedCount` 返回真实值,卡片展示“使用 x 次”。
+3. “我的发型模板”顶部 4 个统计卡片接入真实后端数据。
+4. “本周使用”按本周增量统计,而不是累计值。
+5. AI 发型模拟成功后,相关模板的累计使用量自动递增。
+
+## 3. 非目标
+
+以下内容不在本次范围内:
+
+1. 不补历史数据回填。当前项目没有历史数据,本轮无需做初始化聚合脚本。
+2. 不新增第五个或更多顶部统计卡片。
+3. 不调整模板列表已确认过的 UI 结构、卡片布局和交互。
+4. 不改变“使用量”的业务含义,本轮统一定义为“成功用于 AI 发型模拟的次数”。
+5. 不引入新的使用日志表。
+
+## 4. 方案选择
+
+### 方案 A:仅存累计使用量,顶部“本周使用”也直接显示累计值
+
+做法:
+
+- 只在 `hair_template` 增加累计字段。
+- 列表卡片和顶部“本周使用”都读累计值。
+
+优点:
+
+- 实现最简单。
+
+缺点:
+
+- “本周使用”语义错误,不符合用户确认的口径。
+
+### 方案 B:查询时临时聚合使用次数
+
+做法:
+
+- 不在 `hair_template` 落累计字段。
+- 列表和顶部统计都在查询时实时聚合 `ai_hair_simulation_task`。
+
+优点:
+
+- 数据完全从事实表推导。
+
+缺点:
+
+- 列表接口每次查询都要聚合,复杂度和查询成本更高。
+- 不符合用户已明确提出的“在模板表新增字段”要求。
+
+### 方案 C:模板表存累计使用量,顶部“本周使用”从任务表做周增量统计
+
+做法:
+
+- 在 `hair_template` 增加 `used_count`,保存累计使用量。
+- AI 模拟任务成功时,对对应模板 `used_count + 1`。
+- 顶部“本周使用”单独从 `ai_hair_simulation_task` 统计本周成功任务数。
+
+优点:
+
+- 列表卡片直接读累计值,查询简单。
+- 顶部“本周使用”保留正确的周增量语义。
+- 改动范围可控,符合当前系统结构。
+
+缺点:
+
+- 需要同时维护累计字段和周统计逻辑。
+
+### 结论
+
+采用 **方案 C:模板表存累计使用量,顶部“本周使用”从任务表做周增量统计**。
+
+## 5. 业务口径
+
+### 5.1 模板累计使用量
+
+- 落库字段:`hair_template.used_count`
+- 含义:该模板累计被成功用于 AI 发型模拟的次数
+- 消费位置:
+  - 模板列表卡片中的“使用 x 次”
+  - 未来如有模板详情统计,也可直接复用
+
+### 5.2 本周使用
+
+- 含义:当前自然周内,属于当前美发师模板的 AI 发型模拟成功任务总数
+- 统计来源:`ai_hair_simulation_task`
+- 判断条件:
+  - `task_status = 'SUCCESSES'`
+  - `task_finish_time` 落在当前自然周范围内
+  - `is_deleted = 0`
+  - `template_id` 能关联到当前美发师自己的 `hair_template`
+
+说明:
+
+- 统计单位是“成功任务数”,不是“去重后的模板数”。
+- 同一个模板在本周内成功使用多次,应累计多次。
+
+### 5.3 顶部 4 个统计卡片
+
+继续沿用当前页面已有的 4 个统计项,不改 UI 结构:
+
+1. `total`
+   - 含义:当前美发师未删除模板总数
+2. `published`
+   - 含义:`status = 'PUBLIC'` 的未删除模板数
+3. `weeklyUsed`
+   - 含义:本周成功使用次数
+4. `draft`
+   - 含义:`status = 'DRAFT'` 的未删除模板数
+
+## 6. 数据库设计
+
+### 6.1 表结构
+
+在 `hair_template` 新增字段:
+
+```sql
+used_count int unsigned not null default 0 comment '模板累计使用次数'
+```
+
+要求:
+
+- 默认为 `0`
+- 非空
+- 保留字段注释
+
+### 6.2 Prisma 模型
+
+在 `HairTemplate` 模型新增:
+
+```prisma
+usedCount Int @default(0) @map("used_count") @db.UnsignedInt
+```
+
+### 6.3 数据迁移策略
+
+- 本轮仅新增字段,不做历史数据回填。
+- 新建模板默认 `used_count = 0`。
+- 上线后由真实的 AI 模拟成功事件持续累积。
+
+## 7. 后端设计
+
+### 7.1 模板列表接口
+
+继续使用现有接口:
+
+```http
+GET /api/v1/templates
+```
+
+返回结构保持不变,但 `usedCount` 改为真实映射:
+
+```ts
+{
+  items: Array<{
+    id: string
+    name: string
+    category: 'LONG' | 'MEDIUM' | 'SHORT' | 'DYE'
+    status: 'DRAFT' | 'PUBLIC'
+    description: string
+    tags: string[]
+    image: string
+    makeTaskId?: string
+    makeTaskStatus?: string
+    usedCount: number
+  }>
+  total: number
+  page: number
+  pageSize: number
+}
+```
+
+实现要求:
+
+- `usedCount` 直接来自 `hair_template.used_count`
+- 不再返回固定值 `0`
+
+### 7.2 顶部统计接口
+
+新增模板模块内的真实统计接口:
+
+```http
+GET /api/v1/templates/summary
+```
+
+返回结构:
+
+```json
+{
+  "total": 24,
+  "published": 18,
+  "weeklyUsed": 6,
+  "draft": 4
+}
+```
+
+说明:
+
+- 不再依赖不存在的 `/api/v1/stylist/dashboard/template-summary`
+- 接口归属放在模板模块内,方便和模板列表、模板编辑共用权限与领域边界
+
+### 7.3 统计实现
+
+`TemplatesService` 新增 `getSummary(stylistId)` 方法,返回:
+
+- `total`
+- `published`
+- `weeklyUsed`
+- `draft`
+
+建议实现方式:
+
+1. `total`
+   - `hair_template.count`
+   - 条件:`hairstylist_id = 当前美发师`,`is_deleted = 0`
+2. `published`
+   - `hair_template.count`
+   - 条件加上 `status = 'PUBLIC'`
+3. `draft`
+   - `hair_template.count`
+   - 条件加上 `status = 'DRAFT'`
+4. `weeklyUsed`
+   - 统计 `ai_hair_simulation_task` 中本周成功任务数
+   - 为避免把其他人的模板统计进来,需要按模板归属过滤
+
+`weeklyUsed` 的推荐查询思路:
+
+1. 先计算当前自然周的起止时间
+2. 在 `ai_hair_simulation_task` 中筛选:
+   - `taskStatus = 'SUCCESSES'`
+   - `taskFinishTime` 在本周范围内
+   - `isDeleted = 0`
+3. 通过 `templateId` 关联 `hair_template`
+4. 仅统计:
+   - `hair_template.hairstylist_id = 当前美发师`
+   - `hair_template.is_deleted = 0`
+
+如果 Prisma 直接跨表过滤不够顺手,可以采用两段式最小实现:
+
+1. 先取当前美发师所有未删除模板 ID
+2. 再用这些模板 ID 统计本周成功任务数
+
+### 7.4 累计使用量更新
+
+累计值的更新触发点放在“AI 发型模拟任务成功”的链路里。
+
+当前系统已经存在 `ai_hair_simulation_task` 的状态刷新和结果落库逻辑,因此本轮在任务状态首次转为 `SUCCESSES` 时,补充:
+
+```ts
+hair_template.used_count += 1
+```
+
+要求:
+
+- 只在任务真正从未成功状态转为 `SUCCESSES` 时递增一次
+- 避免重复轮询、重复刷新导致同一个任务重复累加
+
+推荐做法:
+
+1. 在更新任务状态前读取旧状态
+2. 只有当:
+   - 旧状态不是 `SUCCESSES`
+   - 新状态是 `SUCCESSES`
+   时,才对对应模板执行一次递增
+
+说明:
+
+- 触发位置应靠近 `SimulationsService` 中任务刷新成功的逻辑,而不是放在列表查询时临时聚合。
+- 这样可以保证模板表里的累计值始终可直接复用。
+
+## 8. 前端设计
+
+### 8.1 模板列表页
+
+`TemplateListPage` 保持现有布局与交互,数据层做以下调整:
+
+- 页面初始化时请求真实的模板 summary
+- 顶部 4 个统计卡片展示真实值
+- 列表卡片继续读 `template.usedCount`
+
+### 8.2 summary 接口调整
+
+前端 `getTemplateDashboardSummary()` 当前请求:
+
+```ts
+/stylist/dashboard/template-summary
+```
+
+本轮改为请求:
+
+```ts
+/templates/summary
+```
+
+### 8.3 初始值与失败态
+
+当前前端存在默认假数据:
+
+```ts
+{
+  total: 24,
+  published: 18,
+  weeklyUsed: 6,
+  draft: 4,
+}
+```
+
+本轮改为:
+
+- 初始值使用全 `0`
+- 请求失败时回退到全 `0`
+
+原因:
+
+- 避免页面出现和真实业务无关的假统计
+- 保持失败时的 UI 稳定,不额外扩大本轮范围
+
+## 9. 测试设计
+
+### 9.1 后端单测
+
+更新 `templates.service.spec.ts`,至少覆盖:
+
+1. 模板列表返回真实 `usedCount`
+2. `getSummary()` 返回:
+   - `total`
+   - `published`
+   - `weeklyUsed`
+   - `draft`
+3. AI 模拟任务首次成功时会触发对应模板 `used_count + 1`
+4. 同一个任务重复刷新为成功时不会重复累加
+
+### 9.2 后端 E2E
+
+更新 `templates.e2e-spec.ts`,至少覆盖:
+
+1. `GET /api/v1/templates` 返回真实 `usedCount`
+2. `GET /api/v1/templates/summary` 返回 4 个真实统计字段
+
+如现有 E2E 框架不便直接覆盖“模拟任务成功触发模板使用量递增”,可优先用单测锁住该业务规则。
+
+### 9.3 前端测试
+
+更新 `TemplateListPage.test.tsx`,至少覆盖:
+
+1. 顶部 4 个统计卡片展示真实 summary
+2. 列表卡片中的“使用 x 次”展示接口返回值
+3. 不再依赖假默认 summary
+4. summary 请求失败时展示 `0`
+
+## 10. 风险与约束
+
+1. `weeklyUsed` 依赖 `task_finish_time`,因此只有成功完成并记录完成时间的任务才会被统计。
+2. `template_id` 在 `ai_hair_simulation_task` 中是字符串,需要在统计时注意与 `hair_template.id` 的类型转换。
+3. 若未来存在“删除模板但保留历史任务”的场景,本轮设计会按当前规则忽略已删除模板对应的周统计,这与当前“只看未删除模板业务数据”的页面语义一致。
+
+## 11. 交付结果
+
+本轮完成后,系统应具备以下结果:
+
+1. 模板表具备 `used_count` 字段
+2. 模板列表返回真实使用量
+3. 顶部 4 个统计卡片展示真实数据
+4. “本周使用”按本周增量统计
+5. AI 发型模拟成功后自动累计模板使用量

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

+ 125 - 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,30 +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": "保存に失敗しました"
@@ -385,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 で実装します。",

+ 125 - 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,30 +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": "保存失败"
@@ -385,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>
+  )
+}

+ 209 - 75
frontend/src/pages/dashboard/templates/TemplateFormPage.tsx

@@ -1,14 +1,16 @@
-import React, { useState } from 'react'
+import React, { useEffect, useMemo, useState } from 'react'
 import { Link } from 'react-router-dom'
 import { Helmet } from 'react-helmet-async'
 import { usePageContext, usePreviewUrl } from '../../../shared/pageContext'
 import { DashboardLayout } from '../../../layouts/DashboardLayout'
-import { createTemplateAndStartGeneration } from '../../../services/api/templates'
+import {
+  createTemplateMakeTask,
+  getTemplateAssets,
+  type TemplateAssetOption,
+  type TemplateAssets,
+} from '../../../services/api/templates'
 import { uploadMedia } from '../../../services/api/media'
 import { ApiError } from '../../../services/http/client'
-import {
-  getHairstyleTemplateAssetsByCategory,
-} from '../../../config/hairstyleTemplateAssets'
 
 function getSubmitErrorMessage(err: unknown, fallback: string) {
   if (
@@ -22,30 +24,96 @@ function getSubmitErrorMessage(err: unknown, fallback: string) {
   return err instanceof Error ? err.message : fallback
 }
 
+type AssetSource = 'preset' | 'upload'
+
+const EMPTY_ASSETS: TemplateAssets = {
+  models: [],
+  clothes: [],
+  backgrounds: [],
+}
+
 export function TemplateFormPage() {
   const { locale, navigate, path, copy } = usePageContext()
   const pageCopy = copy.templateFormPage
-  const modelOptions = getHairstyleTemplateAssetsByCategory('model')
-  const outfitOptions = getHairstyleTemplateAssetsByCategory('outfit')
-  const backgroundOptions = getHairstyleTemplateAssetsByCategory('background')
-  const hasReadyAssets =
-    modelOptions.length > 0 &&
-    outfitOptions.length > 0 &&
-    backgroundOptions.length > 0
+  const [assets, setAssets] = useState<TemplateAssets>(EMPTY_ASSETS)
+  const [assetsLoading, setAssetsLoading] = useState(true)
+  const [assetsError, setAssetsError] = useState('')
   const [name, setName] = useState('')
   const [category, setCategory] = useState('Long')
   const [status, setStatus] = useState('Draft')
   const [description, setDescription] = useState('')
   const [tags, setTags] = useState('')
   const [referenceFile, setReferenceFile] = useState<File | null>(null)
-  const [selectedModelId, setSelectedModelId] = useState(modelOptions[0]?.id ?? '')
-  const [selectedOutfitId, setSelectedOutfitId] = useState(outfitOptions[0]?.id ?? '')
-  const [selectedBackgroundId, setSelectedBackgroundId] = useState(backgroundOptions[0]?.id ?? '')
+  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')
   const [error, setError] = useState('')
 
+  useEffect(() => {
+    let cancelled = false
+
+    const loadAssets = async () => {
+      setAssetsLoading(true)
+      setAssetsError('')
+
+      try {
+        const data = await getTemplateAssets()
+        if (cancelled) return
+
+        setAssets(data)
+        setSelectedModelId((current) => current || data.models[0]?.id || '')
+        setSelectedOutfitId((current) => current || data.clothes[0]?.id || '')
+        setSelectedBackgroundId((current) => current || data.backgrounds[0]?.id || '')
+
+        if (data.models.length === 0 || data.clothes.length === 0 || data.backgrounds.length === 0) {
+          setAssetsError(pageCopy.assetEmpty || '素材未配置完成,请检查服务器配置')
+        }
+      } catch {
+        if (cancelled) return
+        setAssets(EMPTY_ASSETS)
+        setAssetsError(pageCopy.assetLoadFailed || '素材加载失败,请刷新后重试')
+      } finally {
+        if (!cancelled) {
+          setAssetsLoading(false)
+        }
+      }
+    }
+
+    void loadAssets()
+
+    return () => {
+      cancelled = true
+    }
+  }, [pageCopy.assetEmpty, pageCopy.assetLoadFailed])
+
+  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'
       ? pageCopy.submitting?.uploading || '正在上传参考图...'
@@ -64,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
 
-      if (!selectedModel || !selectedOutfit || !selectedBackground) {
-        throw new Error(pageCopy.validation?.assetsRequired || '请选择模特、服装和背景')
+      let modelUrl = selectedModel?.url ?? ''
+      let clothesUrl = selectedOutfit?.url ?? ''
+      let backgroundUrl = selectedBackground?.url ?? ''
+
+      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 {
@@ -144,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>
@@ -157,25 +253,41 @@ export function TemplateFormPage() {
             <div className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px mt-4">
               <span className="fs-14 fw-700 text-base-color text-uppercase">{pageCopy.model.badge}</span>
               <h2 className="fw-700 text-dark-gray fs-26 mb-25px">{pageCopy.model.title}</h2>
-              {!hasReadyAssets ? (
+              {assetsLoading ? (
                 <p className="fs-14 text-medium-gray mb-20px" role="status">
-                  {pageCopy.assetPreparationHint || '资源未准备完成,请先执行资源上传脚本'}
+                  {pageCopy.loadingAssets || '正在加载素材...'}
                 </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>
-                ))}
+              {!assetsLoading && assetsError ? (
+                <p className="fs-14 text-red mb-20px" role="alert">
+                  {assetsError}
+                </p>
+              ) : null}
+              <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">
@@ -183,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>
 
@@ -204,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>

+ 247 - 68
frontend/src/pages/dashboard/templates/TemplateListPage.tsx

@@ -13,26 +13,70 @@ import { usePageContext } from '../../../shared/pageContext'
 import { DashboardLayout } from '../../../layouts/DashboardLayout'
 
 const initialSummary: TemplateDashboardSummary = {
-  total: 24,
-  published: 18,
-  weeklyUsed: 6,
-  draft: 4,
+  total: 0,
+  published: 0,
+  weeklyUsed: 0,
+  draft: 0,
 }
 
 const FALLBACK_TEMPLATE_IMAGE = '/images/aitori-simulation-result.png'
+const PAGE_SIZE = 8
 
 export function TemplateListPage() {
   const { locale, path, copy } = usePageContext()
   const [items, setItems] = useState<TemplateEntity[]>([])
   const [summary, setSummary] = useState<TemplateDashboardSummary>(initialSummary)
   const [selectedIds, setSelectedIds] = useState<string[]>([])
+  const [query, setQuery] = useState('')
+  const [category, setCategory] = useState('')
+  const [status, setStatus] = useState('')
+  const [page, setPage] = useState(1)
+  const [pageSize] = useState(PAGE_SIZE)
+  const [total, setTotal] = useState(0)
+  const [loading, setLoading] = useState(false)
   const pageCopy = copy.templateListPage
 
   useEffect(() => {
-    listTemplates().then(setItems).catch(() => setItems([]))
     getTemplateDashboardSummary().then(setSummary).catch(() => setSummary(initialSummary))
   }, [])
 
+  useEffect(() => {
+    let cancelled = false
+
+    const load = async () => {
+      setLoading(true)
+      try {
+        const result = await listTemplates({
+          q: query.trim(),
+          category,
+          status,
+          page,
+          pageSize,
+        })
+
+        if (cancelled) return
+        setItems(result.items)
+        setTotal(result.total)
+        setSelectedIds((current) => current.filter((id) => result.items.some((item) => item.id === id)))
+      } catch {
+        if (cancelled) return
+        setItems([])
+        setTotal(0)
+        setSelectedIds([])
+      } finally {
+        if (!cancelled) {
+          setLoading(false)
+        }
+      }
+    }
+
+    void load()
+
+    return () => {
+      cancelled = true
+    }
+  }, [category, page, pageSize, query, status])
+
   const toggleSelect = (templateId: string) => {
     setSelectedIds((current) =>
       current.includes(templateId)
@@ -41,10 +85,26 @@ export function TemplateListPage() {
     )
   }
 
+  const allCurrentPageSelected =
+    items.length > 0 && items.every((item) => selectedIds.includes(item.id))
+
+  const handleToggleSelectAllCurrentPage = () => {
+    const currentPageIds = items.map((item) => item.id)
+
+    setSelectedIds((current) => {
+      if (allCurrentPageSelected) {
+        return current.filter((id) => !currentPageIds.includes(id))
+      }
+
+      return Array.from(new Set([...current, ...currentPageIds]))
+    })
+  }
+
   const handleDelete = async (templateId: string) => {
     await deleteTemplate(templateId)
     setItems((current) => current.filter((item) => item.id !== templateId))
     setSelectedIds((current) => current.filter((id) => id !== templateId))
+    setTotal((current) => Math.max(0, current - 1))
   }
 
   const handleBulkDelete = async () => {
@@ -54,21 +114,37 @@ export function TemplateListPage() {
     await deleteTemplates(deletingIds)
     setItems((current) => current.filter((item) => !deletingIds.includes(item.id)))
     setSelectedIds([])
+    setTotal((current) => Math.max(0, current - deletingIds.length))
   }
 
   const getGenerationBadge = (template: TemplateEntity) => {
-    if (template.generationStatus === 'pending') {
+    if (template.makeTaskStatus === 'WAIT' || template.generationStatus === 'pending') {
       return pageCopy.statusBadgeMap?.pending || '排队中'
     }
-    if (template.generationStatus === 'processing') {
+    if (
+      template.makeTaskStatus === 'PROCESSING' ||
+      template.makeTaskStatus === 'STEP1_SUCCESS' ||
+      template.generationStatus === 'processing'
+    ) {
       return pageCopy.statusBadgeMap?.processing || '生成中'
     }
-    if (template.generationStatus === 'failed') {
+    if (template.makeTaskStatus === 'FAILED' || template.generationStatus === 'failed') {
       return pageCopy.statusBadgeMap?.failed || '生成失败'
     }
     return null
   }
 
+  const getTemplateTarget = (template: TemplateEntity) => {
+    if (template.makeTaskId) {
+      return path(`/templates/tasks/${template.makeTaskId}`)
+    }
+
+    return null
+  }
+
+  const totalPages = Math.max(1, Math.ceil(total / pageSize))
+  const pages = Array.from({ length: totalPages }, (_, index) => index + 1)
+
   return (
     <DashboardLayout>
       <Helmet>
@@ -91,31 +167,55 @@ export function TemplateListPage() {
             </div>
           </div>
 
-          <div className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-25px mb-4">
-            <div className="row g-3 align-items-end">
+          <div className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-25px mb-4 template-list-filter-panel">
+            <div className="row g-3 align-items-end template-list-filter-grid">
               <div className="col-lg-5">
                 <label htmlFor="template-search" className="fw-700 text-dark-gray mb-10px">{pageCopy.filters.searchLabel}</label>
                 <div className="customer-search-field">
                   <i className="feather icon-feather-search"></i>
-                  <input id="template-search" type="search" className="form-control" placeholder={pageCopy.filters.searchPlaceholder} />
+                  <input
+                    id="template-search"
+                    type="search"
+                    className="form-control"
+                    placeholder={pageCopy.filters.searchPlaceholder}
+                    value={query}
+                    onChange={(event) => {
+                      setQuery(event.target.value)
+                      setPage(1)
+                    }}
+                  />
                 </div>
               </div>
-              <div className="col-lg-3 col-md-4">
+              <div className="col-lg-3 col-md-6">
                 <label htmlFor="category-filter" className="fw-700 text-dark-gray mb-10px">{pageCopy.filters.categoryLabel}</label>
-                <select id="category-filter" className="form-select customer-filter-control">
-                  {pageCopy.filters.categoryOptions.map((option: string) => <option key={option}>{option}</option>)}
-                </select>
-              </div>
-              <div className="col-lg-2 col-md-4">
-                <label htmlFor="purpose-filter" className="fw-700 text-dark-gray mb-10px">{pageCopy.filters.purposeLabel}</label>
-                <select id="purpose-filter" className="form-select customer-filter-control">
-                  {pageCopy.filters.purposeOptions.map((option: string) => <option key={option}>{option}</option>)}
+                <select
+                  id="category-filter"
+                  className="form-select customer-filter-control"
+                  value={category}
+                  onChange={(event) => {
+                    setCategory(event.target.value)
+                    setPage(1)
+                  }}
+                >
+                  {pageCopy.filters.categoryOptions.map((option: { value: string; label: string }) => (
+                    <option key={option.value} value={option.value}>{option.label}</option>
+                  ))}
                 </select>
               </div>
-              <div className="col-lg-2 col-md-4">
+              <div className="col-lg-3 col-md-6">
                 <label htmlFor="status-filter" className="fw-700 text-dark-gray mb-10px">{pageCopy.filters.statusLabel}</label>
-                <select id="status-filter" className="form-select customer-filter-control">
-                  {pageCopy.filters.statusOptions.map((option: string) => <option key={option}>{option}</option>)}
+                <select
+                  id="status-filter"
+                  className="form-select customer-filter-control"
+                  value={status}
+                  onChange={(event) => {
+                    setStatus(event.target.value)
+                    setPage(1)
+                  }}
+                >
+                  {pageCopy.filters.statusOptions.map((option: { value: string; label: string }) => (
+                    <option key={option.value} value={option.value}>{option.label}</option>
+                  ))}
                 </select>
               </div>
             </div>
@@ -132,13 +232,23 @@ export function TemplateListPage() {
 
       <section className="pt-0">
         <div className="container">
-          <div className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px">
-            <div className="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-25px">
+          <div className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px template-list-results-panel">
+            <div className="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-25px template-list-results-header">
               <div>
                 <span className="fs-14 fw-700 text-base-color text-uppercase">{pageCopy.resultBadge}</span>
-                <h2 className="fw-700 text-medium-gray fs-26 mb-0">{pageCopy.resultTitle.replace('{{count}}', String(items.length))}</h2>
+                <h2 className="fw-700 text-medium-gray fs-26 mb-0">{pageCopy.resultTitle.replace('{{count}}', String(total))}</h2>
               </div>
-              <div className="d-flex align-items-center gap-2">
+              <div className="d-flex align-items-center gap-2 template-list-results-actions">
+                <button
+                  type="button"
+                  onClick={handleToggleSelectAllCurrentPage}
+                  disabled={items.length === 0}
+                  className="btn btn-small btn-rounded btn-transparent-light-gray border border-1 border-color-dark-gray fw-700"
+                >
+                  {allCurrentPageSelected
+                    ? pageCopy.deselectAll || '取消全选'
+                    : pageCopy.selectAll || '全选'}
+                </button>
                 <button
                   type="button"
                   onClick={handleBulkDelete}
@@ -152,55 +262,124 @@ export function TemplateListPage() {
             </div>
 
             <div className="template-grid">
-              {items.map((template) => (
-                <article key={template.id} className="template-card">
-                  <div className="d-flex align-items-center justify-content-between gap-2 p-15px pb-0">
-                    <label className="d-flex align-items-center gap-2 fs-14 text-medium-gray">
-                      <input
-                        type="checkbox"
-                        checked={selectedIds.includes(template.id)}
-                        aria-label={`${pageCopy.select || '选择'}${template.name}`}
-                        onChange={() => toggleSelect(template.id)}
-                      />
-                      <span>{pageCopy.select || '选择'}</span>
-                    </label>
-                    <button
-                      type="button"
-                      className="btn btn-small btn-rounded btn-transparent-light-gray border border-1 border-color-dark-gray fw-700"
-                      aria-label={`${pageCopy.delete || '删除'}${template.name}`}
-                      onClick={() => void handleDelete(template.id)}
-                    >
-                      {pageCopy.delete || '删除'}
-                    </button>
-                  </div>
-                  <Link to={path(`/templates/${template.id}`)} className="d-block text-decoration-none">
-                    <img src={template.image || FALLBACK_TEMPLATE_IMAGE} alt={template.name} />
-                    <div className="template-card-body">
-                      <div className="template-card-meta">
-                        <span>{pageCopy.categoryMap[template.category as keyof typeof pageCopy.categoryMap] || template.category}</span>
-                        <span>{pageCopy.statusMap[template.status as keyof typeof pageCopy.statusMap] || template.status}</span>
+              {items.map((template) => {
+                const target = getTemplateTarget(template)
+                const generationBadge = getGenerationBadge(template)
+                const cardContent = (
+                  <>
+                    <div className="template-card-image">
+                      {target ? (
+                        <Link
+                          to={target}
+                          className="template-card-image-link"
+                          aria-label={`${pageCopy.detail || '查看任务'}图片`}
+                        >
+                          <img src={template.image || FALLBACK_TEMPLATE_IMAGE} alt={template.name} />
+                        </Link>
+                      ) : (
+                        <div className="template-card-image-link" aria-disabled="true">
+                          <img src={template.image || FALLBACK_TEMPLATE_IMAGE} alt={template.name} />
+                        </div>
+                      )}
+                      <div className="template-card-image-selection">
+                        <input
+                          type="checkbox"
+                          checked={selectedIds.includes(template.id)}
+                          aria-label={`${pageCopy.select || '选择'}${template.name}`}
+                          onChange={() => toggleSelect(template.id)}
+                          className="template-card-checkbox"
+                        />
                       </div>
-                      {getGenerationBadge(template) ? (
-                        <div className="template-card-meta">
-                          <span className="template-status-badge">{getGenerationBadge(template)}</span>
+                      {generationBadge ? (
+                        <div className="template-card-image-overlay">
+                          <span className="template-status-badge">{generationBadge}</span>
                         </div>
                       ) : null}
-                      <h3>{template.name}</h3>
-                      <p>{template.description}</p>
-                      <div className="template-card-footer"><span>{pageCopy.usedCount.replace('{{count}}', String(template.usedCount))}</span><span>{pageCopy.detail}</span></div>
                     </div>
-                  </Link>
-                </article>
-              ))}
+                    <div className="template-card-content">
+                      <div className="template-card-main">
+                        <div className="template-card-meta">
+                          <span>{pageCopy.categoryMap[template.category as keyof typeof pageCopy.categoryMap] || template.category}</span>
+                          <span>{pageCopy.statusMap[template.status as keyof typeof pageCopy.statusMap] || template.status}</span>
+                        </div>
+                        {target ? (
+                          <Link to={target} className="template-card-title-link">
+                            <h3>{template.name}</h3>
+                          </Link>
+                        ) : (
+                          <h3>{template.name}</h3>
+                        )}
+                        <p>{template.description}</p>
+                      </div>
+                      <div className="template-card-footer">
+                        <span>{pageCopy.usedCount.replace('{{count}}', String(template.usedCount))}</span>
+                        <div className="template-card-footer-actions">
+                          {target ? (
+                            <Link to={target} className="template-card-detail-action">
+                              {pageCopy.detail}
+                            </Link>
+                          ) : (
+                            <span className="template-card-detail-action is-disabled">
+                              {pageCopy.detail}
+                            </span>
+                          )}
+                          <button
+                            type="button"
+                            className="template-card-delete-action"
+                            aria-label={`${pageCopy.delete || '删除'}${template.name}`}
+                            onClick={() => void handleDelete(template.id)}
+                          >
+                            {pageCopy.delete || '删除'}
+                          </button>
+                        </div>
+                      </div>
+                    </div>
+                  </>
+                )
+
+                return (
+                  <article
+                    key={template.id}
+                    className={`template-card${target ? '' : ' is-static'}`}
+                  >
+                    <div className="template-card-shell">{cardContent}</div>
+                  </article>
+                )
+              })}
             </div>
 
-            {items.length === 0 ? <p className="fs-15 text-medium-gray mb-0">{copy.common.empty}</p> : null}
+            {!loading && items.length === 0 ? <p className="fs-15 text-medium-gray mb-0">{copy.common.empty}</p> : null}
 
             <div className="customer-pager">
-              <a href="#" className="customer-pager-link is-disabled" aria-label={pageCopy.pagination.prev}>{pageCopy.pagination.prev}</a>
-              <a href="#" className="customer-pager-link is-active" aria-current="page">1</a>
-              <a href="#" className="customer-pager-link">2</a>
-              <a href="#" className="customer-pager-link" aria-label={pageCopy.pagination.next}>{pageCopy.pagination.next}</a>
+              <button
+                type="button"
+                className={`customer-pager-link${page === 1 ? ' is-disabled' : ''}`}
+                aria-label={pageCopy.pagination.prev}
+                disabled={page === 1}
+                onClick={() => setPage((current) => Math.max(1, current - 1))}
+              >
+                {pageCopy.pagination.prev}
+              </button>
+              {pages.map((pageNumber) => (
+                <button
+                  key={pageNumber}
+                  type="button"
+                  className={`customer-pager-link${page === pageNumber ? ' is-active' : ''}`}
+                  aria-current={page === pageNumber ? 'page' : undefined}
+                  onClick={() => setPage(pageNumber)}
+                >
+                  {pageNumber}
+                </button>
+              ))}
+              <button
+                type="button"
+                className={`customer-pager-link${page === totalPages ? ' is-disabled' : ''}`}
+                aria-label={pageCopy.pagination.next}
+                disabled={page === totalPages}
+                onClick={() => setPage((current) => Math.min(totalPages, current + 1))}
+              >
+                {pageCopy.pagination.next}
+              </button>
             </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',
+    )
+  })
+})

+ 203 - 0
frontend/src/pages/dashboard/templates/__tests__/TemplateFormPage.test.tsx

@@ -0,0 +1,203 @@
+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 { 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/templates', () => ({
+  createTemplateMakeTask,
+  getTemplateAssets,
+}))
+
+vi.mock('../../../../services/api/media', () => ({
+  uploadMedia,
+}))
+
+vi.mock('../../../../shared/pageContext', () => ({
+  usePageContext: () => ({
+    locale: 'zh',
+    navigate,
+    path: (path = '') => `/zh${path}`,
+    copy: {
+      common: {
+        cancel: '取消',
+      },
+      templateFormPage: {
+        title: '新建模板',
+        description: 'desc',
+        back: '返回',
+        generate: 'AI生成',
+        badge: '模板',
+        lead: 'lead',
+        submitTitle: '提交任务',
+        submitLead: '提交后进入任务页',
+        loadingAssets: '正在加载素材...',
+        assetLoadFailed: '素材加载失败,请刷新后重试',
+        assetEmpty: '素材未配置完成,请检查服务器配置',
+        reference: {
+          badge: '参考图',
+          title: '参考发型图',
+          previewAlt: '参考图预览',
+          upload: '上传参考图',
+          help: 'help',
+        },
+        info: {
+          badge: '基本信息',
+          title: '模板信息',
+          name: '模板名称',
+          namePlaceholder: '请输入模板名称',
+          status: '状态',
+          category: '分类',
+          tags: '标签',
+          tagsPlaceholder: '首次建议,层次',
+          description: '描述',
+          descriptionPlaceholder: '请输入描述',
+        },
+        model: {
+          badge: '模特',
+          title: '选择AI模特',
+          preset: '使用预设模特',
+          upload: '上传自定义模特',
+          uploadLabel: '上传模特图片',
+        },
+        outfit: {
+          badge: '服装',
+          title: '选择服装',
+          preset: '使用预设服装',
+          upload: '上传自定义服装',
+          uploadLabel: '上传服装图片',
+        },
+        background: {
+          badge: '背景',
+          title: '选择背景',
+          preset: '使用预设背景',
+          upload: '上传自定义背景',
+          uploadLabel: '上传背景图片',
+        },
+        validation: {
+          referenceRequired: '请先上传参考图',
+          assetsRequired: '请选择模特、服装和背景',
+        },
+        errors: {
+          saveFailed: '保存失败',
+        },
+        submitting: {
+          uploading: '上传中',
+          generating: '提交中',
+        },
+        statusOptions: { Draft: '草稿', Published: '公开中' },
+        categoryOptions: { Long: '长发', Medium: '中长发', Short: '短发', Color: '染发' },
+      },
+    },
+  }),
+  usePreviewUrl: () => '/preview.png',
+}))
+
+describe('TemplateFormPage', () => {
+  beforeEach(() => {
+    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',
+    })
+  })
+
+  it('loads dynamic assets, uploads custom model, and redirects to the task page', async () => {
+    render(
+      <HelmetProvider>
+        <MemoryRouter>
+          <TemplateFormPage />
+        </MemoryRouter>
+      </HelmetProvider>,
+    )
+
+    expect(await screen.findByText('模特A')).toBeInTheDocument()
+
+    fireEvent.change(screen.getByLabelText('模板名称 *'), {
+      target: { value: '柔和层次长发' },
+    })
+    fireEvent.change(screen.getByLabelText('描述'), {
+      target: { value: '适合脸周柔和修饰' },
+    })
+
+    fireEvent.change(screen.getByLabelText('上传参考图'), {
+      target: { files: [new File(['ref'], 'reference.jpg', { type: 'image/jpeg' })] },
+    })
+
+    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'))
+
+    render(
+      <HelmetProvider>
+        <MemoryRouter>
+          <TemplateFormPage />
+        </MemoryRouter>
+      </HelmetProvider>,
+    )
+
+    expect(await screen.findByRole('alert')).toHaveTextContent('素材加载失败,请刷新后重试')
+    expect(screen.getAllByRole('button', { name: 'AI生成' })[0]).toBeDisabled()
+  })
+
+  it('disables submit when any asset group is empty', async () => {
+    getTemplateAssets.mockResolvedValue({
+      models: [{ id: 'm1', name: '模特A', url: 'https://cdn.example.com/model-a.jpg', configKey: 'm1' }],
+      clothes: [],
+      backgrounds: [{ id: 'b1', name: '背景A', url: 'https://cdn.example.com/background-a.jpg', configKey: 'b1' }],
+    })
+
+    render(
+      <HelmetProvider>
+        <MemoryRouter>
+          <TemplateFormPage />
+        </MemoryRouter>
+      </HelmetProvider>,
+    )
+
+    expect(await screen.findByRole('alert')).toHaveTextContent('素材未配置完成,请检查服务器配置')
+    expect(screen.getAllByRole('button', { name: 'AI生成' })[0]).toBeDisabled()
+  })
+})

+ 290 - 0
frontend/src/pages/dashboard/templates/__tests__/TemplateListPage.test.tsx

@@ -0,0 +1,290 @@
+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 { TemplateListPage } from '../TemplateListPage'
+
+const {
+  listTemplates,
+  getTemplateDashboardSummary,
+  deleteTemplate,
+  deleteTemplates,
+} = vi.hoisted(() => ({
+  listTemplates: vi.fn(),
+  getTemplateDashboardSummary: vi.fn(),
+  deleteTemplate: vi.fn(),
+  deleteTemplates: vi.fn(),
+}))
+
+vi.mock('../../../../layouts/DashboardLayout', () => ({
+  DashboardLayout: ({ children }: { children: ReactNode }) => <>{children}</>,
+}))
+
+vi.mock('../../../../services/api/templates', () => ({
+  listTemplates,
+  getTemplateDashboardSummary,
+  deleteTemplate,
+  deleteTemplates,
+}))
+
+vi.mock('../../../../shared/pageContext', () => ({
+  usePageContext: () => ({
+    locale: 'zh',
+    navigate: vi.fn(),
+    path: (path = '') => `/zh${path}`,
+    copy: {
+      common: {
+        empty: '暂无数据',
+      },
+      templateListPage: {
+        title: '我的发型模板',
+        description: 'desc',
+        badge: '发型模板',
+        lead: 'lead',
+        newTemplate: '新建发型模板',
+        filters: {
+          searchLabel: '发型模板搜索',
+          searchPlaceholder: '搜索',
+          categoryLabel: '分类',
+          categoryOptions: [{ value: '', label: '全部' }],
+          statusLabel: '状态',
+          statusOptions: [{ value: '', label: '全部' }],
+        },
+        stats: {
+          total: '已登记模板',
+          published: '公开中',
+          weeklyUsed: '本周使用',
+          draft: '草稿',
+        },
+        resultBadge: '发型模板列表',
+        resultTitle: '搜索结果 {{count}}件',
+        sortRecent: '排序:最近使用',
+        categoryMap: { LONG: '长发', MEDIUM: '中长发', SHORT: '短发', DYE: '染发' },
+        statusMap: { DRAFT: '草稿', PUBLIC: '公开中' },
+        usedCount: '使用 {{count}}次',
+        detail: '查看任务',
+        selectAll: '全选',
+        deselectAll: '取消全选',
+        pagination: { prev: '上一页', next: '下一页' },
+      },
+    },
+  }),
+}))
+
+describe('TemplateListPage', () => {
+  beforeEach(() => {
+    listTemplates.mockResolvedValue({
+      items: [
+        {
+          id: '101',
+          name: '处理中模板',
+          category: 'MEDIUM',
+          status: 'DRAFT',
+          description: '处理中',
+          tags: [],
+          image: '/processing.png',
+          makeTaskId: '88',
+          makeTaskStatus: 'PROCESSING',
+          usedCount: 0,
+        },
+        {
+          id: '102',
+          name: '已完成模板',
+          category: 'SHORT',
+          status: 'PUBLIC',
+          description: '已完成',
+          tags: [],
+          image: '/done.png',
+          makeTaskId: '89',
+          makeTaskStatus: 'STEP2_SUCCESS',
+          usedCount: 2,
+        },
+        {
+          id: '103',
+          name: '异常模板',
+          category: 'LONG',
+          status: 'DRAFT',
+          description: '缺少任务记录',
+          tags: [],
+          image: '/fallback.png',
+          usedCount: 0,
+        },
+      ],
+      total: 3,
+      page: 1,
+      pageSize: 8,
+    })
+    getTemplateDashboardSummary.mockResolvedValue({
+      total: 3,
+      published: 1,
+      weeklyUsed: 2,
+      draft: 2,
+    })
+  })
+
+  it('requests first page on load and keeps pagination visible without purpose filter', async () => {
+    render(
+      <HelmetProvider>
+        <MemoryRouter>
+          <TemplateListPage />
+        </MemoryRouter>
+      </HelmetProvider>,
+    )
+
+    expect(await screen.findByRole('link', { name: /处理中模板/ })).toBeInTheDocument()
+    expect(listTemplates).toHaveBeenCalledWith({
+      q: '',
+      category: '',
+      status: '',
+      page: 1,
+      pageSize: 8,
+    })
+    expect(screen.queryByLabelText('用途')).not.toBeInTheDocument()
+    expect(screen.getByLabelText('上一页')).toBeInTheDocument()
+    expect(screen.getByRole('button', { name: '1' })).toBeInTheDocument()
+    expect(screen.getByText('已登记模板')).toBeInTheDocument()
+    expect(screen.getAllByText('公开中').length).toBeGreaterThanOrEqual(1)
+    expect(screen.getByText('本周使用')).toBeInTheDocument()
+    expect(screen.getAllByText('草稿').length).toBeGreaterThanOrEqual(1)
+    expect(screen.getByText('使用 2次')).toBeInTheDocument()
+  })
+
+  it('resets to page 1 when search changes', async () => {
+    render(
+      <HelmetProvider>
+        <MemoryRouter>
+          <TemplateListPage />
+        </MemoryRouter>
+      </HelmetProvider>,
+    )
+
+    await screen.findByRole('link', { name: /处理中模板/ })
+    fireEvent.change(screen.getByLabelText('发型模板搜索'), {
+      target: { value: '空气感' },
+    })
+
+    await waitFor(() => {
+      expect(listTemplates).toHaveBeenLastCalledWith({
+        q: '空气感',
+        category: '',
+        status: '',
+        page: 1,
+        pageSize: 8,
+      })
+    })
+  })
+
+  it('routes templates with makeTaskId to the task page', async () => {
+    render(
+      <HelmetProvider>
+        <MemoryRouter>
+          <TemplateListPage />
+        </MemoryRouter>
+      </HelmetProvider>,
+    )
+
+    const processingLink = await screen.findByRole('link', { name: /处理中模板/ })
+    const doneLink = await screen.findByRole('link', { name: /已完成模板/ })
+
+    expect(processingLink).toHaveAttribute('href', '/zh/templates/tasks/88')
+    expect(doneLink).toHaveAttribute('href', '/zh/templates/tasks/89')
+  })
+
+  it('renders templates without makeTaskId as non-clickable cards', async () => {
+    render(
+      <HelmetProvider>
+        <MemoryRouter>
+          <TemplateListPage />
+        </MemoryRouter>
+      </HelmetProvider>,
+    )
+
+    expect(await screen.findByText('异常模板')).toBeInTheDocument()
+    expect(screen.queryByRole('link', { name: /异常模板/ })).not.toBeInTheDocument()
+  })
+
+  it('renders cards without the top toolbar and keeps an equal-height content skeleton', async () => {
+    render(
+      <HelmetProvider>
+        <MemoryRouter>
+          <TemplateListPage />
+        </MemoryRouter>
+      </HelmetProvider>,
+    )
+
+    const templateLink = await screen.findByRole('link', { name: /处理中模板/ })
+    const templateCard = templateLink.closest('article')
+
+    expect(templateCard).not.toBeNull()
+    expect(templateCard?.querySelector('.template-card-toolbar')).toBeNull()
+    expect(templateCard?.querySelector('.template-card-content')).not.toBeNull()
+    expect(templateCard?.querySelector('.template-card-main')).not.toBeNull()
+    expect(templateCard?.querySelector('.template-card-footer')).not.toBeNull()
+  })
+
+  it('renders checkbox and generation status inside the image overlay area', async () => {
+    render(
+      <HelmetProvider>
+        <MemoryRouter>
+          <TemplateListPage />
+        </MemoryRouter>
+      </HelmetProvider>,
+    )
+
+    const templateLink = await screen.findByRole('link', { name: /处理中模板/ })
+    const templateCard = templateLink.closest('article')
+    const imageOverlay = templateCard?.querySelector('.template-card-image-overlay')
+    const imageSelection = templateCard?.querySelector('.template-card-image-selection')
+
+    expect(imageOverlay).not.toBeNull()
+    expect(imageSelection).not.toBeNull()
+    expect(imageOverlay?.textContent).toContain('生成中')
+    expect(templateCard?.querySelector('.template-card-main .template-status-badge')).toBeNull()
+    expect(templateCard?.querySelector('.template-card-main .template-card-checkbox')).toBeNull()
+  })
+
+  it('renders footer delete action beside detail action and toggles current-page selection only', async () => {
+    render(
+      <HelmetProvider>
+        <MemoryRouter>
+          <TemplateListPage />
+        </MemoryRouter>
+      </HelmetProvider>,
+    )
+
+    const processingLink = await screen.findByRole('link', { name: /处理中模板/ })
+    const templateCard = processingLink.closest('article')
+
+    expect(templateCard?.querySelector('.template-card-footer-actions')).not.toBeNull()
+    expect(templateCard?.querySelector('.template-card-delete-action')).not.toBeNull()
+
+    fireEvent.click(screen.getByRole('button', { name: '全选' }))
+
+    expect(screen.getByRole('button', { name: '取消全选' })).toBeInTheDocument()
+    expect(screen.getByLabelText('选择处理中模板')).toBeChecked()
+    expect(screen.getByLabelText('选择已完成模板')).toBeChecked()
+    expect(screen.getByLabelText('选择异常模板')).toBeChecked()
+
+    fireEvent.click(screen.getByRole('button', { name: '取消全选' }))
+
+    expect(screen.getByRole('button', { name: '全选' })).toBeInTheDocument()
+    expect(screen.getByLabelText('选择处理中模板')).not.toBeChecked()
+  })
+
+  it('renders zero summary values when the summary request fails', async () => {
+    getTemplateDashboardSummary.mockRejectedValueOnce(new Error('boom'))
+
+    render(
+      <HelmetProvider>
+        <MemoryRouter>
+          <TemplateListPage />
+        </MemoryRouter>
+      </HelmetProvider>,
+    )
+
+    await screen.findByRole('link', { name: /处理中模板/ })
+
+    expect(screen.getAllByText('0').length).toBeGreaterThanOrEqual(4)
+  })
+})

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

+ 90 - 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
@@ -56,6 +58,94 @@ export type TemplateEntity = {
   backgroundImageUrl?: string
   usedCount: number
 }
+
+export type TemplateAssetOption = {
+  id: string
+  name: string
+  url: string
+  configKey: string
+}
+
+export type TemplateAssets = {
+  models: TemplateAssetOption[]
+  clothes: TemplateAssetOption[]
+  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,
   }
 }
 

+ 55 - 13
frontend/src/services/api/templates.ts

@@ -1,15 +1,30 @@
 import { http, ApiError, type ListEnvelope } from '../http/client'
 import type {
+  CreateTemplateMakeTaskInput,
   GetTemplateByIdParams,
+  PaginatedTemplateList,
+  TemplateBasicInfo,
+  TemplateAssets,
   TemplateDashboardSummary,
   TemplateDetailStats,
   TemplateEntity,
+  TemplateMakeTaskCreateResult,
+  TemplateMakeTaskDetail,
+  UpdateTemplateBasicInfoInput,
 } from './contracts'
 export type {
+  CreateTemplateMakeTaskInput,
   GetTemplateByIdParams,
+  PaginatedTemplateList,
+  TemplateAssetOption,
+  TemplateAssets,
+  TemplateBasicInfo,
   TemplateDashboardSummary,
   TemplateDetailStats,
   TemplateEntity,
+  TemplateMakeTaskCreateResult,
+  TemplateMakeTaskDetail,
+  UpdateTemplateBasicInfoInput,
 } from './contracts'
 
 export type CreateTemplateGenerationJobInput = {
@@ -22,7 +37,6 @@ export type CreateTemplateGenerationJobInput = {
 export type CreateTemplateAndGenerateInput = {
   name: string
   category?: string
-  purpose?: string
   status?: string
   description?: string
   tags?: string[]
@@ -60,7 +74,6 @@ export type TemplateGenerationJob = {
 export type TemplateSaveInput = {
   name: string
   category?: string
-  purpose?: string
   status?: string
   description?: string
   tags?: string[]
@@ -77,21 +90,50 @@ export type TemplateUsageResult = {
 }
 
 export async function listTemplates(
-  params: { q?: string; category?: string; purpose?: string; status?: string } = {},
-): Promise<TemplateEntity[]> {
+  params: { q?: string; category?: string; status?: string; page?: number; pageSize?: number } = {},
+): Promise<PaginatedTemplateList> {
   const query = new URLSearchParams()
   if (params.q) query.set('q', params.q)
   if (params.category) query.set('category', params.category)
-  if (params.purpose) query.set('purpose', params.purpose)
   if (params.status) query.set('status', params.status)
+  query.set('page', String(params.page ?? 1))
+  query.set('pageSize', String(params.pageSize ?? 8))
   const qs = query.toString()
-  const data = await http.get<ListEnvelope<TemplateEntity>>(`/templates${qs ? `?${qs}` : ''}`)
-  return data.items.map((item) => ({
-    ...item,
-    tags: item.tags || [],
-    image: item.image || '/images/aitori-simulation-result.png',
-    usedCount: typeof item.usedCount === 'number' ? item.usedCount : 0,
-  }))
+  const data = await http.get<PaginatedTemplateList>(`/templates${qs ? `?${qs}` : ''}`)
+  return {
+    ...data,
+    items: data.items.map((item) => ({
+      ...item,
+      tags: item.tags || [],
+      image: item.image || '/images/aitori-simulation-result.png',
+      usedCount: typeof item.usedCount === 'number' ? item.usedCount : 0,
+    })),
+  }
+}
+
+export async function getTemplateAssets(): Promise<TemplateAssets> {
+  return http.get<TemplateAssets>('/templates/assets')
+}
+
+export async function createTemplateMakeTask(
+  input: CreateTemplateMakeTaskInput,
+): Promise<TemplateMakeTaskCreateResult> {
+  return http.post<TemplateMakeTaskCreateResult>('/templates/make-tasks', input)
+}
+
+export async function getTemplateMakeTaskById(taskId: string): Promise<TemplateMakeTaskDetail> {
+  return http.get<TemplateMakeTaskDetail>(`/templates/make-tasks/${encodeURIComponent(taskId)}`)
+}
+
+export async function getTemplateBasicInfo(templateId: string): Promise<TemplateBasicInfo> {
+  return http.get<TemplateBasicInfo>(`/templates/${encodeURIComponent(templateId)}`)
+}
+
+export async function updateTemplateBasicInfo(
+  templateId: string,
+  input: UpdateTemplateBasicInfoInput,
+): Promise<TemplateBasicInfo> {
+  return http.patch<TemplateBasicInfo>(`/templates/${encodeURIComponent(templateId)}`, input)
 }
 
 export async function getTemplateById({ templateId }: GetTemplateByIdParams = {}): Promise<TemplateEntity | null> {
@@ -172,5 +214,5 @@ export async function getTemplateStats(templateId: string): Promise<TemplateDeta
 }
 
 export async function getTemplateDashboardSummary(): Promise<TemplateDashboardSummary> {
-  return http.get<TemplateDashboardSummary>('/stylist/dashboard/template-summary')
+  return http.get<TemplateDashboardSummary>('/templates/summary')
 }

Một số tệp đã không được hiển thị bởi vì quá nhiều tập tin thay đổi trong này khác