|
|
@@ -0,0 +1,1132 @@
|
|
|
+# 发型模板制作任务 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<string, unknown>): 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<string, unknown>) {
|
|
|
+ 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 };
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **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<TemplateMakeTaskDetailDto>`
|
|
|
+- Produces: `GET /api/v1/templates/make-tasks/:taskId`
|
|
|
+- Produces: helper `prepareStep2Bundle(input: PrepareStep2BundleInput): Promise<{ zipUrl: string; localFiles: Record<string, string> }>`
|
|
|
+
|
|
|
+- [ ] **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(<App />);
|
|
|
+
|
|
|
+ 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<TemplateMakeTaskCreateResult>('/templates/make-tasks', input);
|
|
|
+}
|
|
|
+
|
|
|
+export async function getTemplateMakeTaskById(taskId: string) {
|
|
|
+ return http.get<TemplateMakeTaskDetail>(`/templates/make-tasks/${encodeURIComponent(taskId)}`);
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: 注册路由与占位页面**
|
|
|
+
|
|
|
+```tsx
|
|
|
+export function TemplateMakeTaskPage() {
|
|
|
+ return <div>template-make-task-page</div>;
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+```tsx
|
|
|
+<Route
|
|
|
+ path={routes.templateTaskDetail}
|
|
|
+ element={
|
|
|
+ <RequireAuth>
|
|
|
+ <TemplateMakeTaskPage />
|
|
|
+ </RequireAuth>
|
|
|
+ }
|
|
|
+/>
|
|
|
+```
|
|
|
+
|
|
|
+```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(<TemplateFormPage />);
|
|
|
+
|
|
|
+ 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<AssetSource>('preset');
|
|
|
+const [uploadedModelUrl, setUploadedModelUrl] = useState('');
|
|
|
+
|
|
|
+const finalModelUrl =
|
|
|
+ modelSource === 'upload'
|
|
|
+ ? uploadedModelUrl
|
|
|
+ : assets.models.find((item) => item.id === selectedModelId)?.url ?? '';
|
|
|
+```
|
|
|
+
|
|
|
+```tsx
|
|
|
+<button type="button" onClick={() => setModelSource('preset')}>使用预设</button>
|
|
|
+<button type="button" onClick={() => setModelSource('upload')}>上传自定义模特</button>
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **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<TemplateMakeTaskDetail>`
|
|
|
+- 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(<TemplateMakeTaskPage />);
|
|
|
+
|
|
|
+ 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' ? (
|
|
|
+ <Link to={path(`/templates/${task.templateId}`)} className="btn btn-large btn-rounded btn-base-color">
|
|
|
+ {pageCopy.actions.viewTemplate}
|
|
|
+ </Link>
|
|
|
+) : 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
|
|
|
+<Link to={getTemplateTarget(template)} className="d-block text-decoration-none">
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **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`
|