# 发型模板制作任务 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:** 构建真实可用的发型模板制作闭环,支持动态素材、模板制作任务页、step1/step2 AI 编排,以及成功后回写 `hair_template`。 **Architecture:** 后端以 `hair_template` 作为模板实体、以 `hair_template_make_task` 作为制作过程状态源;前端创建模板后进入新的任务详情页,持续轮询本系统后端;后端在任务详情查询时刷新 step1/step2 外部 AI 任务状态并推进下一阶段,避免引入额外的后台 worker。 **Tech Stack:** NestJS 11、Prisma 6、Jest、React 19、React Router 7、Vitest、Testing Library ## Global Constraints - 数据库语义必须严格遵循 `backend/docs/数据库设计.md` 中的 `hair_template`、`hair_template_make_task`、`system_config` 定义。 - 本次不新增后台常驻 worker、消息队列或定时补偿任务。 - 模板新建页沿用现有 UI 骨架,不做大重构。 - 参考发型图片上传类型固定为 `ai-template-origin-image`。 - 模特、服装、背景都采用“预设或上传二选一”,最终统一提交 URL。 - 模特来自 `system_config.config_group = 'ai-model'`,服装来自 `clothing`,背景来自 `background`。 - step1 模板地址来自 `system_config.config_group = 'template-step1'` 的 `config_value`。 - `backend/src/step2` 只保存静态 `config.json` / `content.json`,运行时文件必须写入系统临时目录。 - step2 成功后才回写 `hair_template.cover_url` 与 `hair_template.template_url`,此前模板不可视为可用模板。 - 前端只轮询本系统后端,不能直连 AI 服务。 - 测试优先覆盖真实回归风险:创建双写、详情推进、前端轮询、动态素材提交。 --- ## File Structure ### Backend - Modify: `backend/prisma/schema.prisma` - 补 `SystemConfig` Prisma model,并为模板任务链路补必要的 enum/字段映射引用。 - Modify: `backend/src/modules/templates/templates.module.ts` - 注入 `ConfigModule`、`StorageModule`、`PrismaModule`、AI client 和任务 runner。 - Modify: `backend/src/modules/templates/templates.controller.ts` - 承载 `GET /templates/assets`、`POST /templates/make-tasks`、`GET /templates/make-tasks/:taskId`、模板列表/详情接口入口。 - Modify: `backend/src/modules/templates/templates.service.ts` - 承载素材查询、创建任务、查询任务详情、模板列表/详情查询。 - Create: `backend/src/modules/templates/template-ai.client.ts` - 抽象可复用的 `executeTemplate()` 和 `getResult()`。 - Create: `backend/src/modules/templates/template-task-runner.ts` - 负责 step1 发起、step1 刷新、step2 组包发起、step2 刷新与回写。 - Create: `backend/src/modules/templates/template-task-files.ts` - 负责临时目录、下载文件、复制 step2 静态文件、打 zip、清理目录。 - Create: `backend/src/modules/templates/dto/get-template-assets.dto.ts` - 定义素材列表返回 DTO。 - Create: `backend/src/modules/templates/dto/create-template-make-task.dto.ts` - 定义创建任务请求 DTO。 - Create: `backend/src/modules/templates/dto/template-make-task-detail.dto.ts` - 定义任务详情返回 DTO。 - Create: `backend/src/modules/templates/dto/list-templates-item.dto.ts` - 收敛模板列表项含任务状态的返回结构。 - Create: `backend/src/modules/templates/templates.service.spec.ts` - 单元测试模板创建、状态推进与失败路径。 - Create: `backend/test/templates.e2e-spec.ts` - E2E 覆盖素材接口、创建任务、查询任务详情。 ### Frontend - Modify: `frontend/src/config/routes.ts` - 增加模板任务页路由。 - Modify: `frontend/src/App.tsx` - 注册新路由。 - Modify: `frontend/src/services/api/contracts.ts` - 增加模板素材、创建任务、任务详情相关类型,并为模板列表增加 `makeTaskId` / `makeTaskStatus`。 - Modify: `frontend/src/services/api/templates.ts` - 增加 `getTemplateAssets()`、`createTemplateMakeTask()`、`getTemplateMakeTaskById()`。 - Modify: `frontend/src/pages/dashboard/templates/TemplateFormPage.tsx` - 改为动态素材与上传切换,提交后跳任务页。 - Create: `frontend/src/pages/dashboard/templates/TemplateMakeTaskPage.tsx` - 展示任务进度并轮询。 - Create: `frontend/src/pages/dashboard/templates/__tests__/TemplateFormPage.test.tsx` - 覆盖动态素材、上传切换、提交跳转。 - Create: `frontend/src/pages/dashboard/templates/__tests__/TemplateMakeTaskPage.test.tsx` - 覆盖处理中轮询、成功停止轮询、失败展示。 - Modify: `frontend/src/pages/dashboard/templates/TemplateListPage.tsx` - 根据模板任务状态决定跳任务页或模板详情页。 - Modify: `frontend/src/__tests__/App.test.tsx` - 覆盖模板任务页路由。 - Modify: `frontend/src/i18n/locales/zh/translation.json` - Modify: `frontend/src/i18n/locales/ja/translation.json` - 增加任务页、动态素材、失败文案。 - Modify: `frontend/public/css/aitori.css` - 追加模板任务页与素材来源切换样式。 ## Task 1: 搭好后端模板任务基础设施 **Files:** - Create: `backend/src/modules/templates/template-ai.client.ts` - Create: `backend/src/modules/templates/dto/get-template-assets.dto.ts` - Create: `backend/src/modules/templates/dto/create-template-make-task.dto.ts` - Create: `backend/src/modules/templates/dto/template-make-task-detail.dto.ts` - Modify: `backend/prisma/schema.prisma` - Modify: `backend/src/modules/templates/templates.module.ts` - Test: `backend/src/modules/templates/templates.service.spec.ts` **Interfaces:** - Consumes: `PrismaService`, `ConfigService`, `StorageService` - Produces: `TemplateAiClient.executeTemplate(template: string, inputs: Record): Promise<{ taskId: string }>` - Produces: `TemplateAiClient.getResult(taskId: string): Promise<{ status: 'processing' | 'successes' | 'failed'; message: string; images: string[] }>` - Produces: `CreateTemplateMakeTaskDto` - Produces: `TemplateAssetsDto` - Produces: Prisma model `SystemConfig` - [ ] **Step 1: 写后端单元测试骨架,锁定将来依赖的接口名** ```ts describe('TemplatesService', () => { it('lists grouped template assets from system_config', async () => { const result = await service.getTemplateAssets(); expect(result).toEqual({ models: expect.any(Array), clothes: expect.any(Array), backgrounds: expect.any(Array), }); }); }); ``` Run: `cd /Users/yuchen/Documents/hairstylist/backend && npm test -- --runInBand src/modules/templates/templates.service.spec.ts` Expected: FAIL with `Cannot find module './templates.service.spec.ts'` or missing method/type errors. - [ ] **Step 2: 在 Prisma schema 中补 `SystemConfig` model** ```prisma 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") } ``` Run: `cd /Users/yuchen/Documents/hairstylist/backend && npm run prisma:validate` Expected: PASS with `The schema at prisma/schema.prisma is valid`. - [ ] **Step 3: 创建模板任务 DTO 与通用 AI client** ```ts 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; } ``` ```ts @Injectable() export class TemplateAiClient { constructor(private readonly configService: ConfigService) {} async executeTemplate(template: string, inputs: Record) { const response = await fetch(`${this.configService.getOrThrow('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 }; } } ``` - [ ] **Step 4: 把新依赖接入 `TemplatesModule`** ```ts @Module({ imports: [AuthModule, PrismaModule, ConfigModule, StorageModule], controllers: [TemplatesController], providers: [TemplatesService, TemplateAiClient, TemplateTaskRunner], }) export class TemplatesModule {} ``` - [ ] **Step 5: 让测试通过到“素材接口可被定义”的程度** ```ts async getTemplateAssets() { return { models: [], clothes: [], backgrounds: [], }; } ``` Run: `cd /Users/yuchen/Documents/hairstylist/backend && npm test -- --runInBand src/modules/templates/templates.service.spec.ts` Expected: PASS for the new skeleton test. - [ ] **Step 6: 提交本任务** ```bash cd /Users/yuchen/Documents/hairstylist git add backend/prisma/schema.prisma backend/src/modules/templates git commit -m "feat: scaffold template make task backend" ``` ## Task 2: 实现后端素材接口与创建任务双写 **Files:** - Modify: `backend/src/modules/templates/templates.controller.ts` - Modify: `backend/src/modules/templates/templates.service.ts` - Modify: `backend/src/modules/templates/templates.service.spec.ts` - Create: `backend/src/modules/templates/dto/list-templates-item.dto.ts` - Test: `backend/test/templates.e2e-spec.ts` **Interfaces:** - Consumes: `CreateTemplateMakeTaskDto`, `TemplateAiClient.executeTemplate()` - Produces: `TemplatesService.getTemplateAssets(): Promise<{ models: AssetOptionDto[]; clothes: AssetOptionDto[]; backgrounds: AssetOptionDto[] }>` - Produces: `TemplatesService.createTemplateMakeTask(stylistId: bigint, dto: CreateTemplateMakeTaskDto): Promise<{ templateId: string; taskId: string; totalTaskId: string; taskStatus: 'WAIT' | 'PROCESSING' | 'FAILED' }>` - Produces: `GET /api/v1/templates/assets` - Produces: `POST /api/v1/templates/make-tasks` - [ ] **Step 1: 先写创建任务单元测试** ```ts it('creates hair_template and hair_template_make_task, then starts step1', async () => { prisma.systemConfig.findMany.mockResolvedValue([ { id: BigInt(9), configGroup: 'template-step1', configValue: 'https://cdn.example.com/step1.json', isDeleted: 0 }, ]); aiClient.executeTemplate.mockResolvedValue({ taskId: 'step1-task-1' }); prisma.$transaction.mockImplementation(async (callback) => callback({ hairTemplate: { create: jest.fn().mockResolvedValue({ id: BigInt(101) }) }, hairTemplateMakeTask: { create: jest.fn().mockResolvedValue({ id: BigInt(88) }) }, }), ); const result = await service.createTemplateMakeTask(BigInt(1), dto); expect(result).toEqual({ templateId: '101', taskId: '88', totalTaskId: expect.stringContaining('tmpl_'), taskStatus: 'PROCESSING', }); }); ``` Run: `cd /Users/yuchen/Documents/hairstylist/backend && npm test -- --runInBand src/modules/templates/templates.service.spec.ts` Expected: FAIL with `createTemplateMakeTask is not a function` or unmet mock expectations. - [ ] **Step 2: 实现素材接口** ```ts async getTemplateAssets() { const rows = await this.prisma.systemConfig.findMany({ where: { isDeleted: 0, configGroup: { in: ['ai-model', 'clothing', 'background'] }, }, orderBy: [{ sort: 'asc' }, { id: 'asc' }], }); const mapRow = (row: SystemConfig) => ({ id: row.id.toString(), name: row.configName, url: row.configValue ?? '', configKey: row.configKey, }); return { models: rows.filter((row) => row.configGroup === 'ai-model').map(mapRow), clothes: rows.filter((row) => row.configGroup === 'clothing').map(mapRow), backgrounds: rows.filter((row) => row.configGroup === 'background').map(mapRow), }; } ``` - [ ] **Step 3: 实现创建任务双写与 step1 发起** ```ts async createTemplateMakeTask(stylistId: bigint, dto: CreateTemplateMakeTaskDto) { const step1Template = 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: step1Template, 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(step1Template, { image: dto.refHairUrl, }); await this.prisma.hairTemplateMakeTask.update({ where: { id: created.task.id }, data: { step1TaskId: step1.taskId, taskStatus: 'PROCESSING', step1Resource: JSON.stringify({ templateUrl: step1Template, lastError: null }), }, }); return { templateId: created.template.id.toString(), taskId: created.task.id.toString(), totalTaskId, taskStatus: 'PROCESSING' as const, }; } ``` - [ ] **Step 4: 暴露控制器接口** ```ts @UseGuards(JwtAuthGuard) @Controller('templates') export class TemplatesController { @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); } } ``` - [ ] **Step 5: 补 E2E 测试** ```ts it('/api/v1/templates/assets (GET) returns grouped asset options', async () => { const token = await signToken(); await prisma.systemConfig.createMany({ data: [ { configKey: 'e2e-model', configName: '模特', configGroup: 'ai-model', configValue: 'https://cdn.example.com/model.jpg' }, { configKey: 'e2e-clothes', configName: '服装', configGroup: 'clothing', configValue: 'https://cdn.example.com/clothes.jpg' }, { configKey: 'e2e-background', configName: '背景', configGroup: 'background', configValue: 'https://cdn.example.com/background.jpg' }, ] as never, }); const response = await request(app.getHttpServer()) .get('/api/v1/templates/assets') .set('Authorization', `Bearer ${token}`) .expect(200); expect(response.body.data.models[0]).toMatchObject({ name: '模特' }); }); ``` ```ts it('/api/v1/templates/make-tasks (POST) creates template and task rows', async () => { const token = await signToken(); executeTemplate.mockResolvedValueOnce({ taskId: 'template-e2e-step1' }); const response = await request(app.getHttpServer()) .post('/api/v1/templates/make-tasks') .set('Authorization', `Bearer ${token}`) .send({ templateName: 'e2e-template', category: 'LONG', status: 'DRAFT', 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.taskStatus).toBe('PROCESSING'); }); ``` Run: `cd /Users/yuchen/Documents/hairstylist/backend && npm test -- --runInBand src/modules/templates/templates.service.spec.ts && npm run test:e2e -- templates.e2e-spec.ts` Expected: PASS for assets/create task tests. - [ ] **Step 6: 提交本任务** ```bash cd /Users/yuchen/Documents/hairstylist git add backend/src/modules/templates backend/test/templates.e2e-spec.ts git commit -m "feat: add template make task creation APIs" ``` ## Task 3: 实现 step1/step2 推进、zip 组装与任务详情接口 **Files:** - Create: `backend/src/modules/templates/template-task-files.ts` - Create: `backend/src/modules/templates/template-task-runner.ts` - Modify: `backend/src/modules/templates/templates.service.ts` - Modify: `backend/src/modules/templates/templates.controller.ts` - Modify: `backend/src/modules/templates/templates.service.spec.ts` - Modify: `backend/test/templates.e2e-spec.ts` **Interfaces:** - Consumes: `TemplateAiClient.getResult()`, `StorageService.uploadBuffer()` - Produces: `TemplateTaskRunner.refreshTask(stylistId: bigint, taskId: string): Promise` - Produces: `GET /api/v1/templates/make-tasks/:taskId` - Produces: helper `prepareStep2Bundle(input: PrepareStep2BundleInput): Promise<{ zipUrl: string; localFiles: Record }>` - [ ] **Step 1: 先写任务推进单元测试** ```ts it('promotes a task from PROCESSING to STEP1_SUCCESS when step1 finishes', async () => { prisma.hairTemplateMakeTask.findFirst.mockResolvedValue({ id: BigInt(88), templateId: BigInt(101), taskStatus: 'PROCESSING', step1TaskId: 'step1-task-1', step2TaskId: '', step1ResultImg: '', 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', step1Resource: JSON.stringify({ templateUrl: 'https://cdn.example.com/step1.json' }), step2Resource: JSON.stringify({}), updateTime: new Date(Date.now() - 20_000), }); aiClient.getResult.mockResolvedValue({ status: 'successes', message: '', images: ['https://cdn.example.com/step1-result.jpg'], }); fileRunner.downloadToTempFile.mockResolvedValue('/tmp/task-1/hairstyle.jpg'); const detail = await service.getTemplateMakeTaskDetail(BigInt(1), '88'); expect(prisma.hairTemplateMakeTask.update).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ taskStatus: 'STEP1_SUCCESS', step1ResultImg: 'https://cdn.example.com/step1-result.jpg', }), }), ); expect(detail.step1.resultImage).toBe('https://cdn.example.com/step1-result.jpg'); }); ``` Run: `cd /Users/yuchen/Documents/hairstylist/backend && npm test -- --runInBand src/modules/templates/templates.service.spec.ts` Expected: FAIL because `getTemplateMakeTaskDetail` and file helpers are missing. - [ ] **Step 2: 实现临时文件和 zip 组装 helper** ```ts export async function prepareStep2Bundle(input: PrepareStep2BundleInput) { const workDir = await mkdtemp(join(tmpdir(), `template-make-${input.totalTaskId}-`)); await copyFile(input.step2ConfigPath, join(workDir, 'config.json')); await copyFile(input.step2ContentPath, join(workDir, 'content.json')); await downloadFile(input.step1ResultImg, join(workDir, 'hairstyle.jpg')); await downloadFile(input.clothesUrl, join(workDir, 'input-outfit.jpg')); await downloadFile(input.backgroundUrl, join(workDir, 'input-background.jpg')); const zipPath = join(workDir, 'template.zip'); await zipDirectory(workDir, zipPath, ['template.zip']); return { workDir, zipPath, localFiles: { step1Image: join(workDir, 'hairstyle.jpg'), clothes: join(workDir, 'input-outfit.jpg'), background: join(workDir, 'input-background.jpg'), zip: zipPath, }, }; } ``` - [ ] **Step 3: 实现 `TemplateTaskRunner.refreshTask()`** ```ts async refreshTask(stylistId: bigint, taskId: string) { const task = await this.getOwnedTask(stylistId, taskId); if (task.taskStatus === 'WAIT') { return this.tryStartStep1(task); } if (task.taskStatus === 'PROCESSING' && task.step1TaskId && !task.step1ResultImg) { return this.refreshStep1(task); } if (task.taskStatus === 'STEP1_SUCCESS' && !task.step2TaskId) { return this.startStep2(task); } if (task.taskStatus === 'PROCESSING' && task.step2TaskId) { return this.refreshStep2(task); } return this.buildDetail(task); } ``` - [ ] **Step 4: 在 step2 成功时回写 `hair_template`** ```ts await this.prisma.$transaction([ this.prisma.hairTemplate.update({ where: { id: task.templateId! }, data: { coverUrl: resultImage, templateUrl: uploadedZipUrl, }, }), this.prisma.hairTemplateMakeTask.update({ where: { id: task.id }, data: { taskStatus: 'STEP2_SUCCESS', step2ResultImg: resultImage, step2Resource: JSON.stringify({ uploadedZipUrl, resultImage, lastError: null, }), }, }), ]); ``` - [ ] **Step 5: 暴露详情接口并补 e2e** ```ts @Get('make-tasks/:taskId') detail(@Req() request: AuthenticatedRequest, @Param('taskId') taskId: string) { return this.templatesService.getTemplateMakeTaskDetail(BigInt(request.user.stylistId), taskId); } ``` ```ts it('/api/v1/templates/make-tasks/:taskId (GET) promotes step1 and step2 states', async () => { getResult .mockResolvedValueOnce({ status: 'successes', message: '', images: ['https://cdn.example.com/step1.jpg'] }) .mockResolvedValueOnce({ status: 'successes', message: '', images: ['https://cdn.example.com/final-cover.jpg'] }); const response = await request(app.getHttpServer()) .get(`/api/v1/templates/make-tasks/${taskId}`) .set('Authorization', `Bearer ${token}`) .expect(200); expect(response.body.data.taskStatus).toMatch(/STEP1_SUCCESS|PROCESSING|STEP2_SUCCESS/); }); ``` Run: `cd /Users/yuchen/Documents/hairstylist/backend && npm test -- --runInBand src/modules/templates/templates.service.spec.ts && npm run test:e2e -- templates.e2e-spec.ts` Expected: PASS for step promotion, bundle upload, and detail endpoint tests. - [ ] **Step 6: 提交本任务** ```bash cd /Users/yuchen/Documents/hairstylist git add backend/src/modules/templates backend/test/templates.e2e-spec.ts git commit -m "feat: add template make task polling workflow" ``` ## Task 4: 接入前端 API、路由与任务页骨架 **Files:** - Modify: `frontend/src/config/routes.ts` - Modify: `frontend/src/App.tsx` - Modify: `frontend/src/services/api/contracts.ts` - Modify: `frontend/src/services/api/templates.ts` - Create: `frontend/src/pages/dashboard/templates/TemplateMakeTaskPage.tsx` - Modify: `frontend/src/__tests__/App.test.tsx` **Interfaces:** - Consumes: `GET /templates/assets`, `POST /templates/make-tasks`, `GET /templates/make-tasks/:taskId` - Produces: `TemplateAssetOption`, `TemplateMakeTaskCreateResult`, `TemplateMakeTaskDetail` - Produces: route `/:locale/templates/tasks/:taskId` - [ ] **Step 1: 先写前端路由测试** ```tsx it('renders template make task route', async () => { window.history.replaceState({}, '', '/zh/templates/tasks/88'); render(); await waitFor(() => { expect(screen.getByText('template-make-task-page')).toBeInTheDocument(); }); }); ``` Run: `cd /Users/yuchen/Documents/hairstylist/frontend && npm test -- src/__tests__/App.test.tsx` Expected: FAIL because the new page route is not registered. - [ ] **Step 2: 扩展 contracts 与 API 方法** ```ts export type TemplateAssetOption = { id: string; name: string; url: string; configKey: string; }; 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 TemplateEntity = { id: string; name: string; category: string; status: string; description: string; tags: string[]; image?: string; makeTaskId?: string; makeTaskStatus?: 'WAIT' | 'PROCESSING' | 'STEP1_SUCCESS' | 'STEP2_SUCCESS' | 'FAILED'; templateUrl?: string; }; ``` ```ts export async function getTemplateAssets() { return http.get<{ models: TemplateAssetOption[]; clothes: TemplateAssetOption[]; backgrounds: TemplateAssetOption[] }>('/templates/assets'); } export async function createTemplateMakeTask(input: CreateTemplateMakeTaskInput) { return http.post('/templates/make-tasks', input); } export async function getTemplateMakeTaskById(taskId: string) { return http.get(`/templates/make-tasks/${encodeURIComponent(taskId)}`); } ``` - [ ] **Step 3: 注册路由与占位页面** ```tsx export function TemplateMakeTaskPage() { return
template-make-task-page
; } ``` ```tsx } /> ``` ```ts templateTaskDetail: '/:locale/templates/tasks/:taskId', ``` - [ ] **Step 4: 让路由测试通过** Run: `cd /Users/yuchen/Documents/hairstylist/frontend && npm test -- src/__tests__/App.test.tsx` Expected: PASS with the new route rendered. - [ ] **Step 5: 提交本任务** ```bash cd /Users/yuchen/Documents/hairstylist git add frontend/src/config/routes.ts frontend/src/App.tsx frontend/src/services/api frontend/src/pages/dashboard/templates/TemplateMakeTaskPage.tsx frontend/src/__tests__/App.test.tsx git commit -m "feat: scaffold template make task frontend route" ``` ## Task 5: 改造模板新建页为动态素材与任务跳转 **Files:** - Modify: `frontend/src/pages/dashboard/templates/TemplateFormPage.tsx` - Create: `frontend/src/pages/dashboard/templates/__tests__/TemplateFormPage.test.tsx` - Modify: `frontend/src/services/api/media.ts` - Modify: `frontend/src/i18n/locales/zh/translation.json` - Modify: `frontend/src/i18n/locales/ja/translation.json` - Modify: `frontend/public/css/aitori.css` **Interfaces:** - Consumes: `uploadMedia(file, type)`, `getTemplateAssets()`, `createTemplateMakeTask()` - Produces: form submit payload `{ templateName, category, status, description, tags, refHairUrl, modelUrl, clothesUrl, backgroundUrl }` - Produces: custom upload media type `ai-template-origin-image` - [ ] **Step 1: 先写模板新建页测试** ```tsx it('loads dynamic assets, uploads custom files, and redirects to the task page', async () => { 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.mockResolvedValue({ url: 'https://cdn.example.com/uploaded-model.jpg' }); createTemplateMakeTask.mockResolvedValue({ taskId: '88', templateId: '101', totalTaskId: 'tmpl_x', taskStatus: 'PROCESSING' }); render(); expect(await screen.findByText('模特A')).toBeInTheDocument(); await userEvent.click(screen.getByRole('radio', { name: '上传自定义模特' })); await userEvent.upload(screen.getByLabelText('上传模特图片'), new File(['x'], 'model.jpg', { type: 'image/jpeg' })); await userEvent.click(screen.getByRole('button', { name: 'AI生成' })); await waitFor(() => { expect(createTemplateMakeTask).toHaveBeenCalledWith(expect.objectContaining({ modelUrl: 'https://cdn.example.com/uploaded-model.jpg', })); expect(navigate).toHaveBeenCalledWith('/zh/templates/tasks/88'); }); }); ``` Run: `cd /Users/yuchen/Documents/hairstylist/frontend && npm test -- src/pages/dashboard/templates/__tests__/TemplateFormPage.test.tsx` Expected: FAIL because `getTemplateAssets` and task redirect behavior do not exist. - [ ] **Step 2: 调整上传类型和素材加载逻辑** ```ts const [assets, setAssets] = useState<{ models: TemplateAssetOption[]; clothes: TemplateAssetOption[]; backgrounds: TemplateAssetOption[] }>({ models: [], clothes: [], backgrounds: [], }); useEffect(() => { getTemplateAssets().then(setAssets).catch(() => setAssets({ models: [], clothes: [], backgrounds: [] })); }, []); const referenceImage = (await uploadMedia(referenceFile, 'ai-template-origin-image')).url; ``` - [ ] **Step 3: 为模特、服装、背景增加“预设/上传”切换** ```tsx type AssetSource = 'preset' | 'upload'; const [modelSource, setModelSource] = useState('preset'); const [uploadedModelUrl, setUploadedModelUrl] = useState(''); const finalModelUrl = modelSource === 'upload' ? uploadedModelUrl : assets.models.find((item) => item.id === selectedModelId)?.url ?? ''; ``` ```tsx ``` - [ ] **Step 4: 改为调用创建任务接口并跳任务页** ```ts 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), refHairUrl: referenceImage, modelUrl: finalModelUrl, clothesUrl: finalClothesUrl, backgroundUrl: finalBackgroundUrl, }); navigate(path(`/templates/tasks/${task.taskId}`)); ``` - [ ] **Step 5: 更新文案与样式** ```json "assetSource": { "preset": "使用预设", "upload": "上传自定义" } ``` ```css .template-source-toggle { display: flex; gap: 12px; margin-bottom: 16px; } ``` - [ ] **Step 6: 运行测试** Run: `cd /Users/yuchen/Documents/hairstylist/frontend && npm test -- src/pages/dashboard/templates/__tests__/TemplateFormPage.test.tsx && npm run typecheck` Expected: PASS with dynamic asset and redirect behavior verified. - [ ] **Step 7: 提交本任务** ```bash cd /Users/yuchen/Documents/hairstylist git add frontend/src/pages/dashboard/templates/TemplateFormPage.tsx frontend/src/pages/dashboard/templates/__tests__/TemplateFormPage.test.tsx frontend/src/services/api/media.ts frontend/src/i18n/locales frontend/public/css/aitori.css git commit -m "feat: wire template form to make task flow" ``` ## Task 6: 完成模板任务页轮询与模板列表跳转分流 **Files:** - Modify: `frontend/src/pages/dashboard/templates/TemplateMakeTaskPage.tsx` - Create: `frontend/src/pages/dashboard/templates/__tests__/TemplateMakeTaskPage.test.tsx` - Modify: `frontend/src/pages/dashboard/templates/TemplateListPage.tsx` - Modify: `frontend/src/i18n/locales/zh/translation.json` - Modify: `frontend/src/i18n/locales/ja/translation.json` - Modify: `frontend/public/css/aitori.css` **Interfaces:** - Consumes: `getTemplateMakeTaskById(taskId: string): Promise` - Consumes: `TemplateEntity.makeTaskId?: string` - Consumes: `TemplateEntity.makeTaskStatus?: 'WAIT' | 'PROCESSING' | 'STEP1_SUCCESS' | 'STEP2_SUCCESS' | 'FAILED'` - Produces: processing poll interval `1500ms` - Produces: list item navigation rule `makeTaskStatus in ('WAIT','PROCESSING','STEP1_SUCCESS','FAILED') -> /templates/tasks/:makeTaskId` - [ ] **Step 1: 先写任务页轮询测试** ```tsx it('polls until the task reaches STEP2_SUCCESS and then shows the detail 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(); await act(async () => { await Promise.resolve(); }); await act(async () => { await vi.advanceTimersByTimeAsync(1500); }); expect(getTemplateMakeTaskById).toHaveBeenCalledTimes(2); expect(screen.getByRole('link', { name: '查看模板详情' })).toBeInTheDocument(); }); ``` Run: `cd /Users/yuchen/Documents/hairstylist/frontend && npm test -- src/pages/dashboard/templates/__tests__/TemplateMakeTaskPage.test.tsx` Expected: FAIL because the page is still a stub. - [ ] **Step 2: 实现任务页轮询和状态渲染** ```tsx useEffect(() => { let cancelled = false; let timer: number | undefined; const load = async () => { 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]); ``` ```tsx {task.taskStatus === 'STEP2_SUCCESS' ? ( {pageCopy.actions.viewTemplate} ) : null} ``` - [ ] **Step 3: 为模板列表增加跳转分流** ```tsx const getTemplateTarget = (template: TemplateEntity) => { if ( template.makeTaskId && ['WAIT', 'PROCESSING', 'STEP1_SUCCESS', 'FAILED'].includes(template.makeTaskStatus ?? '') ) { return path(`/templates/tasks/${template.makeTaskId}`); } return path(`/templates/${template.id}`); }; ``` ```tsx ``` - [ ] **Step 4: 更新任务页文案与样式** ```json "templateTaskPage": { "title": "模板制作任务", "actions": { "viewTemplate": "查看模板详情" } } ``` ```css .template-task-layout { display: grid; grid-template-columns: minmax(0, 380px) minmax(0, 1fr); gap: 24px; } .template-task-step-card.is-success { border-color: rgba(82, 196, 26, 0.28); } ``` - [ ] **Step 5: 跑前端回归测试** Run: `cd /Users/yuchen/Documents/hairstylist/frontend && npm test -- src/pages/dashboard/templates/__tests__/TemplateMakeTaskPage.test.tsx src/__tests__/App.test.tsx && npm run typecheck` Expected: PASS with polling and routing behavior verified. - [ ] **Step 6: 提交本任务** ```bash cd /Users/yuchen/Documents/hairstylist git add frontend/src/pages/dashboard/templates/TemplateMakeTaskPage.tsx frontend/src/pages/dashboard/templates/TemplateListPage.tsx frontend/src/pages/dashboard/templates/__tests__/TemplateMakeTaskPage.test.tsx frontend/src/i18n/locales frontend/public/css/aitori.css git commit -m "feat: add template make task page" ``` ## Task 7: 全量验证、文档同步与收尾 **Files:** - Modify: `docs/superpowers/specs/2026-06-28-template-make-task-design.md` (only if implementation diverges) - Modify: `backend/docs/数据库设计.md` (only if doc drift is found and user-approved) **Interfaces:** - Consumes: all prior tasks - Produces: verified working feature branch state - [ ] **Step 1: 运行后端验证** Run: `cd /Users/yuchen/Documents/hairstylist/backend && npm run lint && npm test -- --runInBand src/modules/templates/templates.service.spec.ts && npm run test:e2e -- templates.e2e-spec.ts` Expected: PASS for lint, unit tests, and e2e tests. - [ ] **Step 2: 运行前端验证** Run: `cd /Users/yuchen/Documents/hairstylist/frontend && npm run typecheck && npm test -- src/pages/dashboard/templates/__tests__/TemplateFormPage.test.tsx src/pages/dashboard/templates/__tests__/TemplateMakeTaskPage.test.tsx src/__tests__/App.test.tsx` Expected: PASS for typecheck and targeted template flow tests. - [ ] **Step 3: 手动冒烟检查** ```text 1. 登录后台并打开 /zh/templates/new 2. 选择预设模特,上传自定义服装与背景,提交任务 3. 确认页面跳转到 /zh/templates/tasks/:taskId 4. 观察 step1 -> step2 状态推进 5. 成功后点击“查看模板详情”进入模板详情页 6. 在模板列表里确认该模板卡片可进入正确页面 ``` Expected: 整条链路无阻塞,成功产出封面图与模板包 URL。 - [ ] **Step 4: 如实现偏离 spec,则同步更新文档** ```md - 若最终接口字段或任务页文案与 spec 不一致,立即更新 `docs/superpowers/specs/2026-06-28-template-make-task-design.md` - 若数据库文档存在新增事实字段或语义修正,先确认后再更新 `backend/docs/数据库设计.md` ``` - [ ] **Step 5: 提交收尾** ```bash cd /Users/yuchen/Documents/hairstylist git add . git commit -m "feat: finish template make task flow" ``` ## Self-Review Checklist - Spec coverage: - 动态素材接口 -> Task 2 - 创建模板双写 -> Task 2 - step1/step2 推进、zip 上传、回写模板 -> Task 3 - 前端新任务页、轮询 -> Task 4 / Task 6 - 模板新建页动态素材与跳转 -> Task 5 - 模板列表跳转分流 -> Task 6 - 验证与文档同步 -> Task 7 - Placeholder scan: - 未使用 `TBD`、`TODO`、`later` - 每个任务包含明确文件、接口、命令和预期结果 - Type consistency: - 统一使用 `CreateTemplateMakeTaskDto` / `TemplateMakeTaskDetail` - 统一使用 `TemplateAiClient.executeTemplate()` / `getResult()` - 路由统一为 `/:locale/templates/tasks/:taskId`