# 模板新建页素材数据源切换 Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 将模板新建页中的 AI 模特、服装、背景三组素材从本地静态配置切换为服务端配置接口,同时保留现有页面结构和旧提交流程。 **Architecture:** 前端在模板 API 层新增素材读取方法和类型定义,`TemplateFormPage` 在挂载时请求服务端素材接口并将返回结果映射为现有卡片 UI 所需的选项结构。页面不再依赖本地 `hairstyleTemplateAssets`,接口失败或返回空组时直接显示错误并禁用提交,不做静态兜底。 **Tech Stack:** React, TypeScript, React Router, Vitest, Testing Library, i18n JSON 文案, 现有 `http` API client ## Global Constraints - 只替换这三个区块的数据来源。 - 其他页面结构和旧提交流程先不改。 - 接口失败时不回退本地静态数据。 - 不改当前 `TemplateFormPage` 的其余表单结构。 - 不改当前提交接口 `createTemplateAndStartGeneration()`。 - 不在这次改动中接入模板制作任务页。 - 不保留本地静态素材作为降级兜底。 - 只增加与本次目标直接相关的定向前端测试。 --- ### Task 1: 定义素材接口与失败测试 **Files:** - Create: `frontend/src/pages/dashboard/templates/__tests__/TemplateFormPage.test.tsx` - Modify: `frontend/src/services/api/contracts.ts` - Modify: `frontend/src/services/api/templates.ts` - Modify: `frontend/src/i18n/locales/zh/translation.json` - Modify: `frontend/src/i18n/locales/ja/translation.json` **Interfaces:** - Consumes: `http.get()` from `frontend/src/services/http/client.ts` - Produces: `TemplateAssetOption`, `TemplateAssets`, `getTemplateAssets(): Promise` - [ ] **Step 1: 写失败测试,锁定页面必须通过接口加载素材并在失败时禁用提交** ```tsx import { describe, expect, it, beforeEach, vi } from 'vitest' import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { MemoryRouter } from 'react-router-dom' import { HelmetProvider } from 'react-helmet-async' import { TemplateFormPage } from '../TemplateFormPage' const navigateMock = vi.fn() vi.mock('../../../../shared/pageContext', async () => { const actual = await vi.importActual('../../../../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( , ) } it('loads model, outfit and background options from api', async () => { getTemplateAssetsMock.mockResolvedValue({ models: [{ id: 'm1', name: '模特A', url: 'https://img/model-a.jpg', configKey: 'model-a' }], clothes: [{ id: 'c1', name: '服装A', url: 'https://img/cloth-a.jpg', configKey: 'cloth-a' }], backgrounds: [{ id: 'b1', name: '背景A', url: 'https://img/bg-a.jpg', configKey: 'bg-a' }], }) renderPage() expect(await screen.findByText('模特A')).toBeInTheDocument() expect(screen.getByText('服装A')).toBeInTheDocument() expect(screen.getByText('背景A')).toBeInTheDocument() expect(getTemplateAssetsMock).toHaveBeenCalledTimes(1) }) it('shows error and disables submit when assets request fails', async () => { getTemplateAssetsMock.mockRejectedValue(new Error('network error')) renderPage() expect(await screen.findByRole('alert')).toHaveTextContent('素材加载失败,请刷新后重试') expect(screen.getAllByRole('button', { name: 'AI生成' })[0]).toBeDisabled() }) it('submits api asset urls instead of local static assets', async () => { const user = userEvent.setup() getTemplateAssetsMock.mockResolvedValue({ models: [{ id: 'm1', name: '模特A', url: 'https://img/model-a.jpg', configKey: 'model-a' }], clothes: [{ id: 'c1', name: '服装A', url: 'https://img/cloth-a.jpg', configKey: 'cloth-a' }], backgrounds: [{ id: 'b1', name: '背景A', url: 'https://img/bg-a.jpg', configKey: 'bg-a' }], }) createTemplateAndStartGenerationMock.mockResolvedValue({ id: '88' }) renderPage() await screen.findByText('模特A') await user.type(screen.getByLabelText('模板名称 *'), '云感长发') await user.type(screen.getByLabelText('说明'), '说明内容') await user.upload( screen.getByLabelText('选择图片并预览'), new File(['demo'], 'reference.jpg', { type: 'image/jpeg' }), ) await user.click(screen.getAllByRole('button', { name: 'AI生成' })[0]) await waitFor(() => { expect(createTemplateAndStartGenerationMock).toHaveBeenCalledWith( expect.objectContaining({ modelImageUrl: 'https://img/model-a.jpg', outfitImageUrl: 'https://img/cloth-a.jpg', backgroundImageUrl: 'https://img/bg-a.jpg', }), ) }) }) }) ``` - [ ] **Step 2: 运行测试并确认当前失败** Run: `npm test -- src/pages/dashboard/templates/__tests__/TemplateFormPage.test.tsx` Expected: - FAIL,原因包括 `getTemplateAssets` 尚未导出 - FAIL,原因包括页面仍然从 `hairstyleTemplateAssets` 读取静态素材 - FAIL,原因包括页面尚未显示素材加载失败文案 - [ ] **Step 3: 在 API contracts 中补素材类型** ```ts export type TemplateAssetOption = { id: string name: string url: string configKey: string } export type TemplateAssets = { models: TemplateAssetOption[] clothes: TemplateAssetOption[] backgrounds: TemplateAssetOption[] } ``` - [ ] **Step 4: 在模板 API 文件中新增素材读取方法** ```ts import type { GetTemplateByIdParams, TemplateAssetOption, TemplateAssets, TemplateDashboardSummary, TemplateDetailStats, TemplateEntity, } from './contracts' export type { GetTemplateByIdParams, TemplateAssetOption, TemplateAssets, TemplateDashboardSummary, TemplateDetailStats, TemplateEntity, } from './contracts' export async function getTemplateAssets(): Promise { const data = await http.get<{ models?: TemplateAssetOption[] clothes?: TemplateAssetOption[] backgrounds?: TemplateAssetOption[] }>('/templates/assets') return { models: data.models ?? [], clothes: data.clothes ?? [], backgrounds: data.backgrounds ?? [], } } ``` - [ ] **Step 5: 在中日文文案中补充素材加载失败与空态文案** ```json "templateFormPage": { "loadingAssets": "正在加载素材...", "assetLoadFailed": "素材加载失败,请刷新后重试", "assetEmpty": "素材未配置完成,请检查服务器配置" } ``` ```json "templateFormPage": { "loadingAssets": "素材を読み込み中です...", "assetLoadFailed": "素材の読み込みに失敗しました。再読み込みしてもう一度お試しください", "assetEmpty": "素材設定が未完了です。サーバー設定を確認してください" } ``` - [ ] **Step 6: 重新运行定向测试,确认仍然只剩页面实现失败** Run: `npm test -- src/pages/dashboard/templates/__tests__/TemplateFormPage.test.tsx` Expected: - 仍然 FAIL - 错误收敛到 `TemplateFormPage` 仍未调用 `getTemplateAssets` - [ ] **Step 7: 提交** ```bash git add frontend/src/pages/dashboard/templates/__tests__/TemplateFormPage.test.tsx frontend/src/services/api/contracts.ts frontend/src/services/api/templates.ts frontend/src/i18n/locales/zh/translation.json frontend/src/i18n/locales/ja/translation.json git commit -m "test: lock template form asset loading contract" ``` ### Task 2: 改造模板新建页为服务端素材来源 **Files:** - Modify: `frontend/src/pages/dashboard/templates/TemplateFormPage.tsx` - Test: `frontend/src/pages/dashboard/templates/__tests__/TemplateFormPage.test.tsx` **Interfaces:** - Consumes: `getTemplateAssets(): Promise`, `createTemplateAndStartGeneration(input: CreateTemplateAndGenerateInput): Promise`, `uploadMedia(file: File): Promise<{ url: string }>` - Produces: 页面级素材加载状态 `assetsLoading: boolean`, 页面级素材错误状态 `assetsError: string`, 由 API 返回值映射出的 `modelOptions`, `outfitOptions`, `backgroundOptions` - [ ] **Step 1: 在页面测试中再补一个“空数据禁用提交”的失败用例** ```tsx it('disables submit when any asset group is empty', async () => { getTemplateAssetsMock.mockResolvedValue({ models: [{ id: 'm1', name: '模特A', url: 'https://img/model-a.jpg', configKey: 'model-a' }], clothes: [], backgrounds: [{ id: 'b1', name: '背景A', url: 'https://img/bg-a.jpg', configKey: 'bg-a' }], }) renderPage() expect(await screen.findByRole('alert')).toHaveTextContent('素材未配置完成,请检查服务器配置') expect(screen.getAllByRole('button', { name: 'AI生成' })[0]).toBeDisabled() }) ``` - [ ] **Step 2: 运行测试,确认新用例失败** Run: `npm test -- src/pages/dashboard/templates/__tests__/TemplateFormPage.test.tsx` Expected: - FAIL,页面还没有“空组即禁用提交”的逻辑 - [ ] **Step 3: 最小实现页面加载状态、错误状态与 API 映射** ```tsx import React, { useEffect, useMemo, useState } from 'react' import { createTemplateAndStartGeneration, getTemplateAssets } from '../../../services/api/templates' import type { TemplateAssetOption } from '../../../services/api/contracts' type TemplateOption = { id: string label: string url: string previewUrl: string } function toTemplateOptions(items: TemplateAssetOption[]): TemplateOption[] { return items.map((item) => ({ id: item.id, label: item.name, url: item.url, previewUrl: item.url, })) } export function TemplateFormPage() { const { locale, navigate, path, copy } = usePageContext() const pageCopy = copy.templateFormPage const [modelOptions, setModelOptions] = useState([]) const [outfitOptions, setOutfitOptions] = useState([]) const [backgroundOptions, setBackgroundOptions] = useState([]) const [assetsLoading, setAssetsLoading] = useState(true) const [assetsError, setAssetsError] = useState('') const [selectedModelId, setSelectedModelId] = useState('') const [selectedOutfitId, setSelectedOutfitId] = useState('') const [selectedBackgroundId, setSelectedBackgroundId] = useState('') useEffect(() => { let cancelled = false async function loadAssets() { setAssetsLoading(true) setAssetsError('') try { const data = await getTemplateAssets() if (cancelled) return const nextModels = toTemplateOptions(data.models) const nextClothes = toTemplateOptions(data.clothes) const nextBackgrounds = toTemplateOptions(data.backgrounds) setModelOptions(nextModels) setOutfitOptions(nextClothes) setBackgroundOptions(nextBackgrounds) if (nextModels.length === 0 || nextClothes.length === 0 || nextBackgrounds.length === 0) { setAssetsError(pageCopy.assetEmpty || '素材未配置完成,请检查服务器配置') return } setSelectedModelId((current) => current || nextModels[0].id) setSelectedOutfitId((current) => current || nextClothes[0].id) setSelectedBackgroundId((current) => current || nextBackgrounds[0].id) } catch { if (!cancelled) { setAssetsError(pageCopy.assetLoadFailed || '素材加载失败,请刷新后重试') } } finally { if (!cancelled) { setAssetsLoading(false) } } } void loadAssets() return () => { cancelled = true } }, [pageCopy.assetEmpty, pageCopy.assetLoadFailed]) const hasReadyAssets = useMemo( () => !assetsLoading && !assetsError && modelOptions.length > 0 && outfitOptions.length > 0 && backgroundOptions.length > 0, [assetsLoading, assetsError, modelOptions, outfitOptions, backgroundOptions], ) ``` - [ ] **Step 4: 删除页面对本地静态素材的依赖,并把状态展示改为加载/失败/空态** ```tsx - import { - getHairstyleTemplateAssetsByCategory, - } from '../../../config/hairstyleTemplateAssets' {assetsLoading ? (

{pageCopy.loadingAssets || '正在加载素材...'}

) : null} {!assetsLoading && assetsError ? (

{assetsError}

) : null} ``` - [ ] **Step 5: 保持原提交流程不变,只确保提交使用接口返回的 URL** ```tsx const selectedModel = modelOptions.find((item) => item.id === selectedModelId) const selectedOutfit = outfitOptions.find((item) => item.id === selectedOutfitId) const selectedBackground = backgroundOptions.find((item) => item.id === selectedBackgroundId) if (!selectedModel || !selectedOutfit || !selectedBackground) { throw new Error(pageCopy.validation?.assetsRequired || '请选择模特、服装和背景') } const template = await createTemplateAndStartGeneration({ name, category, status, description, tags: tags.split(',').map((tag) => tag.trim()).filter(Boolean), referenceImage, modelImageUrl: selectedModel.url, outfitImageUrl: selectedOutfit.url, backgroundImageUrl: selectedBackground.url, }) ``` - [ ] **Step 6: 运行定向测试确认通过** Run: `npm test -- src/pages/dashboard/templates/__tests__/TemplateFormPage.test.tsx` Expected: - PASS - 覆盖素材加载成功、接口失败、空组禁用提交、提交使用服务端 URL - [ ] **Step 7: 运行类型检查** Run: `npm run typecheck` Expected: - PASS - 无新增 TypeScript 错误 - [ ] **Step 8: 检查最近编辑文件诊断** Use: `GetDiagnostics` for: - `frontend/src/pages/dashboard/templates/TemplateFormPage.tsx` - `frontend/src/services/api/templates.ts` - `frontend/src/services/api/contracts.ts` Expected: - 无新增诊断;若有简单错误,先修复再继续 - [ ] **Step 9: 提交** ```bash git add frontend/src/pages/dashboard/templates/TemplateFormPage.tsx frontend/src/pages/dashboard/templates/__tests__/TemplateFormPage.test.tsx frontend/src/services/api/templates.ts frontend/src/services/api/contracts.ts frontend/src/i18n/locales/zh/translation.json frontend/src/i18n/locales/ja/translation.json git commit -m "feat: load template form assets from server config" ``` ## Self-Review - **Spec coverage:** 已覆盖三组素材改为服务端接口、失败不回退静态数据、空组禁用提交、保持现有页面结构和旧提交接口不变、补充必要文案与定向测试。 - **Placeholder scan:** 计划中没有 `TODO`、`TBD` 或“稍后实现”类占位语句;每个任务都给了具体文件、命令和代码片段。 - **Type consistency:** `TemplateAssetOption` / `TemplateAssets` / `getTemplateAssets()` 在 Task 1 中定义,并在 Task 2 中按相同命名消费;页面内部映射结构 `TemplateOption` 只在 `TemplateFormPage` 内部使用,不向外暴露。