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
backend/docs/数据库设计.md 中的 hair_template、hair_template_make_task、system_config 定义。ai-template-origin-image。system_config.config_group = 'ai-model',服装来自 clothing,背景来自 background。system_config.config_group = 'template-step1' 的 config_value。backend/src/step2 只保存静态 config.json / content.json,运行时文件必须写入系统临时目录。hair_template.cover_url 与 hair_template.template_url,此前模板不可视为可用模板。backend/prisma/schema.prisma
SystemConfig Prisma model,并为模板任务链路补必要的 enum/字段映射引用。backend/src/modules/templates/templates.module.ts
ConfigModule、StorageModule、PrismaModule、AI client 和任务 runner。backend/src/modules/templates/templates.controller.ts
GET /templates/assets、POST /templates/make-tasks、GET /templates/make-tasks/:taskId、模板列表/详情接口入口。backend/src/modules/templates/templates.service.ts
backend/src/modules/templates/template-ai.client.ts
executeTemplate() 和 getResult()。backend/src/modules/templates/template-task-runner.ts
backend/src/modules/templates/template-task-files.ts
backend/src/modules/templates/dto/get-template-assets.dto.ts
backend/src/modules/templates/dto/create-template-make-task.dto.ts
backend/src/modules/templates/dto/template-make-task-detail.dto.ts
backend/src/modules/templates/dto/list-templates-item.dto.ts
backend/src/modules/templates/templates.service.spec.ts
backend/test/templates.e2e-spec.ts
frontend/src/config/routes.ts
frontend/src/App.tsx
frontend/src/services/api/contracts.ts
makeTaskId / makeTaskStatus。frontend/src/services/api/templates.ts
getTemplateAssets()、createTemplateMakeTask()、getTemplateMakeTaskById()。frontend/src/pages/dashboard/templates/TemplateFormPage.tsx
frontend/src/pages/dashboard/templates/TemplateMakeTaskPage.tsx
frontend/src/pages/dashboard/templates/__tests__/TemplateFormPage.test.tsx
frontend/src/pages/dashboard/templates/__tests__/TemplateMakeTaskPage.test.tsx
frontend/src/pages/dashboard/templates/TemplateListPage.tsx
frontend/src/__tests__/App.test.tsx
frontend/src/i18n/locales/zh/translation.jsonfrontend/src/i18n/locales/ja/translation.json
frontend/public/css/aitori.css
Files:
backend/src/modules/templates/template-ai.client.tsbackend/src/modules/templates/dto/get-template-assets.dto.tsbackend/src/modules/templates/dto/create-template-make-task.dto.tsbackend/src/modules/templates/dto/template-make-task-detail.dto.tsbackend/prisma/schema.prismabackend/src/modules/templates/templates.module.tsbackend/src/modules/templates/templates.service.spec.tsInterfaces:
PrismaService, ConfigService, StorageServiceTemplateAiClient.executeTemplate(template: string, inputs: Record<string, unknown>): Promise<{ taskId: string }>TemplateAiClient.getResult(taskId: string): Promise<{ status: 'processing' | 'successes' | 'failed'; message: string; images: string[] }>CreateTemplateMakeTaskDtoTemplateAssetsDtoProduces: Prisma model SystemConfig
[ ] Step 1: 写后端单元测试骨架,锁定将来依赖的接口名
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
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
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;
}
@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
@Module({
imports: [AuthModule, PrismaModule, ConfigModule, StorageModule],
controllers: [TemplatesController],
providers: [TemplatesService, TemplateAiClient, TemplateTaskRunner],
})
export class TemplatesModule {}
[ ] Step 5: 让测试通过到“素材接口可被定义”的程度
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: 提交本任务
cd /Users/yuchen/Documents/hairstylist
git add backend/prisma/schema.prisma backend/src/modules/templates
git commit -m "feat: scaffold template make task backend"
Files:
backend/src/modules/templates/templates.controller.tsbackend/src/modules/templates/templates.service.tsbackend/src/modules/templates/templates.service.spec.tsbackend/src/modules/templates/dto/list-templates-item.dto.tsbackend/test/templates.e2e-spec.tsInterfaces:
CreateTemplateMakeTaskDto, TemplateAiClient.executeTemplate()TemplatesService.getTemplateAssets(): Promise<{ models: AssetOptionDto[]; clothes: AssetOptionDto[]; backgrounds: AssetOptionDto[] }>TemplatesService.createTemplateMakeTask(stylistId: bigint, dto: CreateTemplateMakeTaskDto): Promise<{ templateId: string; taskId: string; totalTaskId: string; taskStatus: 'WAIT' | 'PROCESSING' | 'FAILED' }>GET /api/v1/templates/assetsProduces: POST /api/v1/templates/make-tasks
[ ] Step 1: 先写创建任务单元测试
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: 实现素材接口
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 发起
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: 暴露控制器接口
@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 测试
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: '模特' });
});
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: 提交本任务
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"
Files:
backend/src/modules/templates/template-task-files.tsbackend/src/modules/templates/template-task-runner.tsbackend/src/modules/templates/templates.service.tsbackend/src/modules/templates/templates.controller.tsbackend/src/modules/templates/templates.service.spec.tsbackend/test/templates.e2e-spec.tsInterfaces:
TemplateAiClient.getResult(), StorageService.uploadBuffer()TemplateTaskRunner.refreshTask(stylistId: bigint, taskId: string): Promise<TemplateMakeTaskDetailDto>GET /api/v1/templates/make-tasks/:taskIdProduces: helper prepareStep2Bundle(input: PrepareStep2BundleInput): Promise<{ zipUrl: string; localFiles: Record<string, string> }>
[ ] Step 1: 先写任务推进单元测试
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
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()
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
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
@Get('make-tasks/:taskId')
detail(@Req() request: AuthenticatedRequest, @Param('taskId') taskId: string) {
return this.templatesService.getTemplateMakeTaskDetail(BigInt(request.user.stylistId), taskId);
}
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: 提交本任务
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"
Files:
frontend/src/config/routes.tsfrontend/src/App.tsxfrontend/src/services/api/contracts.tsfrontend/src/services/api/templates.tsfrontend/src/pages/dashboard/templates/TemplateMakeTaskPage.tsxfrontend/src/__tests__/App.test.tsxInterfaces:
GET /templates/assets, POST /templates/make-tasks, GET /templates/make-tasks/:taskIdTemplateAssetOption, TemplateMakeTaskCreateResult, TemplateMakeTaskDetailProduces: route /:locale/templates/tasks/:taskId
[ ] Step 1: 先写前端路由测试
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 方法
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;
};
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: 注册路由与占位页面
export function TemplateMakeTaskPage() {
return <div>template-make-task-page</div>;
}
<Route
path={routes.templateTaskDetail}
element={
<RequireAuth>
<TemplateMakeTaskPage />
</RequireAuth>
}
/>
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: 提交本任务
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"
Files:
frontend/src/pages/dashboard/templates/TemplateFormPage.tsxfrontend/src/pages/dashboard/templates/__tests__/TemplateFormPage.test.tsxfrontend/src/services/api/media.tsfrontend/src/i18n/locales/zh/translation.jsonfrontend/src/i18n/locales/ja/translation.jsonfrontend/public/css/aitori.cssInterfaces:
uploadMedia(file, type), getTemplateAssets(), createTemplateMakeTask(){ templateName, category, status, description, tags, refHairUrl, modelUrl, clothesUrl, backgroundUrl }Produces: custom upload media type ai-template-origin-image
[ ] Step 1: 先写模板新建页测试
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: 调整上传类型和素材加载逻辑
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: 为模特、服装、背景增加“预设/上传”切换
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 ?? '';
<button type="button" onClick={() => setModelSource('preset')}>使用预设</button>
<button type="button" onClick={() => setModelSource('upload')}>上传自定义模特</button>
[ ] Step 4: 改为调用创建任务接口并跳任务页
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: 更新文案与样式
"assetSource": {
"preset": "使用预设",
"upload": "上传自定义"
}
.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: 提交本任务
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"
Files:
frontend/src/pages/dashboard/templates/TemplateMakeTaskPage.tsxfrontend/src/pages/dashboard/templates/__tests__/TemplateMakeTaskPage.test.tsxfrontend/src/pages/dashboard/templates/TemplateListPage.tsxfrontend/src/i18n/locales/zh/translation.jsonfrontend/src/i18n/locales/ja/translation.jsonfrontend/public/css/aitori.cssInterfaces:
getTemplateMakeTaskById(taskId: string): Promise<TemplateMakeTaskDetail>TemplateEntity.makeTaskId?: stringTemplateEntity.makeTaskStatus?: 'WAIT' | 'PROCESSING' | 'STEP1_SUCCESS' | 'STEP2_SUCCESS' | 'FAILED'1500msProduces: list item navigation rule makeTaskStatus in ('WAIT','PROCESSING','STEP1_SUCCESS','FAILED') -> /templates/tasks/:makeTaskId
[ ] Step 1: 先写任务页轮询测试
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: 实现任务页轮询和状态渲染
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]);
{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: 为模板列表增加跳转分流
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}`);
};
<Link to={getTemplateTarget(template)} className="d-block text-decoration-none">
[ ] Step 4: 更新任务页文案与样式
"templateTaskPage": {
"title": "模板制作任务",
"actions": {
"viewTemplate": "查看模板详情"
}
}
.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: 提交本任务
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"
Files:
docs/superpowers/specs/2026-06-28-template-make-task-design.md (only if implementation diverges)backend/docs/数据库设计.md (only if doc drift is found and user-approved)Interfaces:
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.
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: 手动冒烟检查
1. 登录后台并打开 /zh/templates/new
2. 选择预设模特,上传自定义服装与背景,提交任务
3. 确认页面跳转到 /zh/templates/tasks/:taskId
4. 观察 step1 -> step2 状态推进
5. 成功后点击“查看模板详情”进入模板详情页
6. 在模板列表里确认该模板卡片可进入正确页面
Expected: 整条链路无阻塞,成功产出封面图与模板包 URL。
[ ] Step 4: 如实现偏离 spec,则同步更新文档
- 若最终接口字段或任务页文案与 spec 不一致,立即更新 `docs/superpowers/specs/2026-06-28-template-make-task-design.md`
- 若数据库文档存在新增事实字段或语义修正,先确认后再更新 `backend/docs/数据库设计.md`
[ ] Step 5: 提交收尾
cd /Users/yuchen/Documents/hairstylist
git add .
git commit -m "feat: finish template make task flow"
TBD、TODO、laterCreateTemplateMakeTaskDto / TemplateMakeTaskDetailTemplateAiClient.executeTemplate() / getResult()/:locale/templates/tasks/:taskId