From 121badc7cb761200ba53e816dbe72bdd2f0ead9a Mon Sep 17 00:00:00 2001 From: xyh202131 <246811510+xyh202131@users.noreply.github.com> Date: Mon, 3 Aug 2026 18:34:19 +0800 Subject: [PATCH] feat(playtest): support multi-action preview flow --- frontend/src/app/app.test.tsx | 8 + frontend/src/app/app.tsx | 18 +- frontend/src/app/layout/app-header.test.tsx | 33 +- frontend/src/app/layout/app-header.tsx | 20 +- frontend/src/app/layout/index.test.tsx | 1 + frontend/src/app/layout/index.tsx | 2 +- frontend/src/entities/character/api.ts | 19 +- frontend/src/entities/character/index.ts | 1 + frontend/src/entities/index.ts | 10 + .../src/entities/playtest-inspection/api.ts | 65 ++ .../src/entities/playtest-inspection/index.ts | 24 + frontend/src/features/publish/index.ts | 12 +- .../workflow-controller/controller.ts | 2 +- .../store-invariants.test.ts | 21 + .../workflow-state.test.ts | 34 + .../workflow-controller/workflow-state.ts | 59 +- frontend/src/pages/playtest/catalog.test.tsx | 279 ++++++++ frontend/src/pages/playtest/catalog.tsx | 665 ++++++++++++++++++ frontend/src/pages/playtest/index.test.tsx | 57 +- frontend/src/pages/playtest/index.tsx | 98 ++- .../playtest/playtest-boundaries.test.ts | 6 +- .../pages/playtest/workbench/acceptance.tsx | 41 +- .../playtest/workbench/action-selector.tsx | 202 +++++- .../workbench/analysis/frame-geometry.test.ts | 1 + .../workbench/analysis/frame-geometry.ts | 12 + .../workbench/analysis/image-geometry.test.ts | 1 + .../workbench/analysis/quality-policy.ts | 9 +- .../analysis/sequence-evidence.test.ts | 61 +- .../workbench/analysis/sequence-evidence.ts | 75 +- .../use-frame-review-evidence.test.tsx | 4 +- .../analysis/use-frame-review-evidence.ts | 34 +- .../workbench/animation-stage.test.tsx | 25 +- .../playtest/workbench/animation-stage.tsx | 4 +- .../playtest/workbench/frame-timeline.tsx | 18 +- .../pages/playtest/workbench/index.test.tsx | 49 +- .../src/pages/playtest/workbench/index.tsx | 380 ++++++---- .../playtest/workbench/playback-controls.tsx | 53 +- .../workbench/use-playtest-inspection.ts | 89 +++ frontend/src/pages/quick-start/index.test.tsx | 144 +++- frontend/src/pages/quick-start/index.tsx | 163 ++++- .../src/pages/quick-start/service.test.ts | 63 ++ frontend/src/pages/quick-start/service.ts | 46 ++ frontend/src/shared/api/http-client.test.ts | 36 + frontend/src/shared/api/http-client.ts | 7 +- 44 files changed, 2567 insertions(+), 384 deletions(-) create mode 100644 frontend/src/entities/playtest-inspection/api.ts create mode 100644 frontend/src/entities/playtest-inspection/index.ts create mode 100644 frontend/src/pages/playtest/catalog.test.tsx create mode 100644 frontend/src/pages/playtest/catalog.tsx create mode 100644 frontend/src/pages/playtest/workbench/use-playtest-inspection.ts create mode 100644 frontend/src/shared/api/http-client.test.ts diff --git a/frontend/src/app/app.test.tsx b/frontend/src/app/app.test.tsx index bc207e1..aa7b679 100644 --- a/frontend/src/app/app.test.tsx +++ b/frontend/src/app/app.test.tsx @@ -34,4 +34,12 @@ describe('App', () => { expect(screen.getByRole('heading', { name: '资产库' })).toBeTruthy() expect(screen.queryByRole('heading', { name: '历史记录' })).toBeNull() }) + + it('provides a dedicated Playtest entry', () => { + window.history.replaceState({}, '', '/playtest') + + render() + + expect(screen.getByRole('heading', { name: 'Playtest' })).toBeTruthy() + }) }) diff --git a/frontend/src/app/app.tsx b/frontend/src/app/app.tsx index 1e22871..7b2a305 100644 --- a/frontend/src/app/app.tsx +++ b/frontend/src/app/app.tsx @@ -4,6 +4,7 @@ import { BrowserRouter, Route, Routes } from 'react-router' import { createCharacterApis, createGenerationApis, + createPlaytestInspectionApis, createProjectApis, createWorkflowRunStore, } from '@/entities' @@ -14,6 +15,7 @@ import { HistoryPage } from '@/pages/history' import { NotFoundPage } from '@/pages/not-found' import { PlaytestDemoPage } from '@/pages/playtest/demo-page' import { PlaytestPage } from '@/pages/playtest' +import { PlaytestCatalogPage } from '@/pages/playtest/catalog' import { ProjectDetailPage } from '@/pages/project-detail' import { ProjectCreatePage } from '@/pages/project-create' import { ProjectsPage } from '@/pages/projects' @@ -24,7 +26,14 @@ import { createAutoPrepareProject, createQuickStartService } from '@/pages/quick import { createWorkflowEditorService } from '@/pages/workflow-editor/service' function PlaytestFromBackend() { - const apis = useMemo(() => ({ characters: createCharacterApis() }), []) + const apis = useMemo( + () => ({ + characters: createCharacterApis(), + projects: createProjectApis(), + inspections: createPlaytestInspectionApis(), + }), + [], + ) return } @@ -72,7 +81,8 @@ export function App() { return { id: project.id, spriteSize: project.spriteSize } }, }) - return { projectApis, characterApis, quickStart, workflowEditor, store } + const playtestCatalog = { projects: projectApis, characters: characterApis } + return { projectApis, characterApis, quickStart, workflowEditor, store, playtestCatalog } }, []) return ( @@ -113,6 +123,10 @@ export function App() { element={} /> } /> + } + /> } /> } /> diff --git a/frontend/src/app/layout/app-header.test.tsx b/frontend/src/app/layout/app-header.test.tsx index b3d4ce8..e9a6308 100644 --- a/frontend/src/app/layout/app-header.test.tsx +++ b/frontend/src/app/layout/app-header.test.tsx @@ -8,7 +8,7 @@ import { AppHeader } from './app-header' afterEach(cleanup) describe('AppHeader', () => { - it('保留三个产品入口,并将工作流路由归入创作', () => { + it('保留产品入口,并将工作流路由归入创作', () => { render( @@ -19,4 +19,35 @@ describe('AppHeader', () => { expect(screen.getByRole('link', { name: '项目' }).getAttribute('href')).toBe('/projects') expect(screen.getByRole('link', { name: '创作' }).getAttribute('aria-current')).toBe('page') }) + + it('在 Playtest 中随页面滚动,其余页面继续悬浮', () => { + const { container, unmount } = render( + + + , + ) + + expect(container.querySelector('header')?.className.split(' ')).toContain('relative') + expect(container.querySelector('header')?.className.split(' ')).not.toContain('fixed') + unmount() + + const projects = render( + + + , + ) + + expect(projects.container.querySelector('header')?.className.split(' ')).toContain('fixed') + }) + + it('将 Playtest 标记为独立核验工作区', () => { + render( + + + , + ) + + expect(screen.getByText('动作预览与质量核验')).toBeTruthy() + expect(screen.queryByText('项目与历史记录')).toBeNull() + }) }) diff --git a/frontend/src/app/layout/app-header.tsx b/frontend/src/app/layout/app-header.tsx index e189a2a..9a78f77 100644 --- a/frontend/src/app/layout/app-header.tsx +++ b/frontend/src/app/layout/app-header.tsx @@ -16,7 +16,12 @@ const productNavigation: ProductNavigationItem[] = [ { to: '/projects', label: '项目', - isActive: (pathname) => pathname.startsWith('/projects') || pathname.startsWith('/playtest'), + isActive: (pathname) => pathname.startsWith('/projects'), + }, + { + to: '/playtest', + label: '预览台', + isActive: (pathname) => pathname.startsWith('/playtest'), }, { to: '/quick-start', @@ -27,7 +32,11 @@ const productNavigation: ProductNavigationItem[] = [ ] function getWorkspaceLabel(pathname: string): { title: string; detail: string } { - if (pathname.startsWith('/projects') || pathname.startsWith('/playtest')) { + if (pathname.startsWith('/playtest')) { + return { title: 'Playtest', detail: '动作预览与质量核验' } + } + + if (pathname.startsWith('/projects')) { return { title: '项目与历史记录', detail: '角色、动作与完成版本' } } @@ -42,9 +51,14 @@ function getWorkspaceLabel(pathname: string): { title: string; detail: string } export function AppHeader() { const { pathname } = useLocation() const workspace = getWorkspaceLabel(pathname) + const isPlaytest = pathname.startsWith('/playtest') return ( -
+
{ it.each([ ['/', '首页'], + ['/playtest', 'Playtest'], ['/playtest/demo', 'Playtest'], ['/workflow-editor/run-1', 'Workflow Editor'], ])('为%s 使用全宽页面容器', (pathname) => { diff --git a/frontend/src/app/layout/index.tsx b/frontend/src/app/layout/index.tsx index 583f86e..4441138 100644 --- a/frontend/src/app/layout/index.tsx +++ b/frontend/src/app/layout/index.tsx @@ -13,7 +13,7 @@ export interface AppShellProps { /** 全站外壳,全局导航常驻。 */ export function AppShell({ children }: AppShellProps) { const { pathname } = useLocation() - const isPlaytestWorkspace = pathname.startsWith('/playtest/') + const isPlaytestWorkspace = pathname.startsWith('/playtest') const isWorkflowWorkspace = pathname.startsWith('/workflow-editor') const isHomePage = pathname === '/' diff --git a/frontend/src/entities/character/api.ts b/frontend/src/entities/character/api.ts index 23eec25..10dc7c9 100644 --- a/frontend/src/entities/character/api.ts +++ b/frontend/src/entities/character/api.ts @@ -8,7 +8,7 @@ import type { Outfit, } from '.' -import { get, patch, post } from '@/shared/api' +import { del, get, patch, post } from '@/shared/api' /* ─── 后端 DTO ─── */ @@ -16,6 +16,7 @@ interface BackendFrame { index: number image_url: string duration_ms: number | null + root_motion?: { dx: number; dy: number } | null } interface BackendAction { @@ -62,7 +63,7 @@ function toFrame(raw: BackendFrame): Frame { return { imageUrl: raw.image_url, durationMs: raw.duration_ms, - rootMotion: null, // 后端不提供根位移 + rootMotion: raw.root_motion ?? null, } } @@ -114,7 +115,7 @@ export function createCharacterApis(): CharacterApis { async listByProject(projectId: string): Promise { // http-client 已解包 ApiEnvelope,data 字段就是角色数组本身 const raw = await get( - `/characters?project_id=${encodeURIComponent(projectId)}`, + `/characters?project_id=${encodeURIComponent(projectId)}&page_size=100`, ) return raw.map(toCharacter) }, @@ -130,6 +131,7 @@ export function createCharacterApis(): CharacterApis { async update(character: Character): Promise { const payload = { + project_id: Number(character.projectId), character_data: { version: 1, outfits: character.outfits.map((outfit) => ({ @@ -148,13 +150,22 @@ export function createCharacterApis(): CharacterApis { index, image_url: frame.imageUrl, duration_ms: frame.durationMs, + root_motion: frame.rootMotion, })), })), })), }, } const raw = await patch(`/characters/${character.id}`, payload) - return toCharacter(raw) + const saved = toCharacter(raw) + if (saved.projectId !== character.projectId) { + throw new Error('后端未保存新的项目归属') + } + return saved + }, + + async remove(id: string): Promise { + await del(`/characters/${id}`) }, } } diff --git a/frontend/src/entities/character/index.ts b/frontend/src/entities/character/index.ts index fda981c..23b4ccd 100644 --- a/frontend/src/entities/character/index.ts +++ b/frontend/src/entities/character/index.ts @@ -134,4 +134,5 @@ export interface CharacterApis { listByProject(projectId: string): Promise create(input: CreateCharacterInput): Promise update(character: Character): Promise + remove(id: Character['id']): Promise } diff --git a/frontend/src/entities/index.ts b/frontend/src/entities/index.ts index e903dee..68a7454 100644 --- a/frontend/src/entities/index.ts +++ b/frontend/src/entities/index.ts @@ -57,6 +57,16 @@ export type { export { createMediaApis } from './media/api' export type { MediaApis, MediaCategory, MediaReference } from './media' +/* Playtest 核验 —— 每个动作当前最新的核验结论,不形成历史版本 */ +export { createPlaytestInspectionApis } from './playtest-inspection/api' +export type { + PlaytestInspection, + PlaytestInspectionApis, + PlaytestInspectionStatus, + PlaytestInspectionTarget, + SavePlaytestInspectionInput, +} from './playtest-inspection' + /* 工作流 —— 节点与运行状态都由前端管理 */ export { createWorkflowRunStore, WORKFLOW_STEP_ORDER } from './workflow-run' export type { diff --git a/frontend/src/entities/playtest-inspection/api.ts b/frontend/src/entities/playtest-inspection/api.ts new file mode 100644 index 0000000..6c62412 --- /dev/null +++ b/frontend/src/entities/playtest-inspection/api.ts @@ -0,0 +1,65 @@ +import { ApiError, get, post } from '@/shared/api' + +import type { + PlaytestInspection, + PlaytestInspectionApis, + PlaytestInspectionTarget, + SavePlaytestInspectionInput, +} from '.' + +interface BackendPlaytestInspection { + id: number + character_id: number + outfit_id: string + action_id: string + status: PlaytestInspection['status'] + create_at: string + update_at: string +} + +function toInspection(raw: BackendPlaytestInspection): PlaytestInspection { + return { + id: String(raw.id), + characterId: String(raw.character_id), + outfitId: raw.outfit_id, + actionId: raw.action_id, + status: raw.status, + createdAt: raw.create_at, + updatedAt: raw.update_at, + } +} + +function queryFor(target: PlaytestInspectionTarget): string { + const query = new URLSearchParams({ + character_id: target.characterId, + outfit_id: target.outfitId, + action_id: target.actionId, + }) + return query.toString() +} + +export function createPlaytestInspectionApis(): PlaytestInspectionApis { + return { + async get(target) { + try { + const raw = await get( + `/playtest-inspections?${queryFor(target)}`, + ) + return toInspection(raw) + } catch (cause) { + if (cause instanceof ApiError && cause.code === 404) return null + throw cause + } + }, + + async save(input: SavePlaytestInspectionInput) { + const raw = await post('/playtest-inspections', { + character_id: Number(input.characterId), + outfit_id: input.outfitId, + action_id: input.actionId, + status: input.status, + }) + return toInspection(raw) + }, + } +} diff --git a/frontend/src/entities/playtest-inspection/index.ts b/frontend/src/entities/playtest-inspection/index.ts new file mode 100644 index 0000000..2a16016 --- /dev/null +++ b/frontend/src/entities/playtest-inspection/index.ts @@ -0,0 +1,24 @@ +/** Playtest 对某个动作保存的当前核验结论,不属于资产或创作历史。 */ +export type PlaytestInspectionStatus = 'passed' | 'issues_found' + +export interface PlaytestInspectionTarget { + characterId: string + outfitId: string + actionId: string +} + +export interface PlaytestInspection extends PlaytestInspectionTarget { + id: string + status: PlaytestInspectionStatus + createdAt: string + updatedAt: string +} + +export interface SavePlaytestInspectionInput extends PlaytestInspectionTarget { + status: PlaytestInspectionStatus +} + +export interface PlaytestInspectionApis { + get(target: PlaytestInspectionTarget): Promise + save(input: SavePlaytestInspectionInput): Promise +} diff --git a/frontend/src/features/publish/index.ts b/frontend/src/features/publish/index.ts index b8448d0..6bb29fe 100644 --- a/frontend/src/features/publish/index.ts +++ b/frontend/src/features/publish/index.ts @@ -19,6 +19,11 @@ const ACTION_NAMES: Record = { custom: '自定义动作', } +/** 动作 ID 绑定本次运行,允许同一角色保存多个 custom 或同类型动作。 */ +export function buildPublishedActionId(characterId: string, runId: string): string { + return `${characterId}-${runId}` +} + /** 审核通过时才把 WorkflowRun 中的完整动画写入正式 Character 资产树。 */ export async function publishWorkflowRun( characterApis: CharacterApis, @@ -34,11 +39,14 @@ export async function publishWorkflowRun( const character = await characterApis.get(run.characterId) const outfit = character.outfits.find((item) => item.id === run.outfitId) if (!outfit) throw new Error('角色中没有找到工作流关联的造型') - const actionId = `${character.id}-${result.actionType}` + const actionId = buildPublishedActionId(character.id, run.id) const action = { id: actionId, outfitId: outfit.id, - name: ACTION_NAMES[result.actionType], + name: + result.actionType === 'custom' + ? run.prompt?.trim() || '自定义动作' + : ACTION_NAMES[result.actionType], kind: 'custom' as const, type: result.actionType, fps: 8, diff --git a/frontend/src/features/workflow-controller/controller.ts b/frontend/src/features/workflow-controller/controller.ts index d973eb4..ff944e8 100644 --- a/frontend/src/features/workflow-controller/controller.ts +++ b/frontend/src/features/workflow-controller/controller.ts @@ -24,7 +24,7 @@ import { type CreateWorkflowRunStateInput, } from './workflow-state' -/** 首个纵切只开放创建角色;增加动作进入对应步骤实现时再加入 Controller。 */ +/** 创建角色与给已有角色增加动作共用同一条运行状态机。 */ export type CreateWorkflowControllerInput = CreateWorkflowRunStateInput export interface WorkflowController { diff --git a/frontend/src/features/workflow-controller/store-invariants.test.ts b/frontend/src/features/workflow-controller/store-invariants.test.ts index 9ea98ac..3a5ec50 100644 --- a/frontend/src/features/workflow-controller/store-invariants.test.ts +++ b/frontend/src/features/workflow-controller/store-invariants.test.ts @@ -110,6 +110,27 @@ function expectRefreshable( } describe('store invariants across every state transition', () => { + it('an add_action run survives refresh before generation starts', () => { + const harness = createHarness() + const created = harness.controller.create({ + projectId: 'project-1', + purpose: 'add_action', + driver: 'ai', + prompt: '挥手', + characterId: 'character-1', + outfitId: 'outfit-1', + characterTemplateUrl: 'https://example.com/template.png', + baseFrameUrls: [], + }) + + const restored = expectRefreshable(harness, created.id, '增加动作运行创建后') + expect(restored.characterId).toBe('character-1') + expect(restored.outfitId).toBe('outfit-1') + expect( + restored.revisions[0]?.steps.find((step) => step.type === 'action-generation')?.status, + ).toBe('active') + }) + it('every step of the happy path survives a refresh', async () => { const harness = createHarness() diff --git a/frontend/src/features/workflow-controller/workflow-state.test.ts b/frontend/src/features/workflow-controller/workflow-state.test.ts index 5e2924a..1ab2d7a 100644 --- a/frontend/src/features/workflow-controller/workflow-state.test.ts +++ b/frontend/src/features/workflow-controller/workflow-state.test.ts @@ -52,6 +52,40 @@ describe('workflow state transitions', () => { }) }) + it('starts add_action directly at action generation for the existing outfit', () => { + const run = createWorkflowRunState( + { + projectId: 'project-1', + purpose: 'add_action', + driver: 'ai', + prompt: '挥手打招呼', + characterId: 'character-1', + outfitId: 'outfit-1', + characterTemplateUrl: 'https://example.com/template.png', + baseFrameUrls: [], + }, + { + runId: 'run-action-1', + revisionId: 'revision-action-1', + createdAt: CREATED_AT, + }, + ) + + expect(run).toMatchObject({ + purpose: 'add_action', + characterId: 'character-1', + outfitId: 'outfit-1', + prompt: '挥手打招呼', + }) + expect(run.revisions[0]?.steps.map(({ type, status }) => ({ type, status }))).toEqual([ + { type: 'character-setup', status: 'passed' }, + { type: 'character-template', status: 'passed' }, + { type: 'template-candidate', status: 'passed' }, + { type: 'action-generation', status: 'active' }, + { type: 'review', status: 'locked' }, + ]) + }) + it('normalizes character setup input before storing it', () => { const updated = updateCharacterSetupState(createRun(), { description: ' revised knight ', diff --git a/frontend/src/features/workflow-controller/workflow-state.ts b/frontend/src/features/workflow-controller/workflow-state.ts index 8dc5760..ac93856 100644 --- a/frontend/src/features/workflow-controller/workflow-state.ts +++ b/frontend/src/features/workflow-controller/workflow-state.ts @@ -12,10 +12,7 @@ import { type WorkflowStepType, } from '@/entities' -export type CreateWorkflowRunStateInput = Extract< - CreateWorkflowRunInput, - { purpose: 'create_character' } -> +export type CreateWorkflowRunStateInput = CreateWorkflowRunInput export interface CreateWorkflowRunStateOptions { runId: WorkflowRun['id'] @@ -38,12 +35,13 @@ export function createWorkflowRunState( { runId, revisionId, createdAt }: CreateWorkflowRunStateOptions, ): WorkflowRun { const prompt = input.prompt?.trim() || null + const steps = createInitialSteps(input, revisionId, prompt) return { id: runId, projectId: input.projectId, - characterId: null, - outfitId: null, + characterId: input.purpose === 'add_action' ? input.characterId : null, + outfitId: input.purpose === 'add_action' ? input.outfitId : null, purpose: input.purpose, driver: input.driver, status: 'active', @@ -54,9 +52,7 @@ export function createWorkflowRunState( basedOnRevisionId: null, restartStepId: null, status: 'active', - steps: WORKFLOW_STEP_ORDER.map((type, index) => - createInitialStep(type, revisionId, index, prompt), - ), + steps, generationStatus: 'not_started', exportStatus: 'not_exported', createdAt, @@ -66,6 +62,51 @@ export function createWorkflowRunState( } } +function createInitialSteps( + input: CreateWorkflowRunStateInput, + revisionId: string, + prompt: string | null, +): WorkflowStep[] { + const steps = WORKFLOW_STEP_ORDER.map((type, index) => + createInitialStep(type, revisionId, index, prompt), + ) + if (input.purpose === 'create_character') return steps + + return steps.map((step) => { + if (step.type === 'character-setup') { + return { + ...step, + status: 'passed' as const, + input: { + description: prompt ?? '为已有角色添加动作', + referenceMedia: [], + }, + } + } + if (step.type === 'character-template') { + return { + ...step, + status: 'passed' as const, + output: { + type: 'character_template' as const, + images: [{ url: input.characterTemplateUrl }], + }, + } + } + if (step.type === 'template-candidate') { + return { + ...step, + status: 'passed' as const, + output: { selectedImageUrl: input.characterTemplateUrl }, + } + } + if (step.type === 'action-generation') { + return { ...step, status: 'active' as const } + } + return step + }) +} + export function getCurrentRevision(run: WorkflowRun): WorkflowRevision { const revision = run.revisions.find((item) => item.id === run.currentRevisionId) if (!revision) throw new Error(`WorkflowRun ${run.id} 的 currentRevisionId 无效`) diff --git a/frontend/src/pages/playtest/catalog.test.tsx b/frontend/src/pages/playtest/catalog.test.tsx new file mode 100644 index 0000000..f415508 --- /dev/null +++ b/frontend/src/pages/playtest/catalog.test.tsx @@ -0,0 +1,279 @@ +/** @vitest-environment jsdom */ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { MemoryRouter } from 'react-router' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import type { Character } from '@/entities/character' +import type { Project } from '@/entities/project' + +import { PlaytestCatalogPage, type PlaytestCatalogApis } from './catalog' + +const project: Project = { + id: '37', + ownerId: '1', + name: '灯笼守夜人', + perspective: 'side', + directionalMovement: 'single', + spriteSize: { width: 256, height: 256 }, + gameStyle: null, + sampleImageUrl: null, + createdAt: '2026-08-03T00:00:00.000Z', + updatedAt: '2026-08-03T00:00:00.000Z', +} + +const targetProject: Project = { + ...project, + id: '38', + name: '第二个项目', +} + +const character: Character = { + id: '25', + projectId: project.id, + createdAt: '', + updatedAt: '', + outfits: [ + { + id: 'outfit-25-default', + characterId: '25', + name: '默认造型', + candidateCharacterTemplates: [], + characterTemplateUrl: 'https://cdn.example.test/character.png', + baseFrames: [], + actions: [ + { + id: '25-custom', + outfitId: 'outfit-25-default', + name: '挥舞灯笼', + kind: 'custom', + type: 'custom', + fps: 8, + keyFrameIndex: 0, + frames: [ + { imageUrl: 'https://cdn.example.test/frame.png', durationMs: 125, rootMotion: null }, + ], + }, + ], + }, + ], +} + +function renderCatalog(apis: PlaytestCatalogApis) { + render( + + + , + ) +} + +afterEach(() => { + cleanup() + vi.restoreAllMocks() +}) + +describe('PlaytestCatalogPage', () => { + it('loads published assets across projects and links directly to each action', async () => { + const apis: PlaytestCatalogApis = { + projects: { + list: vi.fn().mockResolvedValue({ items: [project], total: 1, page: 1, pageSize: 1 }), + remove: vi.fn(), + }, + characters: { + listByProject: vi.fn().mockResolvedValue([character]), + update: vi.fn(), + remove: vi.fn(), + }, + } + + renderCatalog(apis) + + expect(await screen.findByRole('heading', { name: 'Playtest' })).toBeTruthy() + expect(screen.getByRole('link', { name: '返回项目' }).getAttribute('href')).toBe('/projects') + expect(screen.getByRole('heading', { name: '灯笼守夜人' })).toBeTruthy() + expect(screen.getByText('挥舞灯笼')).toBeTruthy() + expect(screen.getByRole('link', { name: '载入挥舞灯笼' }).getAttribute('href')).toBe( + '/playtest/25/outfit-25-default?actionId=25-custom', + ) + }) + + it('does not treat an outfit without actions as an importable asset', async () => { + const emptyCharacter: Character = { + ...character, + outfits: [{ ...character.outfits[0]!, actions: [] }], + } + renderCatalog({ + projects: { + list: vi.fn().mockResolvedValue({ items: [project], total: 1, page: 1, pageSize: 1 }), + remove: vi.fn(), + }, + characters: { + listByProject: vi.fn().mockResolvedValue([emptyCharacter]), + update: vi.fn(), + remove: vi.fn(), + }, + }) + + expect(await screen.findByText('空文件夹')).toBeTruthy() + }) + + it('opens projects as folders and persists a dragged character in the target project', async () => { + const update = vi.fn().mockImplementation(async (next: Character) => next) + renderCatalog({ + projects: { + list: vi.fn().mockResolvedValue({ + items: [project, targetProject], + total: 2, + page: 1, + pageSize: 2, + }), + remove: vi.fn(), + }, + characters: { + listByProject: vi + .fn() + .mockImplementation(async (projectId: string) => + projectId === project.id ? [character] : [], + ), + update, + remove: vi.fn(), + }, + }) + + const cardTitle = await screen.findByText('默认造型') + const card = cardTitle.closest('article') + const targetFolder = screen.getByRole('region', { name: '第二个项目项目文件夹' }) + const transfer = { + effectAllowed: '', + setData: vi.fn(), + getData: vi.fn().mockReturnValue(character.id), + } + + fireEvent.dragStart(card!, { dataTransfer: transfer }) + fireEvent.dragEnter(targetFolder, { dataTransfer: transfer }) + fireEvent.drop(targetFolder, { dataTransfer: transfer }) + + await waitFor(() => expect(update).toHaveBeenCalled()) + expect(update.mock.calls[0]?.[0].projectId).toBe(targetProject.id) + expect(await screen.findByText('已将角色资产移动到“第二个项目”')).toBeTruthy() + }) + + it('does not report a move as successful when the backend keeps the old project', async () => { + renderCatalog({ + projects: { + list: vi.fn().mockResolvedValue({ + items: [project, targetProject], + total: 2, + page: 1, + pageSize: 2, + }), + remove: vi.fn(), + }, + characters: { + listByProject: vi + .fn() + .mockImplementation(async (projectId: string) => + projectId === project.id ? [character] : [], + ), + update: vi.fn().mockResolvedValue(character), + remove: vi.fn(), + }, + }) + + const card = (await screen.findByText('默认造型')).closest('article') + const targetFolder = screen.getByRole('region', { name: '第二个项目项目文件夹' }) + const transfer = { + effectAllowed: '', + setData: vi.fn(), + getData: vi.fn().mockReturnValue(character.id), + } + + fireEvent.dragStart(card!, { dataTransfer: transfer }) + fireEvent.drop(targetFolder, { dataTransfer: transfer }) + + expect(await screen.findByText('后端未保存新的项目归属')).toBeTruthy() + expect(screen.queryByText('已将角色资产移动到“第二个项目”')).toBeNull() + expect(screen.getByRole('heading', { name: project.name })).toBeTruthy() + }) + + it('renames an asset and an action through the character update API', async () => { + const update = vi.fn().mockImplementation(async (next: Character) => next) + renderCatalog({ + projects: { + list: vi.fn().mockResolvedValue({ items: [project], total: 1, page: 1, pageSize: 1 }), + remove: vi.fn(), + }, + characters: { + listByProject: vi.fn().mockResolvedValue([character]), + update, + remove: vi.fn(), + }, + }) + + await screen.findByText('默认造型') + fireEvent.click(screen.getByRole('button', { name: '重命名资产名称 默认造型' })) + fireEvent.change(screen.getByRole('textbox', { name: '资产名称' }), { + target: { value: '夜巡造型' }, + }) + fireEvent.click(screen.getByRole('button', { name: '保存资产名称' })) + + await waitFor(() => expect(update).toHaveBeenCalledTimes(1)) + expect(update.mock.calls[0]?.[0].outfits[0].name).toBe('夜巡造型') + + fireEvent.click(screen.getByRole('button', { name: '重命名动作名称 挥舞灯笼' })) + fireEvent.change(screen.getByRole('textbox', { name: '动作名称' }), { + target: { value: '举起灯笼' }, + }) + fireEvent.click(screen.getByRole('button', { name: '保存动作名称' })) + + await waitFor(() => expect(update).toHaveBeenCalledTimes(2)) + expect(update.mock.calls[1]?.[0].outfits[0].actions[0].name).toBe('举起灯笼') + }) + + it('deletes a single asset after confirmation', async () => { + vi.spyOn(window, 'confirm').mockReturnValue(true) + const remove = vi.fn().mockResolvedValue(undefined) + renderCatalog({ + projects: { + list: vi.fn().mockResolvedValue({ items: [project], total: 1, page: 1, pageSize: 1 }), + remove: vi.fn(), + }, + characters: { + listByProject: vi.fn().mockResolvedValue([character]), + update: vi.fn(), + remove, + }, + }) + + await screen.findByText('默认造型') + fireEvent.click(screen.getByRole('button', { name: '删除资产 默认造型' })) + + await waitFor(() => expect(remove).toHaveBeenCalledWith(character.id)) + expect(await screen.findByText('已删除资产“默认造型”')).toBeTruthy() + expect(screen.queryByText('默认造型')).toBeNull() + }) + + it('delegates atomic project cleanup to one backend request', async () => { + vi.spyOn(window, 'confirm').mockReturnValue(true) + const removeCharacter = vi.fn().mockResolvedValue(undefined) + const removeProject = vi.fn().mockResolvedValue(undefined) + renderCatalog({ + projects: { + list: vi.fn().mockResolvedValue({ items: [project], total: 1, page: 1, pageSize: 1 }), + remove: removeProject, + }, + characters: { + listByProject: vi.fn().mockResolvedValue([character]), + update: vi.fn(), + remove: removeCharacter, + }, + }) + + await screen.findByText('默认造型') + fireEvent.click(screen.getByRole('button', { name: `删除项目文件夹 ${project.name}` })) + + await waitFor(() => expect(removeProject).toHaveBeenCalledWith(project.id)) + expect(removeCharacter).not.toHaveBeenCalled() + expect(await screen.findByText(`已删除项目“${project.name}”`)).toBeTruthy() + expect(screen.queryByRole('region', { name: `${project.name}项目文件夹` })).toBeNull() + }) +}) diff --git a/frontend/src/pages/playtest/catalog.tsx b/frontend/src/pages/playtest/catalog.tsx new file mode 100644 index 0000000..080d734 --- /dev/null +++ b/frontend/src/pages/playtest/catalog.tsx @@ -0,0 +1,665 @@ +import { type DragEvent, type FormEvent, type ReactNode, useEffect, useState } from 'react' +import { Link } from 'react-router' + +import type { Action, Character, CharacterApis, Outfit } from '@/entities/character' +import type { Project, ProjectApis } from '@/entities/project' +import { buildPlaytestPath } from '@/features/publish' + +export interface PlaytestCatalogApis { + projects: Pick + characters: Pick +} + +interface CatalogAsset { + project: Project + character: Character + outfit: Outfit +} + +interface CatalogState { + projects: Project[] + assets: CatalogAsset[] + error: string | null + loading: boolean +} + +interface CatalogData { + projects: Project[] + assets: CatalogAsset[] +} + +const INITIAL_STATE: CatalogState = { projects: [], assets: [], error: null, loading: true } + +/** Playtest 入口按项目组织可核验角色,移动和改名通过 Character 整树接口持久化。 */ +export function PlaytestCatalogPage({ apis }: { apis: PlaytestCatalogApis }) { + const [state, setState] = useState(INITIAL_STATE) + const [reloadKey, setReloadKey] = useState(0) + const [selectedProjectId, setSelectedProjectId] = useState(null) + const [busyCharacterIds, setBusyCharacterIds] = useState>(new Set()) + const [busyProjectIds, setBusyProjectIds] = useState>(new Set()) + const [draggedCharacterId, setDraggedCharacterId] = useState(null) + const [dropProjectId, setDropProjectId] = useState(null) + const [operationMessage, setOperationMessage] = useState(null) + + useEffect(() => { + let active = true + setState(INITIAL_STATE) + + void loadCatalog(apis).then( + ({ projects, assets }) => { + if (!active) return + setState({ projects, assets, error: null, loading: false }) + setSelectedProjectId((current) => + current && projects.some((project) => project.id === current) + ? current + : (projects[0]?.id ?? null), + ) + }, + (cause: unknown) => + active && + setState({ + projects: [], + assets: [], + error: errorMessage(cause, '资产加载失败'), + loading: false, + }), + ) + + return () => { + active = false + } + }, [apis, reloadKey]) + + const setCharacterBusy = (characterId: string, busy: boolean) => { + setBusyCharacterIds((current) => { + const next = new Set(current) + if (busy) next.add(characterId) + else next.delete(characterId) + return next + }) + } + + const persistCharacter = async (character: Character, successMessage: string) => { + setCharacterBusy(character.id, true) + setOperationMessage(null) + try { + const saved = await apis.characters.update(character) + if (saved.projectId !== character.projectId) { + throw new Error('后端未保存新的项目归属') + } + setState((current) => replaceCharacter(current, saved)) + setOperationMessage(successMessage) + } catch (cause) { + setOperationMessage(errorMessage(cause, '保存失败,请重试')) + throw cause + } finally { + setCharacterBusy(character.id, false) + } + } + + const moveCharacter = async (characterId: string, projectId: string) => { + const source = state.assets.find((asset) => asset.character.id === characterId) + const target = state.projects.find((project) => project.id === projectId) + if (!source || !target || source.character.projectId === projectId) return + + try { + await persistCharacter( + { ...source.character, projectId }, + `已将角色资产移动到“${target.name}”`, + ) + setSelectedProjectId(projectId) + } catch { + // persistCharacter 已在页面上保留具体错误。 + } + } + + const renameOutfit = async (character: Character, outfitId: string, name: string) => { + await persistCharacter( + { + ...character, + outfits: character.outfits.map((outfit) => + outfit.id === outfitId ? { ...outfit, name } : outfit, + ), + }, + `资产已改名为“${name}”`, + ) + } + + const renameAction = async ( + character: Character, + outfitId: string, + actionId: string, + name: string, + ) => { + await persistCharacter( + { + ...character, + outfits: character.outfits.map((outfit) => + outfit.id === outfitId + ? { + ...outfit, + actions: outfit.actions.map((action) => + action.id === actionId ? { ...action, name } : action, + ), + } + : outfit, + ), + }, + `动作已改名为“${name}”`, + ) + } + + const deleteAsset = async (character: Character, outfit: Outfit) => { + if (!window.confirm(`确定删除资产“${outfit.name}”吗?此操作无法撤销。`)) return + + setCharacterBusy(character.id, true) + setOperationMessage(null) + try { + if (character.outfits.length === 1) { + await apis.characters.remove(character.id) + setState((current) => ({ + ...current, + assets: current.assets.filter((asset) => asset.character.id !== character.id), + })) + } else { + const saved = await apis.characters.update({ + ...character, + outfits: character.outfits.filter((candidate) => candidate.id !== outfit.id), + }) + setState((current) => replaceCharacter(current, saved)) + } + setOperationMessage(`已删除资产“${outfit.name}”`) + } catch (cause) { + setOperationMessage(errorMessage(cause, '资产删除失败,请重试')) + } finally { + setCharacterBusy(character.id, false) + } + } + + const deleteProject = async (project: Project) => { + if (!window.confirm(`确定删除项目“${project.name}”及其中全部角色资产吗?此操作无法撤销。`)) { + return + } + + setBusyProjectIds((current) => new Set(current).add(project.id)) + setOperationMessage(null) + try { + await apis.projects.remove(project.id) + setState((current) => ({ + ...current, + projects: current.projects.filter((candidate) => candidate.id !== project.id), + assets: current.assets.filter((asset) => asset.project.id !== project.id), + })) + setSelectedProjectId((current) => + current === project.id + ? (state.projects.find((candidate) => candidate.id !== project.id)?.id ?? null) + : current, + ) + setOperationMessage(`已删除项目“${project.name}”`) + } catch (cause) { + setOperationMessage(errorMessage(cause, '项目删除失败,请重试')) + } finally { + setBusyProjectIds((current) => { + const next = new Set(current) + next.delete(project.id) + return next + }) + } + } + + const selectedProject = state.projects.find((project) => project.id === selectedProjectId) ?? null + const selectedAssets = selectedProject + ? state.assets.filter((asset) => asset.project.id === selectedProject.id) + : [] + + return ( +
+ + + 返回项目 + + +
+
+

+ PLAYTEST +

+

Playtest

+

选择项目和角色动作进行核验。

+
+ +
+ + {state.error ? {state.error} : null} + {operationMessage ? {operationMessage} : null} + + {!state.error && !state.loading && state.projects.length === 0 ? ( +
+

还没有项目。

+ + 创建角色动作 + +
+ ) : null} + +
+ + +
+ {selectedProject ? ( + <> +
+

当前文件夹

+
+

{selectedProject.name}

+ + {selectedAssets.length} 项资产 + +
+
+
+ {selectedAssets.length === 0 ? ( +

空文件夹

+ ) : ( + selectedAssets.map((asset) => ( + { + event.dataTransfer.effectAllowed = 'move' + event.dataTransfer.setData( + 'application/x-windup-character', + asset.character.id, + ) + setDraggedCharacterId(asset.character.id) + }} + onDragEnd={() => { + setDraggedCharacterId(null) + setDropProjectId(null) + }} + onRenameOutfit={(name) => + renameOutfit(asset.character, asset.outfit.id, name) + } + onRenameAction={(actionId, name) => + renameAction(asset.character, asset.outfit.id, actionId, name) + } + onDelete={() => deleteAsset(asset.character, asset.outfit)} + /> + )) + )} +
+ + ) : ( +

请选择项目文件夹

+ )} +
+
+
+ ) +} + +function AssetCard({ + asset, + busy, + onDragStart, + onDragEnd, + onRenameOutfit, + onRenameAction, + onDelete, +}: { + asset: CatalogAsset + busy: boolean + onDragStart(event: DragEvent): void + onDragEnd(): void + onRenameOutfit(name: string): Promise + onRenameAction(actionId: string, name: string): Promise + onDelete(): Promise +}) { + const { character, outfit } = asset + + return ( +
+
+ {outfit.characterTemplateUrl ? ( + {`${outfit.name} + ) : ( + 暂无角色图 + )} +
+
+
+

角色 {character.id}

+ +
+ +

{outfit.name}

+
+

{outfit.actions.length} 个动作

+
+ {outfit.actions.map((action) => ( + onRenameAction(action.id, name)} + /> + ))} +
+
+
+ ) +} + +function ActionRow({ + character, + outfit, + action, + disabled, + onRename, +}: { + character: Character + outfit: Outfit + action: Action + disabled: boolean + onRename(name: string): Promise +}) { + return ( + + + {action.name} + + {action.frames.length} 帧 + + + + ) +} + +function EditableName({ + value, + label, + disabled, + onSave, + className = '', + children, +}: { + value: string + label: string + disabled: boolean + onSave(value: string): Promise + className?: string + children: ReactNode +}) { + const [editing, setEditing] = useState(false) + const [draft, setDraft] = useState(value) + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + + useEffect(() => setDraft(value), [value]) + + const submit = async (event: FormEvent) => { + event.preventDefault() + const name = draft.trim() + if (!name) { + setError('名称不能为空') + return + } + if (name === value) { + setEditing(false) + return + } + + setSaving(true) + setError(null) + try { + await onSave(name) + setEditing(false) + } catch (cause) { + setError(errorMessage(cause, '保存失败')) + } finally { + setSaving(false) + } + } + + if (editing) { + return ( +
void submit(event)} + onDragStart={(event) => event.stopPropagation()} + className={`grid min-w-0 grid-cols-[minmax(0,1fr)_32px_32px] gap-1 ${className}`} + > + setDraft(event.target.value)} + disabled={saving} + className="h-8 min-w-0 rounded-md border border-[#829087] px-2 text-xs outline-none focus:border-[#35583f]" + /> + + + {error ? {error} : null} +
+ ) + } + + return ( +
+ {children} + +
+ ) +} + +function CatalogAlert({ children, tone }: { children: string; tone: 'error' | 'status' }) { + return ( +
+

{children}

+
+ ) +} + +function replaceCharacter(state: CatalogState, character: Character): CatalogState { + const project = state.projects.find((candidate) => candidate.id === character.projectId) + if (!project) return state + + const otherAssets = state.assets.filter((asset) => asset.character.id !== character.id) + const updatedAssets = character.outfits + .filter((outfit) => outfit.actions.length > 0) + .map((outfit) => ({ project, character, outfit })) + + return { ...state, assets: [...otherAssets, ...updatedAssets] } +} + +function errorMessage(cause: unknown, fallback: string): string { + return cause instanceof Error && cause.message.trim() ? cause.message : fallback +} + +async function loadCatalog(apis: PlaytestCatalogApis): Promise { + const page = await apis.projects.list({ page: 1, pageSize: 100 }) + const charactersByProject = await Promise.all( + page.items.map(async (project) => ({ + project, + characters: await apis.characters.listByProject(project.id), + })), + ) + + return { + projects: page.items, + assets: charactersByProject.flatMap(({ project, characters }) => + characters.flatMap((character) => + character.outfits + .filter((outfit) => outfit.actions.length > 0) + .map((outfit) => ({ project, character, outfit })), + ), + ), + } +} diff --git a/frontend/src/pages/playtest/index.test.tsx b/frontend/src/pages/playtest/index.test.tsx index 81c09c2..c661dba 100644 --- a/frontend/src/pages/playtest/index.test.tsx +++ b/frontend/src/pages/playtest/index.test.tsx @@ -1,5 +1,5 @@ /** @vitest-environment jsdom */ -import { act, cleanup, render, screen, waitFor } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { MemoryRouter, Route, Routes, useNavigate, type NavigateFunction } from 'react-router' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -63,7 +63,10 @@ describe('PlaytestPage', () => { it('loads the requested character through the standard skeleton API only', async () => { const apis: PlaytestPageApis = { - characters: { get: vi.fn().mockResolvedValue(character) }, + characters: { + get: vi.fn().mockResolvedValue(character), + listByProject: vi.fn().mockResolvedValue([character]), + }, } renderPage(apis, '/playtest/character-1/outfit-1?actionId=idle') @@ -76,7 +79,9 @@ describe('PlaytestPage', () => { it.each([{ code: 404 }, { status: 404 }])( 'maps a missing character response to a stable message', async (error) => { - renderPage({ characters: { get: vi.fn().mockRejectedValue(error) } }) + renderPage({ + characters: { get: vi.fn().mockRejectedValue(error), listByProject: vi.fn() }, + }) expect(await screen.findByText('角色不存在')).toBeTruthy() }, @@ -84,7 +89,10 @@ describe('PlaytestPage', () => { it('does not mislabel a transport failure as not found', async () => { renderPage({ - characters: { get: vi.fn().mockRejectedValue(new Error('network unavailable')) }, + characters: { + get: vi.fn().mockRejectedValue(new Error('network unavailable')), + listByProject: vi.fn(), + }, }) expect(await screen.findByText('角色读取失败')).toBeTruthy() @@ -101,6 +109,7 @@ describe('PlaytestPage', () => { outfits: [{ ...character.outfits[0], characterId: 'character-2' }], } const get = vi.fn().mockReturnValueOnce(firstRequest).mockResolvedValueOnce(secondCharacter) + const listByProject = vi.fn().mockResolvedValue([character, secondCharacter]) let navigate: NavigateFunction | undefined function NavigationProbe() { @@ -114,7 +123,7 @@ describe('PlaytestPage', () => { } + element={} /> , @@ -130,4 +139,42 @@ describe('PlaytestPage', () => { expect(get).toHaveBeenNthCalledWith(1, 'character-1') expect(get).toHaveBeenNthCalledWith(2, 'character-2') }) + + it('switches assets from the action sidebar without returning to the catalog', async () => { + const secondCharacter: Character = { + ...character, + id: 'character-2', + outfits: [ + { + ...character.outfits[0]!, + id: 'outfit-2', + characterId: 'character-2', + name: 'Knight', + actions: character.outfits[0]!.actions.map((action) => ({ + ...action, + outfitId: 'outfit-2', + name: 'Guard', + })), + }, + ], + } + const get = vi + .fn() + .mockImplementation(async (id: string) => + id === secondCharacter.id ? secondCharacter : character, + ) + const listByProject = vi.fn().mockResolvedValue([character, secondCharacter]) + + renderPage({ characters: { get, listByProject } }) + + const selector = await screen.findByRole('combobox', { name: '同项目资产' }) + fireEvent.click(selector) + const options = screen.getAllByRole('option') + expect(options).toHaveLength(2) + fireEvent.click(screen.getByRole('option', { name: /Knight/ })) + + expect(await screen.findByRole('heading', { name: 'character-2 · Knight' })).toBeTruthy() + expect(screen.getByRole('button', { name: /Guard/ })).toBeTruthy() + expect(get).toHaveBeenLastCalledWith('character-2') + }) }) diff --git a/frontend/src/pages/playtest/index.tsx b/frontend/src/pages/playtest/index.tsx index eea852b..d26f9b1 100644 --- a/frontend/src/pages/playtest/index.tsx +++ b/frontend/src/pages/playtest/index.tsx @@ -1,12 +1,17 @@ import { useEffect, useState } from 'react' -import { useParams, useSearchParams } from 'react-router' +import { Link, useNavigate, useParams, useSearchParams } from 'react-router' import type { Character, CharacterApis } from '@/entities/character' +import type { PlaytestInspectionApis } from '@/entities/playtest-inspection' +import type { ProjectApis } from '@/entities/project' +import { buildPlaytestPath } from '@/features/publish' -import { PlaytestWorkbench } from './workbench' +import { PlaytestWorkbench, type PlaytestAssetOption } from './workbench' export interface PlaytestPageApis { - characters: Pick + characters: Pick + projects?: Pick + inspections?: Pick } export interface PlaytestPageProps { @@ -15,11 +20,19 @@ export interface PlaytestPageProps { interface PageData { character: Character | null + projectCharacters: Character[] + expectedCanvas: { width: number; height: number } | null error: string | null loading: boolean } -const initialPageData: PageData = { character: null, error: null, loading: false } +const initialPageData: PageData = { + character: null, + projectCharacters: [], + expectedCanvas: null, + error: null, + loading: false, +} function isNotFoundError(error: unknown): boolean { if (typeof error !== 'object' || error === null) return false @@ -40,6 +53,7 @@ function isNotFoundError(error: unknown): boolean { export function PlaytestPage({ apis }: PlaytestPageProps) { const { characterId, outfitId } = useParams() const [searchParams] = useSearchParams() + const navigate = useNavigate() const initialActionId = searchParams.get('actionId') const [data, setData] = useState(initialPageData) @@ -48,6 +62,8 @@ export function PlaytestPage({ apis }: PlaytestPageProps) { if (apis === undefined) { setData({ character: null, + projectCharacters: [], + expectedCanvas: null, error: 'Playtest 角色接口尚未配置', loading: false, }) @@ -61,8 +77,20 @@ export function PlaytestPage({ apis }: PlaytestPageProps) { let cancelled = false setData({ ...initialPageData, loading: true }) void apis.characters.get(characterId).then( - (character) => { - if (!cancelled) setData({ character, error: null, loading: false }) + async (character) => { + let projectCharacters = [character] + let expectedCanvas: PageData['expectedCanvas'] = null + const [charactersResult, projectResult] = await Promise.allSettled([ + apis.characters.listByProject(character.projectId), + apis.projects?.get(character.projectId) ?? Promise.resolve(null), + ]) + if (charactersResult.status === 'fulfilled') projectCharacters = charactersResult.value + if (projectResult.status === 'fulfilled' && projectResult.value !== null) { + expectedCanvas = projectResult.value.spriteSize + } + if (!cancelled) { + setData({ character, projectCharacters, expectedCanvas, error: null, loading: false }) + } }, (error: unknown) => { if (!cancelled) { @@ -83,13 +111,59 @@ export function PlaytestPage({ apis }: PlaytestPageProps) { if (data.loading || data.character === null) return 加载 Playtest 数据中 + const assetOptions = buildAssetOptions(data.projectCharacters) + return ( - +
+
+ + + 全部 Playtest + +
+ + navigate( + buildPlaytestPath({ + characterId: asset.characterId, + outfitId: asset.outfitId, + }), + ) + } + onAddAction={() => { + const params = new URLSearchParams({ + characterId: data.character!.id, + outfitId: outfitId ?? '', + }) + navigate(`/quick-start?${params.toString()}`) + }} + /> +
+ ) +} + +function buildAssetOptions(characters: Character[]): PlaytestAssetOption[] { + return characters.flatMap((character) => + character.outfits + .filter((outfit) => outfit.actions.length > 0) + .map((outfit) => ({ + key: `${character.id}:${outfit.id}`, + characterId: character.id, + outfitId: outfit.id, + name: `${outfit.name}(角色 ${character.id})`, + actionCount: outfit.actions.length, + })), ) } diff --git a/frontend/src/pages/playtest/playtest-boundaries.test.ts b/frontend/src/pages/playtest/playtest-boundaries.test.ts index c55992f..aac31e4 100644 --- a/frontend/src/pages/playtest/playtest-boundaries.test.ts +++ b/frontend/src/pages/playtest/playtest-boundaries.test.ts @@ -12,7 +12,11 @@ const prohibitedImports = [ 'entities/workflow-run', 'entities/generation', ] as const -const allowedEntityImports = ['@/entities/character'] as const +const allowedEntityImports = [ + '@/entities/character', + '@/entities/playtest-inspection', + '@/entities/project', +] as const function sourceFiles(directory: string): readonly string[] { return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { diff --git a/frontend/src/pages/playtest/workbench/acceptance.tsx b/frontend/src/pages/playtest/workbench/acceptance.tsx index f94546e..496e46f 100644 --- a/frontend/src/pages/playtest/workbench/acceptance.tsx +++ b/frontend/src/pages/playtest/workbench/acceptance.tsx @@ -1,10 +1,14 @@ import { StatusPanel } from './status-panel' -export type PlaytestInspectionStatus = 'passed' | 'issues_found' +import type { PlaytestInspectionStatus } from '@/entities/playtest-inspection' export interface AcceptanceProps { inspectionStatus: PlaytestInspectionStatus | null - onRecordStatus(status: PlaytestInspectionStatus): void + available: boolean + loading: boolean + saving: boolean + error: string | null + onRecordStatus(status: PlaytestInspectionStatus): Promise } function statusText(status: PlaytestInspectionStatus | null): string { @@ -13,8 +17,27 @@ function statusText(status: PlaytestInspectionStatus | null): string { return '尚未核验' } -/** 本次浏览会话的临时核验结论,不写回 Character 或任何后端记录。 */ -export function Acceptance({ inspectionStatus, onRecordStatus }: AcceptanceProps) { +/** Playtest 自己的动作核验结论,不写回 Character 或创作历史。 */ +export function Acceptance({ + inspectionStatus, + available, + loading, + saving, + error, + onRecordStatus, +}: AcceptanceProps) { + const detail = !available + ? '当前预览未连接核验记录' + : loading + ? '正在读取核验记录' + : saving + ? '正在保存核验记录' + : error + ? error + : inspectionStatus === null + ? '尚未保存核验结论' + : '已保存到 Playtest 核验记录' + return (
本次核验

{statusText(inspectionStatus)}

-

仅保存在当前页面,不写入后端

+

{detail}

+ ) : ( +
+ +
+ )} + {canSwitchAsset && assetMenuOpen ? ( +
+ {assets.map((asset) => { + const selected = asset.key === selectedAssetKey + + return ( + + ) + })} +
+ ) : null} +
+
+
+

动作

+ + {actions.length} TOTAL + {onAddAction ? ( + + ) : null} + +
+
{actions.map((action) => { const count = frameCount(action) const selected = action.id === selectedActionId @@ -29,23 +191,33 @@ export function ActionSelector({ actions, selectedActionId, onSelectAction }: Ac aria-pressed={selected} disabled={count === 0} onClick={() => onSelectAction(action.id)} - className={`flex w-full items-center justify-between rounded-lg border px-3 py-3 text-left transition-colors ${ + className={`grid min-h-12 w-36 shrink-0 grid-cols-[4px_minmax(0,1fr)_auto] items-center gap-2 rounded-md border px-2 py-1.5 text-left transition-colors lg:w-full ${ selected - ? 'border-white/25 bg-white/12 text-white' - : 'border-transparent text-white/65 hover:bg-white/5 hover:text-white' + ? 'border-white/12 bg-white/9 text-white' + : 'border-transparent text-white/60 hover:bg-white/5 hover:text-white' } disabled:cursor-not-allowed disabled:opacity-40`} > - - {action.name} - +
- +
) } diff --git a/frontend/src/pages/playtest/workbench/analysis/frame-geometry.test.ts b/frontend/src/pages/playtest/workbench/analysis/frame-geometry.test.ts index a1d7089..e9bc8f3 100644 --- a/frontend/src/pages/playtest/workbench/analysis/frame-geometry.test.ts +++ b/frontend/src/pages/playtest/workbench/analysis/frame-geometry.test.ts @@ -45,6 +45,7 @@ describe('measureFrameGeometry', () => { opaquePixels: 4, coverageRatio: 0.25, fingerprint: expect.any(Array), + contentHash: expect.any(String), }) expect(geometry?.fingerprint).toHaveLength(64) }) diff --git a/frontend/src/pages/playtest/workbench/analysis/frame-geometry.ts b/frontend/src/pages/playtest/workbench/analysis/frame-geometry.ts index 5338300..9fff8c3 100644 --- a/frontend/src/pages/playtest/workbench/analysis/frame-geometry.ts +++ b/frontend/src/pages/playtest/workbench/analysis/frame-geometry.ts @@ -26,6 +26,17 @@ export interface FrameGeometry { coverageRatio: number /** Compact 8×8 alpha/luminance signature used for adjacent-frame similarity checks. */ fingerprint?: readonly number[] + /** Exact RGBA content hash used to identify genuinely duplicated frames. */ + contentHash?: string +} + +function hashPixels(data: Uint8ClampedArray): string { + let hash = 0x811c9dc5 + for (const value of data) { + hash ^= value + hash = Math.imul(hash, 0x01000193) + } + return (hash >>> 0).toString(16).padStart(8, '0') } function createFingerprint( @@ -181,5 +192,6 @@ export function measureFrameGeometry(pixels: FramePixelData): FrameGeometry | nu width: subjectWidth, height: subjectHeight, }), + contentHash: hashPixels(data), } } diff --git a/frontend/src/pages/playtest/workbench/analysis/image-geometry.test.ts b/frontend/src/pages/playtest/workbench/analysis/image-geometry.test.ts index 507435a..6b4f2ae 100644 --- a/frontend/src/pages/playtest/workbench/analysis/image-geometry.test.ts +++ b/frontend/src/pages/playtest/workbench/analysis/image-geometry.test.ts @@ -84,6 +84,7 @@ describe('readImageGeometry', () => { opaquePixels: 1, coverageRatio: 0.25, fingerprint: expect.any(Array), + contentHash: expect.any(String), }, }) }) diff --git a/frontend/src/pages/playtest/workbench/analysis/quality-policy.ts b/frontend/src/pages/playtest/workbench/analysis/quality-policy.ts index 37d85a0..4aa8c45 100644 --- a/frontend/src/pages/playtest/workbench/analysis/quality-policy.ts +++ b/frontend/src/pages/playtest/workbench/analysis/quality-policy.ts @@ -11,8 +11,6 @@ export interface LocalQualityPolicy { edgeMargin: { x: number; y: number } minimumCoverageRatio: number maximumCoverageRatio: number - /** 相邻帧指纹平均距离 ≤ 该值时判定为重复帧(duplicate_frame)。 */ - duplicateDistance: number footDriftThreshold: number | null heightDriftThreshold: number | null heightAttentionThreshold: number | null @@ -26,7 +24,6 @@ export interface LocalQualityPolicy { const REFERENCE_CANVAS_SIZE = 256 const MINIMUM_COVERAGE_RATIO = 0.005 const MAXIMUM_COVERAGE_RATIO = 0.65 -const DUPLICATE_DISTANCE = 0.02 const AREA_DELTA_THRESHOLD_PERCENT = 28 function scaledPixels(referencePixels: number, scale: number): number { @@ -80,16 +77,15 @@ function movementCeilingReferencePixels(actionType: PlaytestActionType): number export function deriveLocalQualityPolicy( geometries: readonly FrameGeometry[], actionType: PlaytestActionType, + expectedCanvasOverride: CanvasBaseline | null = null, ): LocalQualityPolicy { - const expectedCanvas = inferCanvasBaseline(geometries) + const expectedCanvas = expectedCanvasOverride ?? inferCanvasBaseline(geometries) if (expectedCanvas === null) { return { expectedCanvas: null, edgeMargin: { x: 1, y: 1 }, minimumCoverageRatio: MINIMUM_COVERAGE_RATIO, maximumCoverageRatio: MAXIMUM_COVERAGE_RATIO, - duplicateDistance: DUPLICATE_DISTANCE, - footDriftThreshold: null, heightDriftThreshold: null, heightAttentionThreshold: null, @@ -114,7 +110,6 @@ export function deriveLocalQualityPolicy( }, minimumCoverageRatio: MINIMUM_COVERAGE_RATIO, maximumCoverageRatio: MAXIMUM_COVERAGE_RATIO, - duplicateDistance: DUPLICATE_DISTANCE, footDriftThreshold: scaledPixels(3, verticalScale), heightDriftThreshold: heightReference === null ? null : scaledPixels(heightReference, verticalScale), diff --git a/frontend/src/pages/playtest/workbench/analysis/sequence-evidence.test.ts b/frontend/src/pages/playtest/workbench/analysis/sequence-evidence.test.ts index 76608ea..bf7697e 100644 --- a/frontend/src/pages/playtest/workbench/analysis/sequence-evidence.test.ts +++ b/frontend/src/pages/playtest/workbench/analysis/sequence-evidence.test.ts @@ -9,7 +9,13 @@ function geometry( FrameGeometry, 'width' | 'height' | 'footY' | 'subjectHeight' | 'opaquePixels' | 'coverageRatio' > - > & { x?: number; y?: number; fingerprint?: readonly number[]; cropped?: boolean } = {}, + > & { + x?: number + y?: number + fingerprint?: readonly number[] + contentHash?: string + cropped?: boolean + } = {}, ): FrameGeometry { const subjectHeight = overrides.subjectHeight ?? 20 @@ -30,6 +36,7 @@ function geometry( opaquePixels: overrides.opaquePixels ?? 100, coverageRatio: overrides.coverageRatio ?? 0.25, fingerprint: overrides.fingerprint, + contentHash: overrides.contentHash, } } @@ -42,11 +49,11 @@ function ready( describe('buildSequenceEvidence', () => { it('returns structured findings for incomplete, cropped and duplicate frames', () => { - const fingerprint = Array.from({ length: 64 }, (_, index) => index / 64) + const contentHash = 'same-frame' const evidence = buildSequenceEvidence( [ - ready(geometry({ cropped: true, fingerprint })), - ready(geometry({ fingerprint })), + ready(geometry({ cropped: true, contentHash })), + ready(geometry({ contentHash })), { geometry: { status: 'unavailable', reason: '图片没有可见主体' }, rootMotion: null }, ], 'walk', @@ -189,7 +196,7 @@ describe('buildSequenceEvidence', () => { ready(geometry({ width: 512, height: 512, x: 4 })), ready(geometry({ width: 512, height: 512, x: 14 })), ], - 'walk', + 'attack', ) expect(evidence.summary).toMatchObject({ @@ -353,4 +360,48 @@ describe('buildSequenceEvidence', () => { expect(evidence.frames[2]?.expectedRootDelta).toMatchObject({ dx: 3, dy: 2 }) expect(evidence.frames[2]?.composedPreviewDelta).toMatchObject({ dx: 5, dy: 1 }) }) + + it('does not mislabel merely similar adjacent frames as duplicates', () => { + const fingerprint = Array.from({ length: 64 }, () => 0.5) + const evidence = buildSequenceEvidence( + [ + ready(geometry({ fingerprint, contentHash: 'frame-a' })), + ready(geometry({ fingerprint, contentHash: 'frame-b' })), + ], + 'attack', + ) + + expect(evidence.findings.map((finding) => finding.code)).not.toContain('duplicate_frame') + }) + + it('checks a loop boundary against the ordinary adjacent-frame baseline', () => { + const evidence = buildSequenceEvidence( + [ready(geometry({ x: 0 })), ready(geometry({ x: 20 })), ready(geometry({ x: 40 }))], + 'walk', + ) + + expect(evidence.summary.movementThreshold).toBe(24) + expect(evidence.findings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: 'motion_spike', + frameIndex: 0, + message: '循环首尾出现异常位移突变', + }), + ]), + ) + }) + + it('uses the project canvas contract instead of accepting a consistently wrong sequence', () => { + const evidence = buildSequenceEvidence( + [ready(geometry({ width: 256, height: 256 })), ready(geometry({ width: 256, height: 256 }))], + 'idle', + { width: 512, height: 512 }, + ) + + expect(evidence.summary.expectedCanvas).toEqual({ width: 512, height: 512 }) + expect( + evidence.findings.filter((finding) => finding.code === 'canvas_size_mismatch'), + ).toHaveLength(2) + }) }) diff --git a/frontend/src/pages/playtest/workbench/analysis/sequence-evidence.ts b/frontend/src/pages/playtest/workbench/analysis/sequence-evidence.ts index 8fbbfb8..ce6a5d9 100644 --- a/frontend/src/pages/playtest/workbench/analysis/sequence-evidence.ts +++ b/frontend/src/pages/playtest/workbench/analysis/sequence-evidence.ts @@ -91,23 +91,8 @@ function medianAbsoluteDeviation(values: readonly number[], center: number | nul return median(values.map((value) => Math.abs(value - center))) } -function fingerprintDistance( - left: readonly number[] | undefined, - right: readonly number[] | undefined, -): number | null { - if ( - left === undefined || - right === undefined || - left.length === 0 || - left.length !== right.length - ) - return null - - const total = left.reduce( - (sum, value, index) => sum + Math.abs(value - (right[index] ?? value)), - 0, - ) - return total / left.length +function framesAreIdentical(left: FrameGeometry, right: FrameGeometry): boolean { + return left.contentHash !== undefined && left.contentHash === right.contentHash } function isCropped(geometry: FrameGeometry, margin: { x: number; y: number }): boolean { @@ -164,12 +149,13 @@ function rootMotion(frame: FrameEvidenceInput): { dx: number; dy: number } { export function buildSequenceEvidence( inputs: readonly FrameEvidenceInput[], actionType: PlaytestActionType, + expectedCanvas: CanvasBaseline | null = null, ): SequenceReviewEvidence { const results = inputs.map((input) => input.geometry) const readyGeometries = results.flatMap((result) => result.status === 'ready' ? [result.geometry] : [], ) - const policy = deriveLocalQualityPolicy(readyGeometries, actionType) + const policy = deriveLocalQualityPolicy(readyGeometries, actionType, expectedCanvas) const isBaselineGeometry = (geometry: FrameGeometry): boolean => policy.expectedCanvas !== null && geometry.width === policy.expectedCanvas.width && @@ -180,6 +166,19 @@ export function buildSequenceEvidence( if (!isBaselineGeometry(previous.geometry) || !isBaselineGeometry(result.geometry)) return null return adjacentDelta(previous.geometry, result.geometry) }) + const checksLoopBoundary = actionType === 'idle' || actionType === 'walk' + const firstResult = results[0] + const lastResult = results.at(-1) + const closingDelta = + checksLoopBoundary && + results.length > 1 && + results.every((result) => result.status === 'ready') && + firstResult?.status === 'ready' && + lastResult?.status === 'ready' && + isBaselineGeometry(firstResult.geometry) && + isBaselineGeometry(lastResult.geometry) + ? adjacentDelta(lastResult.geometry, firstResult.geometry) + : null const rootDeltas = inputs.map((input, index): MotionVector | null => { if (index === 0) return null @@ -331,17 +330,13 @@ export function buildSequenceEvidence( const previous = results[index - 1] if (previous?.status !== 'ready') return - const duplicateDistance = fingerprintDistance( - previous.geometry.fingerprint, - geometry.fingerprint, - ) - if (duplicateDistance !== null && duplicateDistance <= policy.duplicateDistance) { + if (framesAreIdentical(previous.geometry, geometry)) { findings.push({ code: 'duplicate_frame', severity: 'warning', frameIndex: index, - message: '当前帧与上一帧高度相似', - metrics: { distance: duplicateDistance }, + message: '当前帧与上一帧完全相同', + metrics: {}, }) } @@ -385,6 +380,36 @@ export function buildSequenceEvidence( } }) + if (closingDelta !== null && firstResult?.status === 'ready' && lastResult?.status === 'ready') { + if (framesAreIdentical(lastResult.geometry, firstResult.geometry)) { + findings.push({ + code: 'duplicate_frame', + severity: 'warning', + frameIndex: 0, + message: '循环首帧与尾帧完全相同,可能产生停顿', + metrics: {}, + }) + } + if (movementThreshold !== null && closingDelta.distance > movementThreshold) { + findings.push({ + code: 'motion_spike', + severity: 'error', + frameIndex: 0, + message: '循环首尾出现异常位移突变', + metrics: { distance: closingDelta.distance, threshold: movementThreshold }, + }) + } + if (closingDelta.areaDeltaPercent > policy.areaDeltaThresholdPercent) { + findings.push({ + code: 'area_spike', + severity: 'warning', + frameIndex: 0, + message: '循环首尾轮廓面积变化过大', + metrics: { percent: closingDelta.areaDeltaPercent }, + }) + } + } + if ( footDrift !== null && policy.footDriftThreshold !== null && diff --git a/frontend/src/pages/playtest/workbench/analysis/use-frame-review-evidence.test.tsx b/frontend/src/pages/playtest/workbench/analysis/use-frame-review-evidence.test.tsx index c259088..8edf422 100644 --- a/frontend/src/pages/playtest/workbench/analysis/use-frame-review-evidence.test.tsx +++ b/frontend/src/pages/playtest/workbench/analysis/use-frame-review-evidence.test.tsx @@ -107,7 +107,7 @@ describe('useFrameReviewEvidence', () => { expect(reader.mock.calls[1]?.[1]).toBeInstanceOf(AbortSignal) }) - it('reuses settled image results when frames are selected or revisited', async () => { + it('rereads revisited image URLs so regenerated content cannot reuse stale geometry', async () => { // Catches frame navigation repeatedly decoding every image in the same review session. const reader = vi.fn(async (imageUrl) => geometry(imageUrl.includes('one') ? 1 : 2), @@ -127,7 +127,7 @@ describe('useFrameReviewEvidence', () => { await waitFor(() => expect(result.current.evidence?.frames[0]?.geometry?.centroid.x).toBe(2)) rerender({ current: first }) await waitFor(() => expect(result.current.evidence?.frames[0]?.geometry?.centroid.x).toBe(1)) - expect(reader).toHaveBeenCalledTimes(2) + expect(reader).toHaveBeenCalledTimes(3) }) it('retries an unavailable image when its sequence is revisited', async () => { diff --git a/frontend/src/pages/playtest/workbench/analysis/use-frame-review-evidence.ts b/frontend/src/pages/playtest/workbench/analysis/use-frame-review-evidence.ts index ad70061..b57fbe8 100644 --- a/frontend/src/pages/playtest/workbench/analysis/use-frame-review-evidence.ts +++ b/frontend/src/pages/playtest/workbench/analysis/use-frame-review-evidence.ts @@ -1,7 +1,8 @@ -import { useEffect, useRef, useState } from 'react' +import { useEffect, useState } from 'react' import type { PlaytestActionType, PreviewFrame, PreviewSequence } from '../model/types' import { readImageGeometry } from './image-geometry' +import type { CanvasBaseline } from './quality-policy' import { buildSequenceEvidence, type FrameGeometryResult, @@ -18,11 +19,6 @@ export type ImageGeometryReader = ( signal?: AbortSignal, ) => Promise -interface CachedReads { - reader: ImageGeometryReader - entries: Map -} - interface ResolvedState { key: string | null value: FrameReviewEvidenceState @@ -35,56 +31,47 @@ export function useFrameReviewEvidence( sequence: PreviewSequence | null, actionType: PlaytestActionType | null, reader: ImageGeometryReader = readImageGeometry, + expectedCanvas: CanvasBaseline | null = null, ): FrameReviewEvidenceState { const sequenceKey = sequence === null || actionType === null ? null : JSON.stringify([ actionType, + expectedCanvas, ...sequence.frames.map((frame) => ({ imageUrl: frame.imageUrl, rootMotion: frame.rootMotion, })), ]) - const cache = useRef(null) const [resolved, setResolved] = useState({ key: null, value: IDLE_STATE }) - if (cache.current === null || cache.current.reader !== reader) { - cache.current = { reader, entries: new Map() } - } - useEffect(() => { if (sequenceKey === null || actionType === null) { setResolved({ key: null, value: IDLE_STATE }) return } - const [, ...frameDescriptors] = JSON.parse(sequenceKey) as [ + const [, , ...frameDescriptors] = JSON.parse(sequenceKey) as [ PlaytestActionType, + CanvasBaseline | null, ...Array<{ imageUrl: string; rootMotion: PreviewFrame['rootMotion'] }>, ] const imageUrls = frameDescriptors.map((frame) => frame.imageUrl) const controller = new AbortController() let active = true - const reads = cache.current?.entries const inFlight = new Map>() setResolved({ key: sequenceKey, value: LOADING_STATE }) const results = imageUrls.map((imageUrl) => { - const cached = reads?.get(imageUrl) - if (cached !== undefined) return Promise.resolve(cached) - const existing = inFlight.get(imageUrl) if (existing !== undefined) return existing - const pending = reader(imageUrl, controller.signal) - .catch((): FrameGeometryResult => ({ status: 'unavailable', reason: '图片分析失败' })) - .then((result) => { - if (!controller.signal.aborted && result.status === 'ready') reads?.set(imageUrl, result) - return result - }) + const pending = reader(imageUrl, controller.signal).catch( + (): FrameGeometryResult => ({ status: 'unavailable', reason: '图片分析失败' }), + ) inFlight.set(imageUrl, pending) return pending }) @@ -102,6 +89,7 @@ export function useFrameReviewEvidence( rootMotion: frameDescriptors[index]?.rootMotion ?? null, })), actionType, + expectedCanvas, ), }, }) @@ -111,7 +99,7 @@ export function useFrameReviewEvidence( active = false controller.abort() } - }, [actionType, reader, sequenceKey]) + }, [actionType, expectedCanvas, reader, sequenceKey]) if (sequenceKey === null) return IDLE_STATE return resolved.key === sequenceKey ? resolved.value : LOADING_STATE diff --git a/frontend/src/pages/playtest/workbench/animation-stage.test.tsx b/frontend/src/pages/playtest/workbench/animation-stage.test.tsx index 3636cb9..b3d6d80 100644 --- a/frontend/src/pages/playtest/workbench/animation-stage.test.tsx +++ b/frontend/src/pages/playtest/workbench/animation-stage.test.tsx @@ -51,7 +51,22 @@ describe('playtest visual primitives', () => { const onSelectAction = vi.fn() render( - , + , ) expect(screen.getByRole('button', { name: /行走/ }).textContent).toContain('12 FPS') @@ -161,9 +176,9 @@ describe('playtest visual primitives', () => { fireEvent.click(screen.getByRole('button', { name: '下一帧' })) expect(onTogglePlaying).toHaveBeenCalledTimes(1) expect(onNextFrame).toHaveBeenCalledTimes(1) - expect(screen.getByText('A 左行走')).toBeTruthy() - expect(screen.getByText('D 右行走')).toBeTruthy() - expect(screen.getByText('未提供跳跃动作')).toBeTruthy() - expect(screen.getByText('下蹲动作可用')).toBeTruthy() + expect(screen.getByText('跳跃不可用,下蹲可用')).toBeTruthy() + expect(screen.getByRole('button', { name: '循环播放' }).getAttribute('aria-pressed')).toBe( + 'true', + ) }) }) diff --git a/frontend/src/pages/playtest/workbench/animation-stage.tsx b/frontend/src/pages/playtest/workbench/animation-stage.tsx index d3bca47..81b2d92 100644 --- a/frontend/src/pages/playtest/workbench/animation-stage.tsx +++ b/frontend/src/pages/playtest/workbench/animation-stage.tsx @@ -102,7 +102,7 @@ export function AnimationStage({
+
+
-
-
- 方向 - - {playback.action?.sequences.map((sequence) => ( - - ))} -
-
+
+
+
+ + {playback.action?.sequences.map((sequence) => ( + + ))} +
- + + ) : null}
) diff --git a/frontend/src/pages/playtest/workbench/playback-controls.tsx b/frontend/src/pages/playtest/workbench/playback-controls.tsx index 0e52624..d88e2ca 100644 --- a/frontend/src/pages/playtest/workbench/playback-controls.tsx +++ b/frontend/src/pages/playtest/workbench/playback-controls.tsx @@ -14,20 +14,6 @@ export interface PlaybackControlsProps { onToggleLoop(): void } -function AvailabilityBadge({ available, label }: { available: boolean; label: string }) { - return ( - - {label} - - ) -} - interface ControlButtonProps { label: string text: string @@ -42,7 +28,7 @@ function ControlButton({ label, text, disabled = false, onPress }: ControlButton aria-label={label} disabled={disabled} onClick={onPress} - className="grid h-9 w-9 place-items-center rounded-lg border border-slate-200 bg-white text-sm hover:border-slate-400 disabled:cursor-not-allowed disabled:opacity-40" + className="grid h-9 w-9 place-items-center rounded-md border border-white/15 bg-white/6 text-sm text-white hover:bg-white/12 disabled:cursor-not-allowed disabled:opacity-35" > {text} @@ -65,12 +51,14 @@ export function PlaybackControls({ onToggleLoop, }: PlaybackControlsProps) { const disabled = frameCount === 0 + const keyboardStatus = `跳跃${jumpAvailable ? '可用' : '不可用'},下蹲${crouchAvailable ? '可用' : '不可用'}` return (
+ {keyboardStatus} - + {frameCount === 0 ? '00 / 00' : `${String(frameIndex + 1).padStart(2, '0')} / ${String(frameCount).padStart(2, '0')}`} - FPS {fps || '—'} + {fps || '—'} FPS -
-
- A 左行走 - D 右行走 - W 跳跃 - S 下蹲 -
-
- - -
-
) } diff --git a/frontend/src/pages/playtest/workbench/use-playtest-inspection.ts b/frontend/src/pages/playtest/workbench/use-playtest-inspection.ts new file mode 100644 index 0000000..49900b3 --- /dev/null +++ b/frontend/src/pages/playtest/workbench/use-playtest-inspection.ts @@ -0,0 +1,89 @@ +import { useCallback, useEffect, useRef, useState } from 'react' + +import type { + PlaytestInspectionApis, + PlaytestInspectionStatus, + PlaytestInspectionTarget, +} from '@/entities/playtest-inspection' + +interface PlaytestInspectionState { + status: PlaytestInspectionStatus | null + loading: boolean + saving: boolean + error: string | null +} + +const EMPTY_STATE: PlaytestInspectionState = { + status: null, + loading: false, + saving: false, + error: null, +} + +function errorMessage(cause: unknown, fallback: string): string { + return cause instanceof Error && cause.message.trim() ? cause.message : fallback +} + +export function usePlaytestInspection( + apis: Pick | undefined, + target: PlaytestInspectionTarget | null, +) { + const [state, setState] = useState(EMPTY_STATE) + const operationId = useRef(0) + + useEffect(() => { + const currentOperation = ++operationId.current + if (apis === undefined || target === null) { + setState(EMPTY_STATE) + return + } + + let active = true + setState({ ...EMPTY_STATE, loading: true }) + void apis.get(target).then( + (inspection) => { + if (active && operationId.current === currentOperation) { + setState({ ...EMPTY_STATE, status: inspection?.status ?? null }) + } + }, + (cause: unknown) => { + if (active && operationId.current === currentOperation) { + setState({ + ...EMPTY_STATE, + error: errorMessage(cause, '核验记录读取失败'), + }) + } + }, + ) + + return () => { + active = false + } + }, [apis, target]) + + const save = useCallback( + async (status: PlaytestInspectionStatus) => { + if (apis === undefined || target === null) return + + const currentOperation = ++operationId.current + setState((current) => ({ ...current, saving: true, error: null })) + try { + const inspection = await apis.save({ ...target, status }) + if (operationId.current === currentOperation) { + setState({ ...EMPTY_STATE, status: inspection.status }) + } + } catch (cause) { + if (operationId.current === currentOperation) { + setState((current) => ({ + ...current, + saving: false, + error: errorMessage(cause, '核验记录保存失败'), + })) + } + } + }, + [apis, target], + ) + + return { ...state, save, available: apis !== undefined && target !== null } +} diff --git a/frontend/src/pages/quick-start/index.test.tsx b/frontend/src/pages/quick-start/index.test.tsx index 71fcac9..5d72edb 100644 --- a/frontend/src/pages/quick-start/index.test.tsx +++ b/frontend/src/pages/quick-start/index.test.tsx @@ -1,5 +1,5 @@ // @vitest-environment jsdom -import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { MemoryRouter, Route, Routes, useLocation } from 'react-router' @@ -15,6 +15,7 @@ import { import { createWorkflowController } from '@/features/workflow-controller' import { QuickStartPage } from '.' import { createQuickStartService } from './service' +import type { QuickStartService } from './service' afterEach(cleanup) @@ -101,12 +102,16 @@ function currentStep(run: WorkflowRun, type: WorkflowStep['type']) { } function LocationProbe() { - return {useLocation().pathname} + const location = useLocation() + return {location.pathname + location.search} } -function renderQuickStart(service: ReturnType['service']) { +function renderQuickStart( + service: ReturnType['service'] | QuickStartService, + initialEntry = '/quick-start', +) { return render( - + ['service']) } /> 工作流画布} /> + } /> , ) @@ -229,4 +235,134 @@ describe('QuickStartPage', () => { expect(currentStep(run!, 'character-template')?.status).toBe('passed') expect(currentStep(run!, 'template-candidate')?.status).toBe('active') }) + + it('publishes once and opens the generated action in Playtest', async () => { + const run = completedActionRun() + const service: QuickStartService = { + unavailableReason: null, + start: vi.fn(), + startAction: vi.fn(), + getWorkflow: vi.fn(() => run), + subscribe: vi.fn(() => () => undefined), + resume: vi.fn(async () => run), + interrupt: vi.fn(() => run), + confirmCandidate: vi.fn(), + approveReview: vi.fn(async () => ({ ...run, status: 'completed' as const })), + getCharacterInfo: vi.fn(() => ({ characterId: '25', outfitId: 'outfit-25-default' })), + resolveCharacterInfo: vi.fn(), + } + + renderQuickStart(service, '/quick-start/run-1') + fireEvent.click(await screen.findByRole('button', { name: '一键导入 Playtest' })) + + await waitFor(() => + expect(screen.getByLabelText('当前路径').textContent).toBe( + '/playtest/25/outfit-25-default?actionId=25-run-1', + ), + ) + expect(service.approveReview).toHaveBeenCalledExactlyOnceWith('run-1') + }) + + it('starts an action-only run for the character selected in Playtest', async () => { + const run = completedActionRun() + const service: QuickStartService = { + unavailableReason: null, + start: vi.fn(), + startAction: vi.fn(async () => run), + getWorkflow: vi.fn(() => run), + subscribe: vi.fn(() => () => undefined), + resume: vi.fn(async () => run), + interrupt: vi.fn(() => run), + confirmCandidate: vi.fn(), + approveReview: vi.fn(async () => run), + getCharacterInfo: vi.fn(() => ({ characterId: '25', outfitId: 'outfit-25-default' })), + resolveCharacterInfo: vi.fn(), + } + + renderQuickStart(service, '/quick-start?characterId=25&outfitId=outfit-25-default') + fireEvent.change(screen.getByLabelText('动作描述'), { + target: { value: '挥手打招呼' }, + }) + fireEvent.click(screen.getByRole('button', { name: '开始生成新动作' })) + + await waitFor(() => + expect(service.startAction).toHaveBeenCalledWith( + { characterId: '25', outfitId: 'outfit-25-default' }, + '挥手打招呼', + ), + ) + await waitFor(() => + expect(screen.getByLabelText('当前路径').textContent).toBe('/quick-start/run-1'), + ) + }) }) + +function completedActionRun(): WorkflowRun { + const common = { + taskId: null, + submissionId: null, + error: null, + referenceStepIds: [], + } + return { + id: 'run-1', + projectId: '37', + characterId: '25', + outfitId: 'outfit-25-default', + purpose: 'create_character', + driver: 'ai', + status: 'active', + currentRevisionId: 'revision-1', + prompt: '提着灯笼的守夜人', + revisions: [ + { + id: 'revision-1', + basedOnRevisionId: null, + restartStepId: null, + status: 'active', + generationStatus: 'completed', + exportStatus: 'not_exported', + createdAt: '2026-08-03T00:00:00.000Z', + steps: [ + { + ...common, + id: 'setup', + type: 'character-setup', + status: 'passed', + input: null, + output: null, + }, + { + ...common, + id: 'template', + type: 'character-template', + status: 'passed', + input: null, + output: { type: 'character_template', images: [{ url: 'character.png' }] }, + }, + { + ...common, + id: 'candidate', + type: 'template-candidate', + status: 'passed', + input: null, + output: null, + }, + { + ...common, + id: 'action', + type: 'action-generation', + status: 'passed', + input: null, + output: { + type: 'complete_animation', + actionType: 'custom', + frames: [{ url: 'frame.png', durationMs: 125 }], + }, + }, + { ...common, id: 'review', type: 'review', status: 'active', input: null, output: null }, + ], + }, + ], + } +} diff --git a/frontend/src/pages/quick-start/index.tsx b/frontend/src/pages/quick-start/index.tsx index 79eb2ae..6a79b55 100644 --- a/frontend/src/pages/quick-start/index.tsx +++ b/frontend/src/pages/quick-start/index.tsx @@ -1,5 +1,5 @@ import { useEffect, useState, type FormEvent } from 'react' -import { useNavigate, useParams } from 'react-router' +import { Link, useNavigate, useParams, useSearchParams } from 'react-router' import { WORKFLOW_STEP_ORDER, @@ -9,7 +9,7 @@ import { type WorkflowStep, type WorkflowStepType, } from '@/entities' -import { buildPlaytestPath } from '@/features/publish' +import { buildPlaytestPath, buildPublishedActionId } from '@/features/publish' import { unavailableQuickStartService, type QuickStartService } from './service' export type { @@ -48,14 +48,89 @@ export interface QuickStartPageProps { /** Quick Start 独立完成 AI 入口;它不跳转 Workflow Editor。 */ export function QuickStartPage({ service = unavailableQuickStartService }: QuickStartPageProps) { const { runId } = useParams() + const [searchParams] = useSearchParams() + 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} 的当前造型,不会新建角色或覆盖已有动作。 +

+
+