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..900a8811 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[] } @@ -130,6 +136,8 @@ 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 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..70bfa9b5 --- /dev/null +++ b/frontend/src/pages/quick-start/index.test.tsx @@ -0,0 +1,394 @@ +import { StrictMode } from 'react' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { MemoryRouter, Route, Routes, useLocation } from 'react-router' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import type { QuickStartEntryService, QuickStartSession } from './service' +import type { WorkflowRun } from '@/entities' +import { QuickStartPage } from './index' + +afterEach(cleanup) + +function workflow(nodes: WorkflowRun['nodes'], id = 'run-1'): WorkflowRun { + return { id, projectId: 'project-1', version: 1, storageStatus: 'active', nodes } +} + +function setupAndTemplate( + template: Partial> = {}, +): WorkflowRun['nodes'] { + return [ + { + 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: 'active', + phase: 'selecting', + dependsOnNodeIds: ['character-setup'], + generations: [{ taskId: 'template-task', role: 'character_template' }], + error: null, + selectedImageUrl: null, + ...template, + }, + ] +} + +function actionWorkflow( + options: { + firstStatus?: 'active' | 'passed' | 'failed' + firstPhase?: 'generating' | 'selecting' | 'completed' + fullStatus?: 'locked' | 'active' | 'passed' | 'failed' + reviewStatus?: 'locked' | 'active' | 'passed' + error?: string | null + } = {}, +) { + const firstStatus = options.firstStatus ?? 'passed' + const fullStatus = options.fullStatus ?? 'locked' + return workflow([ + ...setupAndTemplate({ status: 'passed', phase: 'completed', selectedImageUrl: 'template.png' }), + { + id: 'action-first', + type: 'action-first-frame', + status: firstStatus, + phase: options.firstPhase ?? (firstStatus === 'passed' ? 'completed' : 'selecting'), + dependsOnNodeIds: ['character-template'], + generations: [{ taskId: 'first-task', role: 'first_frame' }], + error: firstStatus === 'failed' ? (options.error ?? '首帧失败') : null, + input: { outfitId: 'outfit-1', name: '挥手', type: 'custom', prompt: '挥手', fps: 12 }, + selectedFirstFrameUrl: firstStatus === 'passed' ? 'first.png' : null, + }, + { + id: 'method', + type: 'action-generation-method', + status: firstStatus === 'passed' ? 'passed' : 'locked', + phase: firstStatus === 'passed' ? 'completed' : 'selecting', + dependsOnNodeIds: ['action-first'], + generations: [], + error: null, + method: firstStatus === 'passed' ? 'video-cropping' : null, + }, + { + id: 'action-full', + type: 'action-full-frame', + status: fullStatus, + phase: + fullStatus === 'passed' ? 'completed' : fullStatus === 'active' ? 'generating' : 'ready', + dependsOnNodeIds: ['method'], + generations: + fullStatus === 'locked' ? [] : [{ taskId: 'full-task', role: 'complete_animation' }], + error: fullStatus === 'failed' ? (options.error ?? '完整动作失败') : null, + }, + { + id: 'review', + type: 'review', + status: options.reviewStatus ?? 'locked', + phase: options.reviewStatus === 'passed' ? 'completed' : 'reviewing', + dependsOnNodeIds: ['action-full'], + generations: [], + error: null, + }, + ]) +} + +type QuickStartMock = QuickStartEntryService & QuickStartSession + +function serviceFor(run: WorkflowRun | null, overrides: Partial = {}) { + const fallbackRun = run ?? workflow(setupAndTemplate(), 'run-new') + const service: QuickStartMock = { + unavailableReason: null, + runId: fallbackRun.id, + start: vi.fn(async () => service), + startWithUploadedTemplate: vi.fn(async () => service), + open: vi.fn(async () => { + if (!run) throw new Error('not found') + return service + }), + continueWithUploadedTemplate: vi.fn(async () => run!), + startAction: vi.fn(async () => service), + getWorkflow: vi.fn(() => fallbackRun), + subscribe: vi.fn(() => () => undefined), + resume: vi.fn(async () => fallbackRun), + interrupt: vi.fn(async () => fallbackRun), + dispose: vi.fn(), + confirmCandidate: vi.fn(async () => fallbackRun), + getFirstFrameCandidates: vi.fn(async () => []), + confirmFirstFrame: vi.fn(async () => fallbackRun), + approveReview: vi.fn(async () => fallbackRun), + getCharacterInfo: vi.fn(() => ({ characterId: 'character-1', outfitId: 'outfit-1' })), + resolveCharacterInfo: vi.fn(async () => ({ characterId: 'character-1', outfitId: 'outfit-1' })), + getTemplateCandidates: vi.fn(async () => []), + getActionFrames: vi.fn(async () => []), + ...overrides, + } + Object.assign(service, overrides) + return service +} + +function renderAt(path: string, service: QuickStartEntryService) { + function PlaytestLocation() { + const location = useLocation() + return

{`${location.pathname}${location.search}`}

+ } + return render( + + + } /> + } /> + } /> + + , + ) +} + +describe('QuickStartPage', () => { + it('keeps the natural-language creation entry visible when no run is selected', () => { + render( + + + , + ) + + expect(screen.getByRole('heading', { name: /用一句角色设定/u })).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: /像素守夜人/u })) + expect((screen.getByRole('textbox') as HTMLTextAreaElement).value).toContain('像素守夜人') + fireEvent.click(screen.getByRole('button', { name: '轻装信使' })) + expect((screen.getByRole('textbox') as HTMLTextAreaElement).value).toContain('轻装信使') + }) + + it('shows first-frame confirmation instead of stale character candidates after a template is confirmed', async () => { + const run = actionWorkflow({ firstStatus: 'active', firstPhase: 'selecting' }) + const service = serviceFor(run, { + getTemplateCandidates: vi.fn(async () => ['stale-template.png']), + getFirstFrameCandidates: vi.fn(async () => [ + { index: 0, imageUrl: 'first-frame.png', durationMs: null }, + ]), + }) + const view = renderAt('/quick-start/run-1', service) + + await waitFor(() => { + expect(view.getByRole('heading', { name: '选择动作首帧' })).toBeTruthy() + }) + expect(view.getByRole('img', { name: '动作首帧候选 1' })).toBeTruthy() + expect(view.queryByRole('img', { name: '角色图候选 1' })).toBeNull() + }) + + it('submits both text and uploaded-template creation from the natural-language entry', async () => { + const service = serviceFor(null) + const view = renderAt('/quick-start', service) + + fireEvent.click(screen.getByRole('button', { name: /像素守夜人/u })) + fireEvent.click(screen.getByRole('button', { name: '开始生成' })) + await waitFor(() => + expect(service.start).toHaveBeenCalledWith(expect.stringContaining('像素守夜人')), + ) + expect(service.open).not.toHaveBeenCalled() + + view.unmount() + renderAt('/quick-start', service) + const file = new File(['pixels'], 'hero.png', { type: 'image/png' }) + fireEvent.click(screen.getByRole('button', { name: '上传角色母版' })) + fireEvent.change(screen.getByLabelText('上传角色母版'), { target: { files: [file] } }) + expect(screen.getByText('hero.png')).toBeTruthy() + fireEvent.change(screen.getByLabelText('创作指令'), { target: { value: '挥手' } }) + fireEvent.click(screen.getByRole('button', { name: '开始生成' })) + await waitFor(() => + expect(service.startWithUploadedTemplate).toHaveBeenCalledWith( + file, + '挥手', + expect.any(AbortSignal), + ), + ) + }) + + it('hands the created session to the run page under the production StrictMode lifecycle', async () => { + const service = serviceFor(null) + render( + + + + } /> + } /> + + + , + ) + + fireEvent.change(screen.getByLabelText('创作指令'), { target: { value: '像素骑士' } }) + fireEvent.click(screen.getByRole('button', { name: '开始生成' })) + + await waitFor(() => expect(service.resume).toHaveBeenCalled()) + expect(service.open).not.toHaveBeenCalled() + expect(service.dispose).toHaveBeenCalled() + }) + + it('shows entry errors and supports removing an uploaded template', async () => { + const service = serviceFor(null, { + start: vi.fn(async () => Promise.reject(new Error('服务繁忙'))), + }) + renderAt('/quick-start', service) + const file = new File(['pixels'], 'hero.png', { type: 'image/png' }) + fireEvent.change(screen.getByLabelText('上传角色母版'), { target: { files: [file] } }) + fireEvent.click(screen.getByRole('button', { name: '移除图片' })) + expect(screen.queryByText('hero.png')).toBeNull() + fireEvent.change(screen.getByLabelText('创作指令'), { target: { value: '骑士' } }) + fireEvent.click(screen.getByRole('button', { name: '开始生成' })) + expect((await screen.findByRole('alert')).textContent).toContain('服务繁忙') + }) + + it('adds an action to an existing character and reports submission errors', async () => { + const service = serviceFor(null) + const view = renderAt('/quick-start?characterId=character-1&outfitId=outfit-1', service) + fireEvent.change(screen.getByLabelText('动作描述'), { target: { value: '挥手' } }) + fireEvent.click(screen.getByRole('button', { name: '开始生成新动作' })) + await waitFor(() => + expect(service.startAction).toHaveBeenCalledWith( + { characterId: 'character-1', outfitId: 'outfit-1' }, + '挥手', + ), + ) + + view.unmount() + const failed = serviceFor(null, { + startAction: vi.fn(async () => Promise.reject(new Error('动作创建失败'))), + }) + renderAt('/quick-start?characterId=character-1&outfitId=outfit-1', failed) + fireEvent.change(screen.getByLabelText('动作描述'), { target: { value: '挥手' } }) + fireEvent.click(screen.getByRole('button', { name: '开始生成新动作' })) + expect((await screen.findByRole('alert')).textContent).toContain('动作创建失败') + }) + + it('recovers missing runs and returns to the creation entry', async () => { + const service = serviceFor(null) + renderAt('/quick-start/missing', service) + expect(await screen.findByRole('heading', { name: '无法恢复这次创作' })).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: '返回快速开始' })) + expect(screen.getByRole('heading', { name: /用一句角色设定/u })).toBeTruthy() + }) + + it('opens a recoverable run once and accepts its session update', async () => { + const run = workflow(setupAndTemplate()) + const service = serviceFor(run, { + subscribe: vi.fn((listener) => { + listener(run) + return () => undefined + }), + }) + renderAt('/quick-start/run-1', service) + await waitFor(() => expect(service.open).toHaveBeenCalledWith('run-1')) + expect(service.resume).toHaveBeenCalledWith() + }) + + it('selects, confirms, and regenerates a character candidate', async () => { + const run = workflow(setupAndTemplate()) + const service = serviceFor(run, { + getTemplateCandidates: vi.fn(async () => ['https://example.test/candidate.png']), + confirmCandidate: vi.fn(async () => Promise.reject(new Error('候选确认失败'))), + start: vi.fn(async () => Promise.reject(new Error('重新生成失败'))), + }) + renderAt('/quick-start/run-1', service) + const candidate = await screen.findByRole('img', { name: '角色图候选 1' }) + fireEvent.click(candidate) + fireEvent.change(screen.getByLabelText(/动作描述/u), { target: { value: '挥手' } }) + fireEvent.click(screen.getByRole('button', { name: '确认选择,继续下一步' })) + await waitFor(() => + expect(service.confirmCandidate).toHaveBeenCalledWith( + 'https://example.test/candidate.png', + '挥手', + ), + ) + expect((await screen.findByRole('alert')).textContent).toContain('候选确认失败') + fireEvent.click(screen.getByRole('button', { name: '重新生成' })) + await waitFor(() => expect(service.start).toHaveBeenCalledWith('像素骑士')) + expect((await screen.findByRole('alert')).textContent).toContain('重新生成失败') + fireEvent.click(screen.getByRole('button', { name: '新建一次创作' })) + expect(screen.getByRole('heading', { name: /用一句角色设定/u })).toBeTruthy() + }) + + it('confirms a generated first frame before starting the full animation', async () => { + const run = actionWorkflow({ firstStatus: 'active', firstPhase: 'selecting' }) + const service = serviceFor(run, { + getFirstFrameCandidates: vi.fn(async () => [ + { index: 4, imageUrl: 'https://example.test/first.png', durationMs: 80 }, + ]), + confirmFirstFrame: vi.fn(async () => Promise.reject(new Error('首帧确认失败'))), + }) + renderAt('/quick-start/run-1', service) + fireEvent.click(await screen.findByRole('img', { name: '动作首帧候选 1' })) + fireEvent.click(screen.getByRole('button', { name: '确认首帧,生成完整动作' })) + await waitFor(() => + expect(service.confirmFirstFrame).toHaveBeenCalledWith('https://example.test/first.png'), + ) + expect((await screen.findByRole('alert')).textContent).toContain('首帧确认失败') + }) + + it('renders generating and failed states for both first-frame and full animation tasks', async () => { + const states = [ + [actionWorkflow({ firstStatus: 'active', firstPhase: 'generating' }), '正在生成动作首帧'], + [actionWorkflow({ firstStatus: 'failed', error: '首帧服务失败' }), '动作首帧生成失败'], + [actionWorkflow({ fullStatus: 'active' }), '正在生成动作'], + [actionWorkflow({ fullStatus: 'failed', error: '动作服务失败' }), '动作生成失败'], + ] as const + + for (const [run, label] of states) { + const view = renderAt('/quick-start/run-1', serviceFor(run)) + expect((await screen.findAllByText(label, { selector: 'b' })).length).toBeGreaterThan(0) + view.unmount() + } + }) + + it('automatically approves a completed animation and opens its Playtest action', async () => { + const run = actionWorkflow({ fullStatus: 'passed', reviewStatus: 'active' }) + const approved = actionWorkflow({ fullStatus: 'passed', reviewStatus: 'passed' }) + const service = serviceFor(run, { + approveReview: vi.fn(async () => approved), + getActionFrames: vi.fn(async () => [ + { index: 0, imageUrl: 'https://example.test/frame-0.png', durationMs: 80 }, + { index: 1, imageUrl: 'https://example.test/frame-1.png', durationMs: 80 }, + ]), + }) + renderAt('/quick-start/run-1', service) + expect( + await screen.findByRole('heading', { + name: '/playtest/character-1/outfit-1?actionId=action-full', + }), + ).toBeTruthy() + expect(service.approveReview).toHaveBeenCalledWith() + }) + + it('keeps a completed run recoverable when its character binding is missing', async () => { + const run = actionWorkflow({ fullStatus: 'passed', reviewStatus: 'active' }) + const service = serviceFor(run, { + approveReview: vi.fn(async () => + actionWorkflow({ fullStatus: 'passed', reviewStatus: 'passed' }), + ), + getCharacterInfo: vi.fn(() => null), + resolveCharacterInfo: vi.fn(async () => null), + getActionFrames: vi.fn(async () => [ + { index: 0, imageUrl: 'https://example.test/frame.png', durationMs: 80 }, + ]), + }) + renderAt('/quick-start/run-1', service) + expect((await screen.findByRole('alert')).textContent).toContain('没有找到对应的角色资产') + fireEvent.click(screen.getByRole('button', { name: '重新导入 Playtest' })) + await waitFor(() => expect(service.approveReview).toHaveBeenCalledTimes(2)) + }) + + it('interrupts an active run and surfaces interruption failures', async () => { + const run = actionWorkflow({ fullStatus: 'active' }) + const service = serviceFor(run, { + interrupt: vi.fn(async () => Promise.reject(new Error('无法中断'))), + }) + renderAt('/quick-start/run-1', service) + fireEvent.click(await screen.findByRole('button', { name: '中断自动制作' })) + expect((await screen.findByRole('alert')).textContent).toContain('无法中断') + }) +}) +// @vitest-environment jsdom diff --git a/frontend/src/pages/quick-start/index.tsx b/frontend/src/pages/quick-start/index.tsx index 10e1be5b..299999e5 100644 --- a/frontend/src/pages/quick-start/index.tsx +++ b/frontend/src/pages/quick-start/index.tsx @@ -1,13 +1,1063 @@ -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 { + quickStartService, + type QuickStartEntryService, + type QuickStartFrame, + type QuickStartSession, +} from './service' + +export type { + CreateQuickStartServiceOptions, + PrepareQuickStartProject, + QuickStartEntryService, + QuickStartSession, +} 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 + +function playtestPath(characterId: string, outfitId: string, actionId?: string): string { + const path = `/playtest/${encodeURIComponent(characterId)}/${encodeURIComponent(outfitId)}` + return actionId ? `${path}?${new URLSearchParams({ actionId })}` : path +} + +export interface QuickStartPageProps { + /** + * 页面测试与外层组合可以注入同一份服务实例。 + * 未注入时,Quick Start 自己装配真实实体接口,避免 app 层承担流程细节。 + */ + service?: QuickStartEntryService +} + +/** Quick Start 独立完成 AI 入口;它不跳转 Workflow Editor。 */ +export function QuickStartPage({ service }: QuickStartPageProps) { + const { runId } = useParams() + const [searchParams] = useSearchParams() + const activeService = useMemo(() => { + return service ?? quickStartService + }, [service]) + const [createdSession, setCreatedSession] = useState(null) + const characterId = searchParams.get('characterId') + const outfitId = searchParams.get('outfitId') + + return runId ? ( + + ) : characterId && outfitId ? ( + + ) : ( + + ) +} + +function QuickStartActionInput({ + service, + target, + onSessionCreated, +}: { + service: QuickStartEntryService + target: { characterId: string; outfitId: string } + onSessionCreated: (session: QuickStartSession) => void +}) { + 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 session = await service.startAction(target, prompt) + onSessionCreated(session) + navigate(`/quick-start/${encodeURIComponent(session.runId)}`) + } catch (cause) { + setError(errorMessage(cause, '创建动作失败,请稍后重试')) + } finally { + setSubmitting(false) + } + } + + return ( +
+ + ← 返回当前 Playtest + +
+

ADD ACTION

+

给当前角色增加动作

+

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

+
+