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