From 004a34f04fdf3ec6fb8ae43f7fad398b116090c8 Mon Sep 17 00:00:00 2001 From: xyh202131 <246811510+xyh202131@users.noreply.github.com> Date: Tue, 11 Aug 2026 19:02:50 +0800 Subject: [PATCH] revert(quick-start): remove accidentally merged PR 95 --- 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 | 8 - .../workflow-controller/controller.test.ts | 46 - .../workflow-controller/controller.ts | 92 -- frontend/src/pages/quick-start/index.test.tsx | 394 ------ frontend/src/pages/quick-start/index.tsx | 1066 +---------------- .../src/pages/quick-start/service.test.ts | 886 -------------- frontend/src/pages/quick-start/service.ts | 651 ---------- frontend/src/shared/api/index.test.ts | 11 - frontend/src/shared/api/index.ts | 11 - 12 files changed, 14 insertions(+), 3223 deletions(-) delete mode 100644 frontend/src/pages/quick-start/index.test.tsx delete mode 100644 frontend/src/pages/quick-start/service.test.ts delete 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 6991c729..a7aa5132 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 54882a45..1109e673 100644 --- a/frontend/src/entities/workflow-run/api.test.ts +++ b/frontend/src/entities/workflow-run/api.test.ts @@ -356,53 +356,4 @@ 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 b1493317..7760053d 100644 --- a/frontend/src/entities/workflow-run/api.ts +++ b/frontend/src/entities/workflow-run/api.ts @@ -93,7 +93,6 @@ 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') @@ -273,8 +272,8 @@ function getApiClient() { return createApiClient({ getAccessToken: getApiAccessToken }) } -/** 精确对应后端已公开的 CRUD 与项目内分页列表;不声明尚未提供的按 Character 查询。 */ -export const workflowRunApis: WorkflowRunApis & Required> = { +/** 精确对应后端已公开的 CRUD;不声明尚未提供的列表或按 Character 查询。 */ +export const workflowRunApis: WorkflowRunApis = { async create(input) { return mapWorkflowRun( await getApiClient().request('/workflow-runs', { @@ -283,16 +282,6 @@ export const workflowRunApis: WorkflowRunApis & Required('/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 900a8811..7036e104 100644 --- a/frontend/src/entities/workflow-run/index.ts +++ b/frontend/src/entities/workflow-run/index.ts @@ -1,7 +1,6 @@ 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, @@ -47,11 +46,6 @@ interface WorkflowNodeBase { export interface WorkflowCharacterInput { /** 用户填写或后端提取的最终角色名称;旧数据可以没有该字段。 */ name?: string | null - /** - * 当前节点图所属的 Character。后端只原样持久化 nodes,因此前端用它在项目列表中定位角色的唯一 Run。 - * 旧 Run 可能没有该字段;读取方必须兼容未绑定状态。 - */ - characterId?: string | null prompt: string referenceMedia: readonly MediaReference[] } @@ -136,8 +130,6 @@ 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 7da9562a..433605db 100644 --- a/frontend/src/features/workflow-controller/controller.test.ts +++ b/frontend/src/features/workflow-controller/controller.test.ts @@ -253,52 +253,6 @@ 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 04c33039..df2e4f8f 100644 --- a/frontend/src/features/workflow-controller/controller.ts +++ b/frontend/src/features/workflow-controller/controller.ts @@ -81,18 +81,6 @@ 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, @@ -409,83 +397,6 @@ 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, @@ -960,9 +871,6 @@ 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 deleted file mode 100644 index 70bfa9b5..00000000 --- a/frontend/src/pages/quick-start/index.test.tsx +++ /dev/null @@ -1,394 +0,0 @@ -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 299999e5..10e1be5b 100644 --- a/frontend/src/pages/quick-start/index.tsx +++ b/frontend/src/pages/quick-start/index.tsx @@ -1,1063 +1,13 @@ -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} 的当前造型,不会新建角色或覆盖已有动作。 -

-
-