From f2813b69e025425262d324675e63701003628218 Mon Sep 17 00:00:00 2001 From: xyh202131 <246811510+xyh202131@users.noreply.github.com> Date: Tue, 11 Aug 2026 16:18:11 +0800 Subject: [PATCH 01/13] refactor(quick-start): align animation review workflow --- frontend/src/app/app.test.tsx | 8 +- .../src/entities/workflow-run/api.test.ts | 49 + frontend/src/entities/workflow-run/api.ts | 15 +- frontend/src/entities/workflow-run/index.ts | 17 +- frontend/src/features/publish/index.ts | 24 + .../workflow-controller/controller.test.ts | 46 + .../workflow-controller/controller.ts | 92 ++ frontend/src/pages/quick-start/index.test.tsx | 139 +++ frontend/src/pages/quick-start/index.tsx | 1012 ++++++++++++++++- .../src/pages/quick-start/service.test.ts | 212 ++++ frontend/src/pages/quick-start/service.ts | 709 ++++++++++++ 11 files changed, 2308 insertions(+), 15 deletions(-) create mode 100644 frontend/src/features/publish/index.ts create mode 100644 frontend/src/pages/quick-start/index.test.tsx create mode 100644 frontend/src/pages/quick-start/service.test.ts create mode 100644 frontend/src/pages/quick-start/service.ts diff --git a/frontend/src/app/app.test.tsx b/frontend/src/app/app.test.tsx index a7aa5132..6991c729 100644 --- a/frontend/src/app/app.test.tsx +++ b/frontend/src/app/app.test.tsx @@ -53,7 +53,7 @@ describe('AppRoutes authentication boundary', () => { '/?account=login&returnTo=%2Fquick-start%3Fdraft%3D1%23setup', ), ) - expect(screen.queryByRole('heading', { name: '快速开始' })).toBeNull() + expect(screen.queryByRole('heading', { name: /开始一条可追踪的制作流程/ })).toBeNull() }) it('redirects a guest from the PlayTest entry and preserves that return path', async () => { @@ -110,13 +110,13 @@ describe('AppRoutes authentication boundary', () => { , ) - expect(screen.queryByRole('heading', { name: '快速开始' })).toBeNull() + expect(screen.queryByRole('heading', { name: /开始一条可追踪的制作流程/ })).toBeNull() expect(screen.getByTestId('location').textContent).toBe('/quick-start') const restoredTokens = await baseApis.refresh('stored-refresh-token') await act(async () => resolveRefresh(restoredTokens)) - expect(await screen.findByRole('heading', { name: '快速开始' })).toBeTruthy() + expect(await screen.findByRole('heading', { name: /开始一条可追踪的制作流程/ })).toBeTruthy() }) it('renders protected product pages for an authenticated session', async () => { @@ -128,7 +128,7 @@ describe('AppRoutes authentication boundary', () => { , ) - expect(await screen.findByRole('heading', { name: '快速开始' })).toBeTruthy() + expect(await screen.findByRole('heading', { name: /开始一条可追踪的制作流程/ })).toBeTruthy() }) it('tells the user when restoring the session fails instead of becoming a silent guest', async () => { diff --git a/frontend/src/entities/workflow-run/api.test.ts b/frontend/src/entities/workflow-run/api.test.ts index 1109e673..54882a45 100644 --- a/frontend/src/entities/workflow-run/api.test.ts +++ b/frontend/src/entities/workflow-run/api.test.ts @@ -356,4 +356,53 @@ describe('workflowRunApis', () => { kind: 'invalid-response', }) }) + + it('lists project runs and preserves the character binding', async () => { + let requestUrl = '' + const setupNode = nodes[0] + if (setupNode?.type !== 'character-setup') throw new Error('test fixture is invalid') + const listedRun = { + ...workflowRunDto, + nodes: [ + { + ...setupNode, + input: { ...setupNode.input, characterId: 'character-7' }, + }, + ], + } + const apis = await loadWorkflowRunApis(async (input) => { + requestUrl = String(input) + return new Response( + JSON.stringify({ + code: 200, + message: 'success', + data: [listedRun], + total: 1, + page: 2, + page_size: 10, + }), + { headers: { 'content-type': 'application/json' } }, + ) + }) + + await expect(apis.listByProject('42', { page: 2, pageSize: 10 })).resolves.toMatchObject({ + items: [ + { + id: '17', + nodes: [ + { + type: 'character-setup', + input: { characterId: 'character-7' }, + }, + ], + }, + ], + total: 1, + page: 2, + pageSize: 10, + }) + expect(requestUrl).toBe( + 'https://api.windup.test/workflow-runs?project_id=42&page=2&page_size=10', + ) + }) }) diff --git a/frontend/src/entities/workflow-run/api.ts b/frontend/src/entities/workflow-run/api.ts index 7760053d..b1493317 100644 --- a/frontend/src/entities/workflow-run/api.ts +++ b/frontend/src/entities/workflow-run/api.ts @@ -93,6 +93,7 @@ function hasValidCharacterInput(value: unknown): boolean { (typeof value.name === 'string' && value.name.trim().length > 0 && value.name.length <= 20)) && + (value.characterId === undefined || isNullableString(value.characterId)) && typeof value.prompt === 'string' && Array.isArray(value.referenceMedia) && value.referenceMedia.every((item) => typeof item === 'string') @@ -272,8 +273,8 @@ function getApiClient() { return createApiClient({ getAccessToken: getApiAccessToken }) } -/** 精确对应后端已公开的 CRUD;不声明尚未提供的列表或按 Character 查询。 */ -export const workflowRunApis: WorkflowRunApis = { +/** 精确对应后端已公开的 CRUD 与项目内分页列表;不声明尚未提供的按 Character 查询。 */ +export const workflowRunApis: WorkflowRunApis & Required> = { async create(input) { return mapWorkflowRun( await getApiClient().request('/workflow-runs', { @@ -282,6 +283,16 @@ export const workflowRunApis: WorkflowRunApis = { }), ) }, + async listByProject(projectId, query = {}) { + const result = await getApiClient().requestList('/workflow-runs', { + query: { + project_id: toBackendId(projectId, 'projectId'), + page: query.page, + page_size: query.pageSize, + }, + }) + return { ...result, items: result.items.map(mapWorkflowRun) } + }, async get(id) { return mapWorkflowRun( await getApiClient().request(`/workflow-runs/${encodeURIComponent(id)}`), diff --git a/frontend/src/entities/workflow-run/index.ts b/frontend/src/entities/workflow-run/index.ts index 7036e104..e36d8aea 100644 --- a/frontend/src/entities/workflow-run/index.ts +++ b/frontend/src/entities/workflow-run/index.ts @@ -1,6 +1,7 @@ import type { ActionType } from '../character' import type { Generation } from '../generation' import type { MediaReference } from '../media' +import type { Paged, PageQuery } from '@/shared/pagination' import { WORKFLOW_GENERATION_ROLES, WORKFLOW_NODE_PHASES, @@ -46,6 +47,11 @@ interface WorkflowNodeBase { export interface WorkflowCharacterInput { /** 用户填写或后端提取的最终角色名称;旧数据可以没有该字段。 */ name?: string | null + /** + * 当前节点图所属的 Character。后端只原样持久化 nodes,因此前端用它在项目列表中定位角色的唯一 Run。 + * 旧 Run 可能没有该字段;读取方必须兼容未绑定状态。 + */ + characterId?: string | null prompt: string referenceMedia: readonly MediaReference[] } @@ -115,7 +121,7 @@ export type WorkflowNode = export interface WorkflowRun { id: string projectId: string - /** 后端乐观版本号,每次 PATCH 后使用响应中的新值。 */ + /** 后端更新序号;当前仅随 PATCH 递增,不承担并发冲突检测。 */ version: number /** 后端资源状态,仅表示正常或软删除。 */ storageStatus: WorkflowRunStorageStatus @@ -130,9 +136,18 @@ export interface CreateWorkflowRunInput { export interface WorkflowRunApis { create(input: CreateWorkflowRunInput): Promise + /** 后端只返回未软删除的运行记录。 */ + listByProject?(projectId: string, query?: PageQuery): Promise> get(id: WorkflowRun['id']): Promise update(run: WorkflowRun): Promise remove(id: WorkflowRun['id']): Promise } export { workflowRunApis } from './api' +export { + WORKFLOW_GENERATION_ROLES, + WORKFLOW_NODE_PHASES, + WORKFLOW_NODE_STATUSES, + WORKFLOW_NODE_TYPES, + WORKFLOW_RUN_STORAGE_STATUSES, +} from './constants' diff --git a/frontend/src/features/publish/index.ts b/frontend/src/features/publish/index.ts new file mode 100644 index 00000000..ca4e2d55 --- /dev/null +++ b/frontend/src/features/publish/index.ts @@ -0,0 +1,24 @@ +/** + * 资产进入 Character 树由 Quick Start / Workflow Editor 在审核通过时完成。 + * 此处只保留页面跳转和稳定资产 ID 规则,避免再把已废弃的节点内嵌结果 + * 转成第二份资产模型。 + */ +export interface PublishedAssetTarget { + characterId: string + outfitId: string + actionId?: string +} + +export function buildPlaytestPath(target: PublishedAssetTarget): string { + const path = `/playtest/${encodeURIComponent(target.characterId)}/${encodeURIComponent(target.outfitId)}` + return target.actionId ? `${path}?${new URLSearchParams({ actionId: target.actionId })}` : path +} + +/** 同一角色的多个 Action 以 Run 和完整动画节点共同定位,防止互相覆盖。 */ +export function buildPublishedActionId( + characterId: string, + runId: string, + actionNodeId: string, +): string { + return `${characterId}-${runId}-${actionNodeId}` +} diff --git a/frontend/src/features/workflow-controller/controller.test.ts b/frontend/src/features/workflow-controller/controller.test.ts index 433605db..7da9562a 100644 --- a/frontend/src/features/workflow-controller/controller.test.ts +++ b/frontend/src/features/workflow-controller/controller.test.ts @@ -253,6 +253,52 @@ async function flushAsyncWork() { } describe('WorkflowController', () => { + it('绑定角色后拒绝把同一条 WorkflowRun 改绑到另一角色', async () => { + const { controller } = createController() + + await controller.bindCharacter('setup-1', 'character-1') + + expect(controller.getWorkflow().nodes[0]).toMatchObject({ + type: 'character-setup', + input: { characterId: 'character-1' }, + }) + await expect(controller.bindCharacter('setup-1', 'character-2')).rejects.toThrow( + 'WorkflowRun 已绑定到另一角色,不能改绑', + ) + }) + + it('只在角色设定节点仍处于配置阶段时更新提示词和参考媒体', async () => { + const { controller } = createController() + + await controller.updateCharacterSetup('setup-1', { + prompt: '披着红色斗篷的像素骑士', + referenceMedia: ['https://img/reference.png' as never], + }) + + expect(controller.getWorkflow().nodes[0]).toMatchObject({ + input: { + prompt: '披着红色斗篷的像素骑士', + referenceMedia: ['https://img/reference.png'], + }, + }) + }) + + it('接受上传母版时完成角色设定和母版节点', async () => { + const { controller } = createController() + + await controller.acceptUploadedCharacterTemplate('setup-1', 'https://img/uploaded-template.png') + + expect(controller.getWorkflow().nodes).toMatchObject([ + { type: 'character-setup', status: 'passed', phase: 'completed' }, + { + type: 'character-template', + status: 'passed', + phase: 'completed', + selectedImageUrl: 'https://img/uploaded-template.png', + }, + ]) + }) + it('页面通过订阅接收命令保存和 SSE 写回后的同一份 WorkflowRun', async () => { const { controller, generation } = createController() let renderedWorkflow = controller.getWorkflow() diff --git a/frontend/src/features/workflow-controller/controller.ts b/frontend/src/features/workflow-controller/controller.ts index df2e4f8f..04c33039 100644 --- a/frontend/src/features/workflow-controller/controller.ts +++ b/frontend/src/features/workflow-controller/controller.ts @@ -81,6 +81,18 @@ export interface WorkflowController { nodeId: CharacterSetupWorkflowNode['id'], options: GenerateCharacterTemplateOptions, ): Promise + /** 将已创建的 Character 绑定到入口节点;一条 Run 不允许改绑到另一角色。 */ + bindCharacter(nodeId: CharacterSetupWorkflowNode['id'], characterId: string): Promise + /** 仅在入口节点尚未提交时修改角色描述和参考媒体。 */ + updateCharacterSetup( + nodeId: CharacterSetupWorkflowNode['id'], + input: Pick, + ): Promise + /** 使用用户上传的角色母版,显式跳过角色候选图生成。 */ + acceptUploadedCharacterTemplate( + nodeId: CharacterSetupWorkflowNode['id'], + selectedImageUrl: string, + ): Promise confirmCharacterTemplate( nodeId: CharacterTemplateWorkflowNode['id'], selectedImageUrl: string, @@ -397,6 +409,83 @@ export function createWorkflowController({ ) } + function bindCharacter(nodeId: CharacterSetupWorkflowNode['id'], characterId: string) { + ensureRunning() + const normalizedCharacterId = nonEmpty(characterId, 'characterId') + return persist((run) => + updateNode(run, nodeId, (node) => { + if (node.type !== 'character-setup') throw new Error('目标节点不是角色设定') + if (node.input.characterId && node.input.characterId !== normalizedCharacterId) { + throw new Error('WorkflowRun 已绑定到另一角色,不能改绑') + } + return replaceNode(run, { + ...node, + input: { ...node.input, characterId: normalizedCharacterId }, + }) + }), + ) + } + + function updateCharacterSetup( + nodeId: CharacterSetupWorkflowNode['id'], + input: Pick, + ) { + ensureRunning() + const prompt = nonEmpty(input.prompt, 'prompt') + return persist((run) => + updateNode(run, nodeId, (node) => { + if (node.type !== 'character-setup') throw new Error('目标节点不是角色设定') + if (node.status !== 'active' || node.phase !== 'configuring') { + throw new Error('角色设定节点当前不能修改') + } + return replaceNode(run, { + ...node, + input: { + ...node.input, + prompt, + referenceMedia: [...input.referenceMedia], + }, + }) + }), + ) + } + + function acceptUploadedCharacterTemplate( + nodeId: CharacterSetupWorkflowNode['id'], + selectedImageUrl: string, + ) { + ensureRunning() + const imageUrl = nonEmpty(selectedImageUrl, 'selectedImageUrl') + return persist((run) => { + const setupNode = findNode(run, nodeId) + if (setupNode.type !== 'character-setup') throw new Error('目标节点不是角色设定') + if (setupNode.status !== 'active' || setupNode.phase !== 'configuring') { + throw new Error('角色设定节点当前不能使用上传母版') + } + const templateNode = findSingleDependentNode(run, setupNode.id, 'character-template') + if (templateNode.status !== 'locked' || templateNode.phase !== 'ready') { + throw new Error('角色母版节点当前不能使用上传图片') + } + return unlockReadyNodes({ + ...run, + nodes: run.nodes.map((node) => { + if (node.id === setupNode.id) { + return { ...setupNode, status: 'passed', phase: 'completed', error: null } + } + if (node.id === templateNode.id) { + return { + ...templateNode, + selectedImageUrl: imageUrl, + status: 'passed', + phase: 'completed', + } + } + return node + }), + }) + }) + } + function generateFirstFrame( nodeId: ActionFirstFrameWorkflowNode['id'], options: GenerateActionOptions, @@ -871,6 +960,9 @@ export function createWorkflowController({ setCharacterName: asCommand(setCharacterName), addAction: asCommand(addAction), generateCharacterTemplate: asCommand(generateCharacterTemplate), + bindCharacter: asCommand(bindCharacter), + updateCharacterSetup: asCommand(updateCharacterSetup), + acceptUploadedCharacterTemplate: asCommand(acceptUploadedCharacterTemplate), confirmCharacterTemplate: asCommand(confirmCharacterTemplate), generateFirstFrame: asCommand(generateFirstFrame), confirmFirstFrame: asCommand(confirmFirstFrame), diff --git a/frontend/src/pages/quick-start/index.test.tsx b/frontend/src/pages/quick-start/index.test.tsx new file mode 100644 index 00000000..1fa1a663 --- /dev/null +++ b/frontend/src/pages/quick-start/index.test.tsx @@ -0,0 +1,139 @@ +import { cleanup, render, screen, waitFor } from '@testing-library/react' +import { MemoryRouter, Route, Routes } from 'react-router' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import type { QuickStartService } from './service' +import type { WorkflowRun } from '@/entities' +import { QuickStartPage } from './index' + +afterEach(cleanup) + +describe('QuickStartPage', () => { + it('keeps the natural-language creation entry visible when no run is selected', () => { + render( + + + , + ) + + expect(screen.getByRole('heading', { name: /用一句角色设定/u })).toBeTruthy() + }) + + it('shows first-frame confirmation instead of stale character candidates after a template is confirmed', async () => { + const run: WorkflowRun = { + id: 'run-1', + projectId: 'project-1', + version: 1, + storageStatus: 'active', + nodes: [ + { + id: 'character-setup', + type: 'character-setup', + status: 'passed', + phase: 'completed', + dependsOnNodeIds: [], + generations: [], + error: null, + input: { characterId: 'character-1', prompt: '像素骑士', referenceMedia: [] }, + }, + { + id: 'character-template', + type: 'character-template', + status: 'passed', + phase: 'completed', + dependsOnNodeIds: ['character-setup'], + generations: [{ taskId: 'task-template', role: 'character_template' }], + error: null, + selectedImageUrl: 'https://example.test/template.png', + }, + { + id: 'action-walk', + type: 'action-first-frame', + status: 'active', + phase: 'selecting', + dependsOnNodeIds: ['character-template'], + generations: [{ taskId: 'task-first-frame', role: 'first_frame' }], + error: null, + input: { + outfitId: 'outfit-1', + name: '行走', + type: 'custom', + prompt: '向右行走', + fps: 12, + }, + selectedFirstFrameUrl: null, + }, + { + id: 'action-walk:action-generation-method', + type: 'action-generation-method', + status: 'locked', + phase: 'selecting', + dependsOnNodeIds: ['action-walk'], + generations: [], + error: null, + method: null, + }, + { + id: 'action-walk:action-full-frame', + type: 'action-full-frame', + status: 'locked', + phase: 'ready', + dependsOnNodeIds: ['action-walk:action-generation-method'], + generations: [], + error: null, + }, + { + id: 'action-walk:review', + type: 'review', + status: 'locked', + phase: 'reviewing', + dependsOnNodeIds: ['action-walk:action-full-frame'], + generations: [], + error: null, + }, + ], + } + const service = { + unavailableReason: null, + start: vi.fn(), + startWithUploadedTemplate: vi.fn(), + continueWithUploadedTemplate: vi.fn(), + startAction: vi.fn(), + peekWorkflow: vi.fn(() => run), + subscribe: vi.fn((_runId, listener) => { + listener(run) + return () => undefined + }), + resume: vi.fn(async () => run), + interrupt: vi.fn(async () => run), + confirmCandidate: vi.fn(), + confirmFirstFrame: vi.fn(async () => run), + approveReview: vi.fn(async () => run), + getCharacterInfo: vi.fn(() => ({ characterId: 'character-1', outfitId: 'outfit-1' })), + resolveCharacterInfo: vi.fn(async () => ({ + characterId: 'character-1', + outfitId: 'outfit-1', + })), + getTemplateCandidates: vi.fn(async () => ['https://example.test/stale-template.png']), + getFirstFrameCandidates: vi.fn(async () => [ + { index: 0, imageUrl: 'https://example.test/first-frame.png', durationMs: null }, + ]), + getActionFrames: vi.fn(async () => []), + } as unknown as QuickStartService + + const view = render( + + + } /> + + , + ) + + await waitFor(() => { + expect(view.getByRole('heading', { name: '选择动作首帧' })).toBeTruthy() + }) + expect(view.getByRole('img', { name: '动作首帧候选 1' })).toBeTruthy() + expect(view.queryByRole('img', { name: '角色图候选 1' })).toBeNull() + }) +}) +// @vitest-environment jsdom diff --git a/frontend/src/pages/quick-start/index.tsx b/frontend/src/pages/quick-start/index.tsx index 10e1be5b..eb0bb41f 100644 --- a/frontend/src/pages/quick-start/index.tsx +++ b/frontend/src/pages/quick-start/index.tsx @@ -1,13 +1,1009 @@ -import { PageContainer } from '@/shared/ui' +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type ChangeEvent, + type FormEvent, +} from 'react' +import { Link, useNavigate, useParams, useSearchParams } from 'react-router' + +import { + type ActionFirstFrameWorkflowNode, + type CharacterTemplateWorkflowNode, + type WorkflowRun, + type WorkflowNode, + type WorkflowNodeType, +} from '@/entities' +import { buildPlaytestPath, buildPublishedActionId } from '@/features/publish' +import { + unavailableQuickStartService, + type QuickStartFrame, + type QuickStartService, +} from './service' + +export type { + CreateQuickStartServiceOptions, + PrepareQuickStartProject, + QuickStartService, +} from './service' + +const STEP_LABELS: Record = { + 'character-setup': '角色设定', + 'character-template': '角色图', + 'action-first-frame': '候选选择', + 'action-generation-method': '生成路线', + 'action-full-frame': '动作生成', + review: '审核', +} + +const EXAMPLES = [ + { + label: '像素守夜人', + prompt: '一位提着风灯、披深色斗篷的像素守夜人', + }, + { + label: '轻装信使', + prompt: '轻装信使,侧视像素风,轮廓清晰,动作轻快', + }, +] as const + +export interface QuickStartPageProps { + /** + * 页面测试与外层组合可以注入同一份服务实例。 + * 未注入时,Quick Start 自己装配真实实体接口,避免 app 层承担流程细节。 + */ + service?: QuickStartService +} + +/** Quick Start 独立完成 AI 入口;它不跳转 Workflow Editor。 */ +export function QuickStartPage({ service }: QuickStartPageProps) { + const { runId } = useParams() + const [searchParams] = useSearchParams() + const activeService = useMemo(() => { + return service ?? unavailableQuickStartService + }, [service]) + const characterId = searchParams.get('characterId') + const outfitId = searchParams.get('outfitId') + + return runId ? ( + + ) : characterId && outfitId ? ( + + ) : ( + + ) +} + +function QuickStartActionInput({ + service, + target, +}: { + service: QuickStartService + target: { characterId: string; outfitId: string } +}) { + const navigate = useNavigate() + const [description, setDescription] = useState('') + const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState(null) + + async function submit(event: FormEvent) { + event.preventDefault() + const prompt = description.trim() + if (!prompt || submitting || service.unavailableReason) return + setSubmitting(true) + setError(null) + try { + const run = await service.startAction(target, prompt) + navigate(`/quick-start/${encodeURIComponent(run.id)}`) + } catch (cause) { + setError(errorMessage(cause, '创建动作失败,请稍后重试')) + } finally { + setSubmitting(false) + } + } + + return ( +
+ + ← 返回当前 Playtest + +
+

ADD ACTION

+

给当前角色增加动作

+

+ 新动作会追加到角色 {target.characterId} 的当前造型,不会新建角色或覆盖已有动作。 +

+
+