|
@@ -1,495 +0,0 @@
|
|
|
-# 模板新建页素材数据源切换 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` 内部使用,不向外暴露。
|
|
|