Răsfoiți Sursa

feat: load template form assets from server config

ChenYu 4 luni în urmă
părinte
comite
78d2e9c68c

+ 495 - 0
docs/superpowers/plans/2026-06-28-template-form-config-assets-minimal.md

@@ -0,0 +1,495 @@
+# 模板新建页素材数据源切换 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: 写失败测试,锁定页面必须通过接口加载素材并在失败时禁用提交**
+
+```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<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 中补素材类型**
+
+```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<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: 在中日文文案中补充素材加载失败与空态文案**
+
+```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<TemplateAssets>`, `createTemplateAndStartGeneration(input: CreateTemplateAndGenerateInput): Promise<TemplateEntity>`, `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<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: 删除页面对本地静态素材的依赖,并把状态展示改为加载/失败/空态**
+
+```tsx
+- 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**
+
+```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` 内部使用,不向外暴露。

+ 135 - 0
docs/superpowers/specs/2026-06-28-template-form-config-assets-minimal-design.md

@@ -0,0 +1,135 @@
+# 模板新建页素材数据源切换最小设计
+
+## 背景
+
+当前主仓库 `main` 分支上的 [TemplateFormPage.tsx](file:///Users/yuchen/Documents/hairstylist/frontend/src/pages/dashboard/templates/TemplateFormPage.tsx) 仍然使用本地静态素材:
+
+- `选择AI模特`
+- `选择服装`
+- `选择背景`
+
+这三块目前通过 `getHairstyleTemplateAssetsByCategory()` 从本地 `hairstyleTemplateAssets` 读取,而不是从服务端的 `config` 表读取。
+
+用户本次要求只做最小改动:
+
+- 只替换这三个区块的数据来源
+- 其他页面结构和旧提交流程先不改
+- 接口失败时不回退本地静态数据
+
+## 目标
+
+- 让模板新建页中的模特、服装、背景三组素材改为从服务端配置接口读取
+- 前端展示仍保持现有卡片式 UI,不做结构重写
+- 如果配置接口失败或返回为空,页面明确显示异常状态,并阻止继续提交
+
+## 非目标
+
+- 不改当前 `TemplateFormPage` 的其余表单结构
+- 不改当前提交接口 `createTemplateAndStartGeneration()`
+- 不在这次改动中接入模板制作任务页
+- 不保留本地静态素材作为降级兜底
+
+## 推荐方案
+
+采用最小替换方案:
+
+1. 在前端模板 API 层新增一个读取模板素材的方法
+2. 页面初始化时请求服务端素材接口
+3. 将返回结果映射为当前页面已有卡片 UI 所需的数据结构
+4. 删除页面对本地 `hairstyleTemplateAssets` 的依赖
+5. 当素材未加载成功时,显示错误提示并禁用提交按钮
+
+这样可以只清理用户明确指出的旧逻辑,不扩大到整条模板创建链路。
+
+## 接口与数据约定
+
+前端读取服务端模板素材接口,返回三组数据:
+
+- `models`
+- `clothes`
+- `backgrounds`
+
+每个选项至少包含:
+
+- `id`
+- `name`
+- `url`
+- `configKey`
+
+服务端数据来源约定如下:
+
+- `config_group = 'ai-model'` -> `models`
+- `config_group = 'clothing'` -> `clothes`
+- `config_group = 'background'` -> `backgrounds`
+
+前端页面映射规则如下:
+
+- `id` 直接作为选中值
+- `name` 映射为卡片标题
+- `url` 映射为当前卡片图片地址
+
+## 页面行为
+
+### 1. 初始化加载
+
+页面挂载时请求一次模板素材接口。
+
+成功时:
+
+- 将三组接口结果写入本地状态
+- 默认选中每组的第一项
+
+失败时:
+
+- 不回退到本地静态素材
+- 显示“素材加载失败”类提示
+- 禁用顶部和底部的提交按钮
+
+### 2. 空数据处理
+
+如果接口返回成功但某一组为空,也视为“资源未准备完成”:
+
+- 显示提示文案
+- 页面不崩溃
+- 禁用提交按钮
+
+### 3. 提交流程
+
+本次不改旧提交流程,只替换素材来源。
+
+因此提交时仍然:
+
+- 上传参考图
+- 读取当前选中的模特、服装、背景 URL
+- 调用现有 `createTemplateAndStartGeneration()`
+
+也就是说,提交 payload 中使用的三组 URL 仍来自当前选中的服务端配置素材,而不是本地静态素材。
+
+## 影响文件
+
+- 修改 [TemplateFormPage.tsx](file:///Users/yuchen/Documents/hairstylist/frontend/src/pages/dashboard/templates/TemplateFormPage.tsx)
+  - 删除对 `hairstyleTemplateAssets` 的依赖
+  - 新增页面级素材加载状态、错误状态和接口映射
+- 修改 [templates.ts](file:///Users/yuchen/Documents/hairstylist/frontend/src/services/api/templates.ts)
+  - 新增读取模板素材的 API 方法
+- 修改 [contracts.ts](file:///Users/yuchen/Documents/hairstylist/frontend/src/services/api/contracts.ts)
+  - 新增模板素材类型定义
+- 可选修改 [translation.json](file:///Users/yuchen/Documents/hairstylist/frontend/src/i18n/locales/zh/translation.json) / [translation.json](file:///Users/yuchen/Documents/hairstylist/frontend/src/i18n/locales/ja/translation.json)
+  - 如果现有提示文案不够准确,则补充“素材加载失败”相关文案
+
+## 测试策略
+
+本次测试只覆盖真实回归风险:
+
+1. 页面能从接口读取三组素材并渲染
+2. 页面不再依赖本地 `hairstyleTemplateAssets`
+3. 接口失败时显示错误提示并禁用提交
+4. 选择素材后提交时使用接口返回的 URL
+
+不增加与本次目标无关的重构测试。
+
+## 风险与边界
+
+- 因为当前页面仍保留旧提交接口,所以这次只是“清理三组素材来源”,不是整页真实链路切换
+- 如果后续要切到模板制作任务流,应在下一次改动中再统一收敛 `TemplateFormPage`
+- 本次明确不做静态兜底,因此如果服务端配置没准备好,页面将无法提交,这是符合用户当前要求的

+ 3 - 0
frontend/src/i18n/locales/ja/translation.json

@@ -322,6 +322,9 @@
     "submitLead": "送信後、参考画像と選択したモデル・服装・背景をもとにテンプレートを生成します。",
     "generate": "AI生成",
     "assetPreparationHint": "素材の準備がまだ完了していません。先に素材アップロードスクリプトを実行してください。",
+    "loadingAssets": "素材を読み込み中です...",
+    "assetLoadFailed": "素材の読み込みに失敗しました。再読み込みしてもう一度お試しください",
+    "assetEmpty": "素材設定が未完了です。サーバー設定を確認してください",
     "submitting": {
       "uploading": "参考画像をアップロード中です...",
       "generating": "テンプレートを生成中です。少し時間がかかる場合があります...",

+ 3 - 0
frontend/src/i18n/locales/zh/translation.json

@@ -322,6 +322,9 @@
     "submitLead": "提交后,将根据参考图片以及所选模特、服装和背景生成发型模板。",
     "generate": "AI生成",
     "assetPreparationHint": "资源未准备完成,请先执行资源上传脚本",
+    "loadingAssets": "正在加载素材...",
+    "assetLoadFailed": "素材加载失败,请刷新后重试",
+    "assetEmpty": "素材未配置完成,请检查服务器配置",
     "submitting": {
       "uploading": "正在上传参考图...",
       "generating": "正在生成模板,可能需要一点时间...",

+ 101 - 17
frontend/src/pages/dashboard/templates/TemplateFormPage.tsx

@@ -1,14 +1,15 @@
-import React, { useState } from 'react'
+import React, { useEffect, useMemo, useState } from 'react'
 import { Link } from 'react-router-dom'
 import { Helmet } from 'react-helmet-async'
 import { usePageContext, usePreviewUrl } from '../../../shared/pageContext'
 import { DashboardLayout } from '../../../layouts/DashboardLayout'
-import { createTemplateAndStartGeneration } from '../../../services/api/templates'
+import {
+  createTemplateAndStartGeneration,
+  getTemplateAssets,
+} from '../../../services/api/templates'
 import { uploadMedia } from '../../../services/api/media'
 import { ApiError } from '../../../services/http/client'
-import {
-  getHairstyleTemplateAssetsByCategory,
-} from '../../../config/hairstyleTemplateAssets'
+import type { TemplateAssetOption } from '../../../services/api/contracts'
 
 function getSubmitErrorMessage(err: unknown, fallback: string) {
   if (
@@ -22,30 +23,108 @@ function getSubmitErrorMessage(err: unknown, fallback: string) {
   return err instanceof Error ? err.message : fallback
 }
 
+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 = getHairstyleTemplateAssetsByCategory('model')
-  const outfitOptions = getHairstyleTemplateAssetsByCategory('outfit')
-  const backgroundOptions = getHairstyleTemplateAssetsByCategory('background')
-  const hasReadyAssets =
-    modelOptions.length > 0 &&
-    outfitOptions.length > 0 &&
-    backgroundOptions.length > 0
+  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 [name, setName] = useState('')
   const [category, setCategory] = useState('Long')
   const [status, setStatus] = useState('Draft')
   const [description, setDescription] = useState('')
   const [tags, setTags] = useState('')
   const [referenceFile, setReferenceFile] = useState<File | null>(null)
-  const [selectedModelId, setSelectedModelId] = useState(modelOptions[0]?.id ?? '')
-  const [selectedOutfitId, setSelectedOutfitId] = useState(outfitOptions[0]?.id ?? '')
-  const [selectedBackgroundId, setSelectedBackgroundId] = useState(backgroundOptions[0]?.id ?? '')
+  const [selectedModelId, setSelectedModelId] = useState('')
+  const [selectedOutfitId, setSelectedOutfitId] = useState('')
+  const [selectedBackgroundId, setSelectedBackgroundId] = useState('')
   const referencePreview = usePreviewUrl(referenceFile, '/images/stylist-profile/hairstyle/long/User3-4.jpg')
   const [submitting, setSubmitting] = useState(false)
   const [submitPhase, setSubmitPhase] = useState<'idle' | 'uploading' | 'generating'>('idle')
   const [error, setError] = useState('')
 
+  useEffect(() => {
+    let cancelled = false
+
+    const loadAssets = async () => {
+      setAssetsLoading(true)
+      setAssetsError('')
+
+      try {
+        const data = await getTemplateAssets()
+        if (cancelled) return
+
+        const nextModels = toTemplateOptions(data.models)
+        const nextOutfits = toTemplateOptions(data.clothes)
+        const nextBackgrounds = toTemplateOptions(data.backgrounds)
+
+        setModelOptions(nextModels)
+        setOutfitOptions(nextOutfits)
+        setBackgroundOptions(nextBackgrounds)
+
+        if (nextModels.length === 0 || nextOutfits.length === 0 || nextBackgrounds.length === 0) {
+          setSelectedModelId('')
+          setSelectedOutfitId('')
+          setSelectedBackgroundId('')
+          setAssetsError(pageCopy.assetEmpty || '素材未配置完成,请检查服务器配置')
+          return
+        }
+
+        setSelectedModelId(nextModels[0].id)
+        setSelectedOutfitId(nextOutfits[0].id)
+        setSelectedBackgroundId(nextBackgrounds[0].id)
+      } catch {
+        if (!cancelled) {
+          setModelOptions([])
+          setOutfitOptions([])
+          setBackgroundOptions([])
+          setSelectedModelId('')
+          setSelectedOutfitId('')
+          setSelectedBackgroundId('')
+          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],
+  )
+
   const submitMessage =
     submitPhase === 'uploading'
       ? pageCopy.submitting?.uploading || '正在上传参考图...'
@@ -157,9 +236,14 @@ export function TemplateFormPage() {
             <div className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px mt-4">
               <span className="fs-14 fw-700 text-base-color text-uppercase">{pageCopy.model.badge}</span>
               <h2 className="fw-700 text-dark-gray fs-26 mb-25px">{pageCopy.model.title}</h2>
-              {!hasReadyAssets ? (
+              {assetsLoading ? (
                 <p className="fs-14 text-medium-gray mb-20px" role="status">
-                  {pageCopy.assetPreparationHint || '资源未准备完成,请先执行资源上传脚本'}
+                  {pageCopy.loadingAssets || '正在加载素材...'}
+                </p>
+              ) : null}
+              {!assetsLoading && assetsError ? (
+                <p className="fs-14 text-red mb-20px" role="alert">
+                  {assetsError}
                 </p>
               ) : null}
               <div className="template-option-grid">

+ 182 - 0
frontend/src/pages/dashboard/templates/__tests__/TemplateFormPage.test.tsx

@@ -0,0 +1,182 @@
+import { HelmetProvider } from 'react-helmet-async'
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { MemoryRouter } from 'react-router-dom'
+import type { ReactNode } from 'react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { TemplateFormPage } from '../TemplateFormPage'
+
+const { uploadMedia, getTemplateAssets, createTemplateAndStartGeneration, navigate } =
+  vi.hoisted(() => ({
+    uploadMedia: vi.fn(),
+    getTemplateAssets: vi.fn(),
+    createTemplateAndStartGeneration: vi.fn(),
+    navigate: vi.fn(),
+  }))
+
+vi.mock('../../../../layouts/DashboardLayout', () => ({
+  DashboardLayout: ({ children }: { children: ReactNode }) => <>{children}</>,
+}))
+
+vi.mock('../../../../services/api/media', () => ({
+  uploadMedia,
+}))
+
+vi.mock('../../../../services/api/templates', () => ({
+  getTemplateAssets,
+  createTemplateAndStartGeneration,
+}))
+
+vi.mock('../../../../shared/pageContext', () => ({
+  usePageContext: () => ({
+    locale: 'zh',
+    navigate,
+    path: (path = '') => `/zh${path}`,
+    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: '正在提交任务...',
+        },
+      },
+    },
+  }),
+  usePreviewUrl: (_file: File | null, fallback: string) => fallback,
+}))
+
+describe('TemplateFormPage', () => {
+  beforeEach(() => {
+    uploadMedia.mockReset()
+    getTemplateAssets.mockReset()
+    createTemplateAndStartGeneration.mockReset()
+    navigate.mockReset()
+  })
+
+  function renderPage() {
+    return render(
+      <HelmetProvider>
+        <MemoryRouter>
+          <TemplateFormPage />
+        </MemoryRouter>
+      </HelmetProvider>,
+    )
+  }
+
+  it('loads model, outfit and background options from api', async () => {
+    getTemplateAssets.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(getTemplateAssets).toHaveBeenCalledTimes(1)
+  })
+
+  it('shows error and disables submit when assets request fails', async () => {
+    getTemplateAssets.mockRejectedValue(new Error('network error'))
+
+    renderPage()
+
+    expect(await screen.findByRole('alert')).toHaveTextContent('素材加载失败,请刷新后重试')
+    expect(screen.getAllByRole('button', { name: 'AI生成' })[0]).toBeDisabled()
+  })
+
+  it('disables submit when any asset group is empty', async () => {
+    getTemplateAssets.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()
+  })
+
+  it('submits api asset urls instead of local static assets', async () => {
+    getTemplateAssets.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' }],
+    })
+    uploadMedia.mockResolvedValue({ url: 'https://cdn.example.com/reference.jpg' })
+    createTemplateAndStartGeneration.mockResolvedValue({ id: '88' })
+
+    renderPage()
+
+    await screen.findByText('模特A')
+
+    fireEvent.change(screen.getByLabelText(/模板名称/i), { target: { value: '云感长发' } })
+    fireEvent.change(screen.getByLabelText(/说明/i), { target: { value: '说明内容' } })
+
+    const input = document.querySelector('input[type="file"]') as HTMLInputElement
+    fireEvent.change(input, {
+      target: {
+        files: [new File(['demo'], 'reference.jpg', { type: 'image/jpeg' })],
+      },
+    })
+
+    fireEvent.click(screen.getAllByRole('button', { name: 'AI生成' })[0])
+
+    await waitFor(() => {
+      expect(createTemplateAndStartGeneration).toHaveBeenCalledWith(
+        expect.objectContaining({
+          modelImageUrl: 'https://img/model-a.jpg',
+          outfitImageUrl: 'https://img/cloth-a.jpg',
+          backgroundImageUrl: 'https://img/bg-a.jpg',
+        }),
+      )
+    })
+  })
+})

+ 14 - 0
frontend/src/services/api/contracts.ts

@@ -56,6 +56,20 @@ export type TemplateEntity = {
   backgroundImageUrl?: string
   usedCount: number
 }
+
+export type TemplateAssetOption = {
+  id: string
+  name: string
+  url: string
+  configKey: string
+}
+
+export type TemplateAssets = {
+  models: TemplateAssetOption[]
+  clothes: TemplateAssetOption[]
+  backgrounds: TemplateAssetOption[]
+}
+
 export type WorkbenchEntity = MockWorkbench
 export type SimulationResultEntity = {
   id: string

+ 18 - 0
frontend/src/services/api/templates.ts

@@ -1,12 +1,16 @@
 import { http, ApiError, type ListEnvelope } from '../http/client'
 import type {
   GetTemplateByIdParams,
+  TemplateAssetOption,
+  TemplateAssets,
   TemplateDashboardSummary,
   TemplateDetailStats,
   TemplateEntity,
 } from './contracts'
 export type {
   GetTemplateByIdParams,
+  TemplateAssetOption,
+  TemplateAssets,
   TemplateDashboardSummary,
   TemplateDetailStats,
   TemplateEntity,
@@ -94,6 +98,20 @@ export async function listTemplates(
   }))
 }
 
+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 ?? [],
+  }
+}
+
 export async function getTemplateById({ templateId }: GetTemplateByIdParams = {}): Promise<TemplateEntity | null> {
   if (!templateId) return null
   try {