2026-06-28-template-form-config-assets-minimal.md 18 KB

模板新建页素材数据源切换 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<T>() from frontend/src/services/http/client.ts
  • Produces: 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:

  • FAIL,原因包括 getTemplateAssets 尚未导出
  • FAIL,原因包括页面仍然从 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:

  • 仍然 FAIL
  • 错误收敛到 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"
    

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<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:

  • 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: 提交

    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 内部使用,不向外暴露。