diff --git a/docs/screenshots/workflow-editor-real-runtime-desktop.jpg b/docs/screenshots/workflow-editor-real-runtime-desktop.jpg
new file mode 100644
index 00000000..5eb99096
Binary files /dev/null and b/docs/screenshots/workflow-editor-real-runtime-desktop.jpg differ
diff --git a/docs/screenshots/workflow-editor-real-runtime-mobile.jpg b/docs/screenshots/workflow-editor-real-runtime-mobile.jpg
new file mode 100644
index 00000000..f505d5fd
Binary files /dev/null and b/docs/screenshots/workflow-editor-real-runtime-mobile.jpg differ
diff --git a/frontend/API_CONTRACT.md b/frontend/API_CONTRACT.md
index 9aa32e8a..65774e82 100644
--- a/frontend/API_CONTRACT.md
+++ b/frontend/API_CONTRACT.md
@@ -47,12 +47,13 @@ Character
前端只映射后端真实字段:
-- Character:`id`、`project_id`、`name`、`description`、`reference_image_url`、`character_data.version`、`status`
+- Character:`id`、`project_id`、`workflow_run_id`、`name`、`description`、`reference_image_url`、`character_data.version`、`status`
- Outfit:`id`、`name`、`description`、`preview_url`、`actions`
- Action:`id`、`type`、`name`、`loop`、`fps`、`frame_count`、`frames`
- Frame:`index`、`image_url`、`duration_ms`
Outfit、Action、Frame 没有独立端点。`outfit.characterId` 与 `action.outfitId` 仅由嵌套关系推导;修改任一子项时通过 `PATCH Character` 提交完整 `character_data`。
+创建 Character 时必须传入 `workflow_run_id`;编辑器也只读取与当前 WorkflowRun 绑定的角色,不能从同项目角色中按顺序猜测。
## 二、本轮明确不实现
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index d513d30c..7be3279f 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -8,6 +8,7 @@
"name": "windup-frontend",
"version": "0.0.0",
"dependencies": {
+ "@xyflow/react": "^12.11.2",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-router": "^8.3.0"
@@ -2019,6 +2020,55 @@
"assertion-error": "^2.0.1"
}
},
+ "node_modules/@types/d3-color": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
+ "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-drag": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
+ "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-selection": "*"
+ }
+ },
+ "node_modules/@types/d3-interpolate": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
+ "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-color": "*"
+ }
+ },
+ "node_modules/@types/d3-selection": {
+ "version": "3.0.11",
+ "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
+ "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-transition": {
+ "version": "3.0.9",
+ "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
+ "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-selection": "*"
+ }
+ },
+ "node_modules/@types/d3-zoom": {
+ "version": "3.0.8",
+ "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz",
+ "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-interpolate": "*",
+ "@types/d3-selection": "*"
+ }
+ },
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
@@ -2047,7 +2097,7 @@
"version": "19.2.17",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
"integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
@@ -2057,7 +2107,7 @@
"version": "19.2.3",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^19.2.0"
@@ -2202,6 +2252,48 @@
"url": "https://opencollective.com/vitest"
}
},
+ "node_modules/@xyflow/react": {
+ "version": "12.11.2",
+ "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.11.2.tgz",
+ "integrity": "sha512-eLAlDWJfWnQEhJwGMjlWdAXO9eYllKpliUmPQlAmOLxz6mExXuzMVDUKLMquixgkrtmMFFtug3jGKmYYld12cA==",
+ "license": "MIT",
+ "dependencies": {
+ "@xyflow/system": "0.0.79",
+ "classcat": "^5.0.3",
+ "zustand": "^4.4.0"
+ },
+ "peerDependencies": {
+ "@types/react": ">=17",
+ "@types/react-dom": ">=17",
+ "react": ">=17",
+ "react-dom": ">=17"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@xyflow/system": {
+ "version": "0.0.79",
+ "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.79.tgz",
+ "integrity": "sha512-czLyOh91NF0hIzbNzwi8I6GlqG23BHh2435OddfI6uiaLH3xdrdygO93gqgH1Bv9mhy8XPFQJOBn1FTq4LvEWA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-drag": "^3.0.7",
+ "@types/d3-interpolate": "^3.0.4",
+ "@types/d3-selection": "^3.0.10",
+ "@types/d3-transition": "^3.0.8",
+ "@types/d3-zoom": "^3.0.8",
+ "d3-drag": "^3.0.0",
+ "d3-interpolate": "^3.0.1",
+ "d3-selection": "^3.0.0",
+ "d3-zoom": "^3.0.0"
+ }
+ },
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
@@ -2268,6 +2360,12 @@
"node": ">=18"
}
},
+ "node_modules/classcat": {
+ "version": "5.0.5",
+ "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz",
+ "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
+ "license": "MIT"
+ },
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -2299,9 +2397,114 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
- "dev": true,
+ "devOptional": true,
"license": "MIT"
},
+ "node_modules/d3-color": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
+ "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-dispatch": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
+ "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-drag": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
+ "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-dispatch": "1 - 3",
+ "d3-selection": "3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-ease": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
+ "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-interpolate": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
+ "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-color": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-selection": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
+ "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-timer": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
+ "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-transition": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
+ "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-color": "1 - 3",
+ "d3-dispatch": "1 - 3",
+ "d3-ease": "1 - 3",
+ "d3-interpolate": "1 - 3",
+ "d3-timer": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "peerDependencies": {
+ "d3-selection": "2 - 3"
+ }
+ },
+ "node_modules/d3-zoom": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
+ "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-dispatch": "1 - 3",
+ "d3-drag": "2 - 3",
+ "d3-interpolate": "1 - 3",
+ "d3-selection": "2 - 3",
+ "d3-transition": "2 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/data-urls": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
@@ -3376,6 +3579,15 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/use-sync-external-store": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
+ "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
"node_modules/vite": {
"version": "8.1.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz",
@@ -3625,6 +3837,34 @@
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
"dev": true,
"license": "MIT"
+ },
+ "node_modules/zustand": {
+ "version": "4.5.7",
+ "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
+ "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
+ "license": "MIT",
+ "dependencies": {
+ "use-sync-external-store": "^1.2.2"
+ },
+ "engines": {
+ "node": ">=12.7.0"
+ },
+ "peerDependencies": {
+ "@types/react": ">=16.8",
+ "immer": ">=9.0.6",
+ "react": ">=16.8"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "immer": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ }
+ }
}
}
}
diff --git a/frontend/package.json b/frontend/package.json
index 0a845f78..330d0fbd 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -14,6 +14,7 @@
"typecheck": "tsc -b"
},
"dependencies": {
+ "@xyflow/react": "^12.11.2",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-router": "^8.3.0"
diff --git a/frontend/src/app/app.tsx b/frontend/src/app/app.tsx
index c8acde9b..40298bf5 100644
--- a/frontend/src/app/app.tsx
+++ b/frontend/src/app/app.tsx
@@ -1,3 +1,4 @@
+import { lazy, Suspense } from 'react'
import { BrowserRouter, Navigate, Route, Routes } from 'react-router'
import { AssetLibraryPage } from '@/pages/asset-library'
@@ -9,10 +10,13 @@ import { ProjectDetailPage } from '@/pages/project-detail'
import { ProjectCreatePage } from '@/pages/project-create'
import { ProjectsPage } from '@/pages/projects'
import { QuickStartPage } from '@/pages/quick-start'
-import { WorkflowEditorPage } from '@/pages/workflow-editor'
import { ProtectedRoute } from '@/features/auth-guard'
import { AppShellRoute } from './layout'
+const WorkflowEditorPage = lazy(() =>
+ import('@/pages/workflow-editor').then(({ WorkflowEditorPage: Page }) => ({ default: Page })),
+)
+
/**
* 路由表与全局外壳。
* 页面自己获取所需数据,不再由 app 层构造服务后逐层传入。
@@ -27,6 +31,14 @@ export function App() {
)
}
+function LazyWorkflowEditorPage() {
+ return (
+ }>
+
+
+ )
+}
+
/** 路由声明独立导出,测试用 MemoryRouter 验证直达地址。 */
export function AppRoutes() {
return (
@@ -38,8 +50,8 @@ export function AppRoutes() {
} />
} />
} />
- } />
- } />
+ } />
+ } />
} />
} />
diff --git a/frontend/src/app/workflow-editor-route.test.tsx b/frontend/src/app/workflow-editor-route.test.tsx
new file mode 100644
index 00000000..11e799c4
--- /dev/null
+++ b/frontend/src/app/workflow-editor-route.test.tsx
@@ -0,0 +1,35 @@
+/** @vitest-environment jsdom */
+import { cleanup, render, screen } from '@testing-library/react'
+import { afterEach, expect, it, vi } from 'vitest'
+import { MemoryRouter, Outlet } from 'react-router'
+
+import { AuthenticatedAuthSession } from '@/test/auth-session'
+
+afterEach(() => {
+ cleanup()
+ vi.doUnmock('@/pages/workflow-editor')
+ vi.doUnmock('./layout')
+ vi.resetModules()
+})
+
+it('只有进入 Workflow Editor 路由时才加载 React Flow 页面', async () => {
+ const pageModuleFactory = vi.fn(() => ({
+ WorkflowEditorPage: () =>
懒加载 Workflow Editor
,
+ }))
+ vi.doMock('@/pages/workflow-editor', pageModuleFactory)
+ vi.doMock('./layout', () => ({ AppShellRoute: () => }))
+
+ const { AppRoutes } = await import('./app')
+ expect(pageModuleFactory).not.toHaveBeenCalled()
+
+ render(
+
+
+
+
+ ,
+ )
+
+ expect(await screen.findByText('懒加载 Workflow Editor')).toBeTruthy()
+ expect(pageModuleFactory).toHaveBeenCalledTimes(1)
+})
diff --git a/frontend/src/entities/character/index.test.ts b/frontend/src/entities/character/index.test.ts
index af0a21de..1b731b11 100644
--- a/frontend/src/entities/character/index.test.ts
+++ b/frontend/src/entities/character/index.test.ts
@@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
const characterDto = {
id: 51,
project_id: 42,
+ workflow_run_id: 77,
name: '轻装信使',
description: null,
reference_image_url: 'https://cdn.windup.test/reference.png',
@@ -86,6 +87,7 @@ describe('characterApis', () => {
{
id: '51',
projectId: '42',
+ workflowRunId: '77',
name: '轻装信使',
description: null,
referenceImageUrl: 'https://cdn.windup.test/reference.png',
@@ -140,6 +142,7 @@ describe('characterApis', () => {
await characterApis.create({
projectId: '42',
+ workflowRunId: '77',
name: '轻装信使',
description: '项目角色',
referenceImageUrl: null,
@@ -148,6 +151,7 @@ describe('characterApis', () => {
expect(request?.method).toBe('POST')
await expect(request?.json()).resolves.toEqual({
project_id: 42,
+ workflow_run_id: 77,
name: '轻装信使',
description: '项目角色',
reference_image_url: null,
diff --git a/frontend/src/entities/character/index.ts b/frontend/src/entities/character/index.ts
index 43c2cf6c..6d101e71 100644
--- a/frontend/src/entities/character/index.ts
+++ b/frontend/src/entities/character/index.ts
@@ -38,6 +38,8 @@ export interface Outfit {
export interface Character {
id: string
projectId: string
+ /** 角色由哪一条制作流程产出;Workflow Editor 以它落实单画布单角色。 */
+ workflowRunId: string
name: string | null
description: string | null
referenceImageUrl: string | null
@@ -50,6 +52,7 @@ export interface Character {
/** 创建 Character 记录的字段;生成流程由 Workflow Editor 负责。 */
export interface CreateCharacterInput {
projectId: string
+ workflowRunId: string
name?: string | null
description?: string | null
referenceImageUrl?: string | null
@@ -99,6 +102,7 @@ interface CharacterDataDto {
interface CharacterDto {
id: number
project_id: number
+ workflow_run_id: number
name: string | null
description: string | null
reference_image_url: string | null
@@ -149,6 +153,7 @@ function mapCharacter(dto: CharacterDto): Character {
return {
id: characterId,
projectId: String(dto.project_id),
+ workflowRunId: String(dto.workflow_run_id),
name: dto.name,
description: dto.description,
referenceImageUrl: dto.reference_image_url,
@@ -215,6 +220,7 @@ export const characterApis: CharacterApis = {
method: 'POST',
json: {
project_id: toBackendId(input.projectId, 'projectId'),
+ workflow_run_id: toBackendId(input.workflowRunId, 'workflowRunId'),
name: input.name,
description: input.description,
reference_image_url: input.referenceImageUrl,
diff --git a/frontend/src/features/export/index.test.ts b/frontend/src/features/export/index.test.ts
new file mode 100644
index 00000000..c07157e1
--- /dev/null
+++ b/frontend/src/features/export/index.test.ts
@@ -0,0 +1,254 @@
+import { describe, expect, it } from 'vitest'
+
+import type { Character, CharacterApis, Generation, WorkflowRun } from '@/entities'
+
+import * as exportFeature from './index'
+
+interface ExportFeatureModule {
+ createCharacterAssetPublisher(apis: Pick): {
+ publishReviewedAction(input: {
+ character: Character
+ workflow: WorkflowRun
+ reviewNodeId: string
+ generation: Generation
+ }): Promise
+ }
+}
+
+describe('Character asset publisher', () => {
+ it('publishes the reviewed action into the existing outfit', async () => {
+ const updates: Character[] = []
+ const publisher = (
+ exportFeature as unknown as ExportFeatureModule
+ ).createCharacterAssetPublisher({
+ async update(character) {
+ updates.push(structuredClone(character))
+ return structuredClone(character)
+ },
+ })
+
+ const published = await publisher.publishReviewedAction({
+ character: characterFixture(),
+ workflow: workflowFixture(),
+ reviewNodeId: 'action-walk:review',
+ generation: completeAnimationFixture(),
+ })
+
+ expect(updates).toHaveLength(1)
+ expect(published).toMatchObject({
+ description: '人工维护的角色描述',
+ referenceImageUrl: 'https://assets.windup.test/current-master.png',
+ })
+ expect(published.outfits[0]).toMatchObject({
+ id: 'outfit-default',
+ previewUrl: 'https://assets.windup.test/outfit.png',
+ })
+ expect(published.outfits[0]?.actions).toEqual([
+ expect.objectContaining({ id: 'idle', type: 'idle' }),
+ {
+ id: 'action-walk',
+ outfitId: 'outfit-default',
+ name: '行走',
+ type: 'walk',
+ loop: true,
+ fps: 12,
+ frameCount: 2,
+ frames: [
+ { index: 0, imageUrl: 'https://assets.windup.test/walk-01.png', durationMs: null },
+ { index: 1, imageUrl: 'https://assets.windup.test/walk-02.png', durationMs: null },
+ ],
+ },
+ ])
+ })
+
+ it('replaces the same stable action instead of duplicating it on retry', async () => {
+ const publisher = (
+ exportFeature as unknown as ExportFeatureModule
+ ).createCharacterAssetPublisher({
+ async update(character) {
+ return structuredClone(character)
+ },
+ })
+ const input = {
+ workflow: workflowFixture(),
+ reviewNodeId: 'action-walk:review',
+ generation: completeAnimationFixture(),
+ }
+
+ const first = await publisher.publishReviewedAction({ character: characterFixture(), ...input })
+ const retried = await publisher.publishReviewedAction({ character: first, ...input })
+
+ expect(retried.outfits[0]?.actions.map((action) => action.id)).toEqual(['idle', 'action-walk'])
+ })
+
+ it('rejects a completed task from another project', async () => {
+ const publisher = createRejectingPublisher()
+
+ await expect(
+ publisher.publishReviewedAction({
+ character: characterFixture(),
+ workflow: workflowFixture(),
+ reviewNodeId: 'action-walk:review',
+ generation: { ...completeAnimationFixture(), projectId: 'another-project' },
+ }),
+ ).rejects.toThrow('Generation 与当前 WorkflowRun 不匹配')
+ })
+
+ it('rejects a task whose type is not complete_animation', async () => {
+ const publisher = createRejectingPublisher()
+ const wrongType = {
+ ...completeAnimationFixture(),
+ type: 'character_template',
+ } as unknown as Generation
+
+ await expect(
+ publisher.publishReviewedAction({
+ character: characterFixture(),
+ workflow: workflowFixture(),
+ reviewNodeId: 'action-walk:review',
+ generation: wrongType,
+ }),
+ ).rejects.toThrow('完整动画生成结果不可发布')
+ })
+})
+
+function createRejectingPublisher() {
+ return (exportFeature as unknown as ExportFeatureModule).createCharacterAssetPublisher({
+ async update() {
+ throw new Error('不应写入 Character')
+ },
+ })
+}
+
+function characterFixture(): Character {
+ return {
+ id: '51',
+ projectId: '42',
+ workflowRunId: '120',
+ name: '骑士',
+ description: '人工维护的角色描述',
+ referenceImageUrl: 'https://assets.windup.test/current-master.png',
+ dataVersion: 1,
+ status: 1,
+ outfits: [
+ {
+ id: 'outfit-default',
+ characterId: '51',
+ name: '常态造型',
+ description: null,
+ previewUrl: 'https://assets.windup.test/outfit.png',
+ actions: [
+ {
+ id: 'idle',
+ outfitId: 'outfit-default',
+ name: '待机',
+ type: 'idle',
+ loop: true,
+ fps: 8,
+ frameCount: 1,
+ frames: [
+ {
+ index: 0,
+ imageUrl: 'https://assets.windup.test/idle-01.png',
+ durationMs: null,
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ }
+}
+
+function workflowFixture(): WorkflowRun {
+ return {
+ id: '120',
+ projectId: '42',
+ version: 7,
+ storageStatus: 'active',
+ nodes: [
+ {
+ id: 'character-setup',
+ type: 'character-setup',
+ status: 'passed',
+ phase: 'completed',
+ dependsOnNodeIds: [],
+ generations: [],
+ error: null,
+ input: { prompt: '戴红围巾的像素骑士', referenceMedia: [] },
+ },
+ {
+ id: 'character-template',
+ type: 'character-template',
+ status: 'passed',
+ phase: 'completed',
+ dependsOnNodeIds: ['character-setup'],
+ generations: [],
+ error: null,
+ selectedImageUrl: 'https://assets.windup.test/master.png',
+ },
+ {
+ id: 'action-walk',
+ type: 'action-first-frame',
+ status: 'passed',
+ phase: 'completed',
+ dependsOnNodeIds: ['character-template'],
+ generations: [],
+ error: null,
+ input: {
+ outfitId: 'outfit-default',
+ name: '行走',
+ type: 'walk',
+ prompt: '轻快地向前行走',
+ fps: 12,
+ },
+ selectedFirstFrameUrl: 'https://assets.windup.test/walk-01.png',
+ },
+ {
+ id: 'action-walk:action-generation-method',
+ type: 'action-generation-method',
+ status: 'passed',
+ phase: 'completed',
+ dependsOnNodeIds: ['action-walk'],
+ generations: [],
+ error: null,
+ method: 'video-cropping',
+ },
+ {
+ id: 'action-walk:action-full-frame',
+ type: 'action-full-frame',
+ status: 'passed',
+ phase: 'completed',
+ dependsOnNodeIds: ['action-walk:action-generation-method'],
+ generations: [{ taskId: 'generation-walk', role: 'complete_animation' }],
+ error: null,
+ },
+ {
+ id: 'action-walk:review',
+ type: 'review',
+ status: 'active',
+ phase: 'reviewing',
+ dependsOnNodeIds: ['action-walk:action-full-frame'],
+ generations: [],
+ error: null,
+ },
+ ],
+ }
+}
+
+function completeAnimationFixture(): Generation<'complete_animation'> {
+ return {
+ id: 'generation-walk',
+ projectId: '42',
+ type: 'complete_animation',
+ status: 'completed',
+ error: null,
+ result: {
+ type: 'complete_animation',
+ frames: [
+ { url: 'https://assets.windup.test/walk-01.png' },
+ { url: 'https://assets.windup.test/walk-02.png' },
+ ],
+ },
+ }
+}
diff --git a/frontend/src/features/export/index.ts b/frontend/src/features/export/index.ts
index adfc5aad..706e80b9 100644
--- a/frontend/src/features/export/index.ts
+++ b/frontend/src/features/export/index.ts
@@ -1,6 +1,115 @@
-/** 把确认后的造型与动作导出到资产库。 */
-export interface ExportProps {
- runId: string
- characterId: string
- outfitId: string
+import type {
+ Action,
+ Character,
+ CharacterApis,
+ Generation,
+ WorkflowNode,
+ WorkflowRun,
+} from '@/entities'
+
+export interface PublishReviewedActionInput {
+ character: Character
+ workflow: WorkflowRun
+ reviewNodeId: string
+ generation: Generation
+}
+
+export interface CharacterAssetPublisher {
+ publishReviewedAction(input: PublishReviewedActionInput): Promise
+}
+
+/**
+ * 审核通过前把完整动画幂等写入 Character 资产树。Action ID 使用首帧节点 ID,
+ * 因而发布成功但 WorkflowRun 保存失败时可以安全重试,不会追加重复动作。
+ */
+export function createCharacterAssetPublisher(
+ characterApis: Pick,
+): CharacterAssetPublisher {
+ return {
+ async publishReviewedAction({ character, workflow, reviewNodeId, generation }) {
+ if (character.workflowRunId !== workflow.id || character.projectId !== workflow.projectId) {
+ throw new Error('Character 与当前 WorkflowRun 不匹配')
+ }
+
+ const reviewNode = findNode(workflow, reviewNodeId, 'review')
+ if (reviewNode.status !== 'active' || reviewNode.phase !== 'reviewing') {
+ throw new Error('只能发布正在审核的动作')
+ }
+ const fullFrameNode = findSingleDependency(workflow, reviewNode, 'action-full-frame')
+ if (fullFrameNode.status !== 'passed' || fullFrameNode.phase !== 'completed') {
+ throw new Error('完整动画尚未完成')
+ }
+ if (generation.projectId !== workflow.projectId) {
+ throw new Error('Generation 与当前 WorkflowRun 不匹配')
+ }
+ if (
+ generation.id !==
+ fullFrameNode.generations.find((item) => item.role === 'complete_animation')?.taskId ||
+ generation.type !== 'complete_animation' ||
+ generation.status !== 'completed' ||
+ generation.result?.type !== 'complete_animation' ||
+ generation.result.frames.length === 0
+ ) {
+ throw new Error('完整动画生成结果不可发布')
+ }
+
+ const methodNode = findSingleDependency(workflow, fullFrameNode, 'action-generation-method')
+ const firstFrameNode = findSingleDependency(workflow, methodNode, 'action-first-frame')
+ const templateNode = findSingleDependency(workflow, firstFrameNode, 'character-template')
+ if (!templateNode.selectedImageUrl) throw new Error('角色母版尚未确认')
+
+ const outfitIndex = character.outfits.findIndex(
+ (outfit) => outfit.id === firstFrameNode.input.outfitId,
+ )
+ if (outfitIndex < 0) throw new Error('动作所属造型不存在')
+
+ const action: Action = {
+ id: firstFrameNode.id,
+ outfitId: firstFrameNode.input.outfitId,
+ name: firstFrameNode.input.name,
+ type: firstFrameNode.input.type,
+ loop: firstFrameNode.input.type === 'idle' || firstFrameNode.input.type === 'walk',
+ fps: firstFrameNode.input.fps,
+ frameCount: generation.result.frames.length,
+ frames: generation.result.frames.map((frame, index) => ({
+ index,
+ imageUrl: frame.url,
+ durationMs: null,
+ })),
+ }
+ const targetOutfit = character.outfits[outfitIndex]!
+ const actionIndex = targetOutfit.actions.findIndex((item) => item.id === action.id)
+ const actions = [...targetOutfit.actions]
+ if (actionIndex >= 0) actions[actionIndex] = action
+ else actions.push(action)
+
+ const outfits = [...character.outfits]
+ outfits[outfitIndex] = { ...targetOutfit, actions }
+ return characterApis.update({
+ ...character,
+ outfits,
+ })
+ },
+ }
+}
+
+function findNode(
+ workflow: WorkflowRun,
+ nodeId: string,
+ type: TType,
+): Extract {
+ const node = workflow.nodes.find((candidate) => candidate.id === nodeId)
+ if (!node || node.type !== type) throw new Error(`目标节点不是 ${type}`)
+ return node as Extract
+}
+
+function findSingleDependency(
+ workflow: WorkflowRun,
+ node: WorkflowNode,
+ type: TType,
+): Extract {
+ if (node.dependsOnNodeIds.length !== 1) {
+ throw new Error(`${node.id} 必须且只能依赖一个节点`)
+ }
+ return findNode(workflow, node.dependsOnNodeIds[0]!, type)
}
diff --git a/frontend/src/features/generation/index.ts b/frontend/src/features/generation/index.ts
index 20157522..fe628fdd 100644
--- a/frontend/src/features/generation/index.ts
+++ b/frontend/src/features/generation/index.ts
@@ -3,8 +3,8 @@ export interface GenerationProps {
runId: string
/**
* 目标动作,生成母版等非动作任务可以省略。
- * 动作 ID 只在造型内唯一,所以造型由 runId 对应的 WorkflowRun.outfitId 决定,
- * 不能脱离 run 单独使用这个字段。
+ * 动作 ID 只在造型内唯一;调用方需用 runId 找到绑定 Character,再从
+ * WorkflowRun 动作节点的 input.outfitId 确定造型,不能脱离两者单独使用此字段。
*/
actionId?: string
}
diff --git a/frontend/src/features/workflow-controller/controller.test.ts b/frontend/src/features/workflow-controller/controller.test.ts
index 713d76c2..64cf347b 100644
--- a/frontend/src/features/workflow-controller/controller.test.ts
+++ b/frontend/src/features/workflow-controller/controller.test.ts
@@ -464,16 +464,20 @@ describe('WorkflowController', () => {
it('提交角色设定后在母版节点记录任务并进入候选选择', async () => {
const { controller, workflow, generation, asyncErrors } = createController()
- await controller.generateCharacterTemplate('setup-1', { spriteWidth: 64, spriteHeight: 64 })
+ await controller.generateCharacterTemplate('setup-1', {
+ spriteWidth: 64,
+ spriteHeight: 64,
+ input: { prompt: '戴红围巾的像素骑士', referenceMedia: [] },
+ })
- expect(generation.apis.create).toHaveBeenCalledWith(
- expect.objectContaining({
- type: 'character_template',
- prompt: '像素骑士',
- spriteWidth: 64,
- spriteHeight: 64,
- }),
- )
+ expect(generation.apis.create).toHaveBeenCalledWith({
+ type: 'character_template',
+ projectId: '1',
+ prompt: '戴红围巾的像素骑士',
+ referenceMedia: [],
+ spriteWidth: 64,
+ spriteHeight: 64,
+ })
expect(workflow.getSaved().nodes).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: 'setup-1', status: 'passed', phase: 'completed' }),
diff --git a/frontend/src/features/workflow-controller/controller.ts b/frontend/src/features/workflow-controller/controller.ts
index 251f85a8..a5e3ff91 100644
--- a/frontend/src/features/workflow-controller/controller.ts
+++ b/frontend/src/features/workflow-controller/controller.ts
@@ -15,6 +15,7 @@ import type {
MediaReference,
ReviewWorkflowNode,
WorkflowActionInput,
+ WorkflowCharacterInput,
WorkflowGenerationRef,
WorkflowGenerationRole,
WorkflowNode,
@@ -35,6 +36,8 @@ export interface AddActionInput {
export interface GenerateCharacterTemplateOptions {
spriteWidth: number
spriteHeight: number
+ /** 手动编辑器提交时覆盖 configuring 节点的初始输入;节点通过后不再改写。 */
+ input?: WorkflowCharacterInput
}
export interface GenerateActionOptions {
@@ -315,8 +318,20 @@ export function createWorkflowController({
if (setupNode.status !== 'active' || setupNode.phase !== 'configuring') {
throw new Error('角色设定节点当前不能提交')
}
+ const input = options.input
+ ? {
+ prompt: nonEmpty(options.input.prompt, '角色描述'),
+ referenceMedia: [...options.input.referenceMedia],
+ }
+ : setupNode.input
return unlockReadyNodes(
- replaceNode(run, { ...setupNode, status: 'passed', phase: 'completed', error: null }),
+ replaceNode(run, {
+ ...setupNode,
+ input,
+ status: 'passed',
+ phase: 'completed',
+ error: null,
+ }),
)
})
const templateNode = findSingleDependentNode(advanced, nodeId, 'character-template')
@@ -329,7 +344,8 @@ export function createWorkflowController({
projectId: run.projectId,
prompt: setupNode.input.prompt,
referenceMedia: setupNode.input.referenceMedia,
- ...options,
+ spriteWidth: options.spriteWidth,
+ spriteHeight: options.spriteHeight,
}
return input
})
diff --git a/frontend/src/pages/playtest/workbench/minimal-workbench.test.tsx b/frontend/src/pages/playtest/workbench/minimal-workbench.test.tsx
index e2b81e3f..0d0806eb 100644
--- a/frontend/src/pages/playtest/workbench/minimal-workbench.test.tsx
+++ b/frontend/src/pages/playtest/workbench/minimal-workbench.test.tsx
@@ -16,6 +16,7 @@ function frame(index: number, imageUrl: string) {
const character: Character = {
id: '51',
projectId: '42',
+ workflowRunId: 'workflow-run-51',
name: '轻装信使',
description: null,
referenceImageUrl: null,
diff --git a/frontend/src/pages/playtest/workbench/model.test.ts b/frontend/src/pages/playtest/workbench/model.test.ts
index 377a7a5c..b762dd33 100644
--- a/frontend/src/pages/playtest/workbench/model.test.ts
+++ b/frontend/src/pages/playtest/workbench/model.test.ts
@@ -7,6 +7,7 @@ import { createPlaytestModel } from './model'
const character: Character = {
id: '51',
projectId: '42',
+ workflowRunId: 'workflow-run-51',
name: '轻装信使',
description: null,
referenceImageUrl: null,
diff --git a/frontend/src/pages/workflow-editor/index.test.tsx b/frontend/src/pages/workflow-editor/index.test.tsx
index bb752c0d..40c5f83e 100644
--- a/frontend/src/pages/workflow-editor/index.test.tsx
+++ b/frontend/src/pages/workflow-editor/index.test.tsx
@@ -1,39 +1,1015 @@
/** @vitest-environment jsdom */
-import { cleanup, fireEvent, render, screen } from '@testing-library/react'
-import { afterEach, describe, expect, it, vi } from 'vitest'
-import { MemoryRouter, Route, Routes } from 'react-router'
+import type { ReactNode } from 'react'
+import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { Link, MemoryRouter, Route, Routes } from 'react-router'
+import type {
+ Character,
+ CharacterTemplateWorkflowNode,
+ Generation,
+ GenerationApis,
+ Project,
+ WorkflowRun,
+ WorkflowRunApis,
+} from '@/entities'
+import { createWorkflowController, type WorkflowController } from '@/features/workflow-controller'
import { WorkflowEditorPage } from './index'
+import type { WorkflowEditorSession } from './runtime'
+const { defaultSessionLoader, flowProps, fitView } = vi.hoisted(() => ({
+ defaultSessionLoader: vi.fn(),
+ flowProps: { current: null as Record | null },
+ fitView: vi.fn(),
+}))
+
+vi.mock('./runtime', async (importOriginal) => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ createDefaultRealWorkflowEditorSession: defaultSessionLoader,
+ }
+})
+
+vi.mock('@xyflow/react', async (importOriginal) => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ Controls: () => null,
+ Handle: () => null,
+ ReactFlow: ({ nodes, children, ...props }: TestFlowProps) => {
+ flowProps.current = { nodes, ...props }
+ return (
+
+ {nodes.map((node) => (
+
+ {node.data.content}
+
+ ))}
+ {children}
+
+ )
+ },
+ useReactFlow: () => ({ fitView }),
+ }
+})
+
+interface TestCanvasNode {
+ id: string
+ deletable?: boolean
+ draggable?: boolean
+ dragHandle?: string
+ data: { title: string; content: ReactNode }
+}
+
+interface TestFlowProps extends Record {
+ nodes: TestCanvasNode[]
+ children?: ReactNode
+}
+
+beforeEach(() => {
+ defaultSessionLoader.mockReset()
+ fitView.mockClear()
+ flowProps.current = null
+ window.history.replaceState({}, '', '/')
+})
afterEach(cleanup)
-describe('WorkflowEditorPage', () => {
- it('按六节点顺序展示资产生成方式选择', () => {
- const onSelect = vi.fn()
+describe('WorkflowEditorPage real runtime boundary', () => {
+ it('默认路由只恢复真实 WorkflowRun 会话', async () => {
+ defaultSessionLoader.mockResolvedValue(createSession())
+ window.history.replaceState({}, '', '/workflow-editor/42')
+
+ renderEditor('/workflow-editor/42')
+
+ expect(await screen.findByText('真实 WorkflowRun 接口')).toBeTruthy()
+ expect(defaultSessionLoader).toHaveBeenCalledWith('42')
+ })
+
+ it('在角色设定卡片提交描述后由 Controller 持久化并固定输入', async () => {
+ const session = createSession()
+ defaultSessionLoader.mockResolvedValue(session)
+
+ renderEditor('/workflow-editor/42')
+
+ const promptInput = await screen.findByRole('textbox', { name: '角色描述' })
+ expect(screen.queryByRole('textbox', { name: /角色名称/ })).toBeNull()
+ fireEvent.change(promptInput, { target: { value: '戴红围巾的短发少年冒险家' } })
+ fireEvent.click(screen.getByRole('button', { name: '生成角色候选' }))
+
+ await waitFor(() =>
+ expect(session.controller.getWorkflow().nodes[0]).toMatchObject({
+ status: 'passed',
+ phase: 'completed',
+ input: { prompt: '戴红围巾的短发少年冒险家', referenceMedia: [] },
+ }),
+ )
+ await waitFor(() => expect(screen.queryByRole('textbox', { name: '角色描述' })).toBeNull())
+ })
+
+ it('真实 Generation 尚未实现时展示接口错误,不回退到演示候选', async () => {
+ defaultSessionLoader.mockResolvedValue(createSession())
+ renderEditor('/workflow-editor/42')
+
+ fireEvent.click(await screen.findByRole('button', { name: '生成角色候选' }))
+
+ expect((await screen.findByRole('alert')).textContent).toContain('Generation 后端尚未实现')
+ expect(screen.queryByRole('button', { name: /选择角色候选/ })).toBeNull()
+ })
+
+ it('切换 WorkflowRun 时清空上一条任务的临时动作菜单', async () => {
+ defaultSessionLoader
+ .mockResolvedValueOnce(createSession(completedTemplateWorkflow('42')))
+ .mockResolvedValueOnce(createSession(completedTemplateWorkflow('43')))
+
render(
-
+
+ 下一条 WorkflowRun
- }
- />
+ } />
,
)
- expect(screen.getAllByRole('listitem')).toHaveLength(6)
- fireEvent.click(screen.getByRole('button', { name: /视频裁剪/ }))
- fireEvent.click(screen.getByRole('button', { name: /3D 转 2D/ }))
- expect(onSelect.mock.calls).toEqual([['video-cropping'], ['3d-to-2d']])
+ fireEvent.click(await screen.findByRole('button', { name: '添加动作分支' }))
+ expect(screen.getByRole('button', { name: '生成动作 ›' })).toBeTruthy()
+
+ fireEvent.click(screen.getByRole('link', { name: '下一条 WorkflowRun' }))
+ await waitFor(() =>
+ expect(screen.getByRole('img', { name: '已确认身份母版' }).getAttribute('src')).toBe(
+ 'https://assets.windup.test/43.png',
+ ),
+ )
+
+ expect(screen.queryByRole('button', { name: '生成动作 ›' })).toBeNull()
})
- it('没有 Controller 装配时不在页面内伪造选择', () => {
+ it('切换 WorkflowRun 后忽略上一条任务迟到的命令错误', async () => {
+ const pendingGeneration = deferred()
+ const createGeneration = vi.fn(() => pendingGeneration.promise)
+ defaultSessionLoader
+ .mockResolvedValueOnce(
+ createSession(workflowFixture(), {
+ generationApis: generationApisFixture({ create: createGeneration }),
+ }),
+ )
+ .mockResolvedValueOnce(createSession(completedTemplateWorkflow('43')))
+
render(
-
-
+
+ 下一条 WorkflowRun
+
+ } />
+
,
)
- expect(screen.getByRole('button', { name: /视频裁剪/ })).toHaveProperty('disabled', true)
- expect(screen.getByRole('button', { name: /3D 转 2D/ })).toHaveProperty('disabled', true)
+
+ fireEvent.click(await screen.findByRole('button', { name: '生成角色候选' }))
+ await waitFor(() => expect(createGeneration).toHaveBeenCalledTimes(1))
+ fireEvent.click(screen.getByRole('link', { name: '下一条 WorkflowRun' }))
+ await waitFor(() =>
+ expect(screen.getByRole('img', { name: '已确认身份母版' }).getAttribute('src')).toBe(
+ 'https://assets.windup.test/43.png',
+ ),
+ )
+
+ await act(async () => {
+ pendingGeneration.reject(new Error('旧 WorkflowRun 的迟到错误'))
+ await pendingGeneration.promise.catch(() => undefined)
+ })
+
+ expect(screen.queryByRole('alert')).toBeNull()
+ })
+
+ it('只采用同一 WorkflowRun 内最后一次 Generation 读取结果', async () => {
+ const oldRead = deferred()
+ const latestRead = deferred()
+ defaultSessionLoader.mockResolvedValue(createGenerationRaceSession(oldRead, latestRead))
+
+ renderEditor('/workflow-editor/42')
+ await waitFor(() => expect(latestFlowProps().nodes).toHaveLength(2))
+
+ await act(async () => {
+ latestRead.resolve(characterGeneration('new'))
+ await latestRead.promise
+ })
+ expect(screen.getByRole('img', { name: '角色候选 1' }).getAttribute('src')).toContain(
+ '/new.png',
+ )
+
+ await act(async () => {
+ oldRead.resolve(characterGeneration('old'))
+ await oldRead.promise
+ })
+ expect(screen.getByRole('img', { name: '角色候选 1' }).getAttribute('src')).toContain(
+ '/new.png',
+ )
+ })
+
+ it('从 WorkflowRun 绑定角色中明确选择造型,并只提交后端支持的动作类型', async () => {
+ const session = createSession(completedTemplateWorkflow('42'), {
+ character: characterFixture(),
+ })
+ defaultSessionLoader.mockResolvedValue(session)
+ renderEditor('/workflow-editor/42')
+
+ fireEvent.click(await screen.findByRole('button', { name: '添加动作分支' }))
+ fireEvent.click(screen.getByRole('button', { name: '生成动作 ›' }))
+ fireEvent.click(screen.getByRole('button', { name: '选择造型 夜行装' }))
+
+ expect(screen.queryByRole('button', { name: /Jump/ })).toBeNull()
+ fireEvent.click(screen.getByRole('button', { name: /Attack 攻击/ }))
+
+ await waitFor(() =>
+ expect(session.controller.getWorkflow().nodes).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ type: 'action-first-frame',
+ // 落库的动作名是中文短名,不含菜单里的英文前缀。
+ input: expect.objectContaining({ outfitId: 'night', type: 'attack', name: '攻击' }),
+ }),
+ ]),
+ ),
+ )
+ })
+
+ it('发布成功但审核保存失败时仍显式刷新 Character', async () => {
+ const publishReviewedAction = vi.fn(async () => characterFixture())
+ const session = createSession(reviewingActionWorkflow(), {
+ character: { ...characterFixture(), outfits: [] },
+ generationApis: generationApisFixture({
+ get: vi.fn().mockResolvedValue(completeAnimationGeneration()),
+ }),
+ publishReviewedAction,
+ })
+ vi.spyOn(session.controller, 'approveReview').mockRejectedValue(new Error('审核保存失败'))
+ defaultSessionLoader.mockResolvedValue(session)
+ renderEditor('/workflow-editor/42')
+
+ fireEvent.click(await screen.findByRole('button', { name: '审核通过' }))
+
+ expect((await screen.findByRole('alert')).textContent).toContain('审核保存失败')
+ expect(publishReviewedAction).toHaveBeenCalledWith('action-walk:review')
+ fireEvent.click(screen.getByRole('button', { name: '添加动作分支' }))
+ expect((screen.getByRole('button', { name: '生成动作 ›' }) as HTMLButtonElement).disabled).toBe(
+ false,
+ )
+ })
+
+ it('同一节点挂多个生成任务时按角色分别读取,后写入的不覆盖前一个', async () => {
+ const workflow = selectingTemplateWorkflow(3, 'template-task')
+ workflow.nodes[1] = {
+ ...(workflow.nodes[1] as CharacterTemplateWorkflowNode),
+ generations: [
+ { taskId: 'template-task', role: 'character_template' },
+ { taskId: 'animation-task', role: 'complete_animation' },
+ ],
+ }
+ defaultSessionLoader.mockResolvedValue(
+ createSession(workflow, {
+ generationApis: generationApisFixture({
+ get: vi.fn(async (_projectId: string, taskId: string) =>
+ taskId === 'template-task'
+ ? characterGeneration('template')
+ : completeAnimationGeneration(),
+ ) as GenerationApis['get'],
+ }),
+ }),
+ )
+
+ renderEditor('/workflow-editor/42')
+
+ expect((await screen.findByRole('img', { name: '角色候选 1' })).getAttribute('src')).toContain(
+ '/template.png',
+ )
+ })
+
+ it('已删除的节点不再读取生成结果', async () => {
+ const workflow = selectingTemplateWorkflow(3, 'template-task')
+ workflow.nodes[1] = {
+ ...(workflow.nodes[1] as CharacterTemplateWorkflowNode),
+ deletedAt: '2026-08-11T00:00:00.000Z',
+ }
+ const get = vi.fn(async () => characterGeneration('template'))
+ defaultSessionLoader.mockResolvedValue(
+ createSession(workflow, {
+ generationApis: generationApisFixture({ get: get as GenerationApis['get'] }),
+ }),
+ )
+
+ renderEditor('/workflow-editor/42')
+ await waitFor(() => expect(latestFlowProps().nodes).toHaveLength(1))
+
+ expect(get).not.toHaveBeenCalled()
+ })
+
+ it('WorkflowRun 推进但节点集合不变时不重置画布视角', async () => {
+ const controlled = createRestartSelectionSession()
+ defaultSessionLoader.mockResolvedValue(controlled.session)
+ renderEditor('/workflow-editor/42')
+ await waitFor(() => expect(latestFlowProps().nodes).toHaveLength(2))
+ await waitFor(() => expect(fitView).toHaveBeenCalled())
+
+ const afterFirstFit = fitView.mock.calls.length
+ act(() => controlled.emit(selectingTemplateWorkflow(9, 'another-task')))
+ await new Promise((resolve) => setTimeout(resolve, 80))
+
+ expect(fitView.mock.calls.length).toBe(afterFirstFit)
+ })
+
+ it('新增动作分支后重新取景,让新节点进入视野', async () => {
+ const controlled = createRestartSelectionSession()
+ defaultSessionLoader.mockResolvedValue(controlled.session)
+ renderEditor('/workflow-editor/42')
+ await waitFor(() => expect(latestFlowProps().nodes).toHaveLength(2))
+ await waitFor(() => expect(fitView).toHaveBeenCalled())
+
+ const afterFirstFit = fitView.mock.calls.length
+ act(() => controlled.emit(reviewingActionWorkflow()))
+ await waitFor(() => expect(fitView.mock.calls.length).toBeGreaterThan(afterFirstFit))
+ })
+
+ it('一条动作分支执行命令时不阻塞其他分支的操作', async () => {
+ const pendingPublish = deferred()
+ defaultSessionLoader.mockResolvedValue(
+ createSession(reviewingActionWorkflow(), {
+ character: characterFixture(),
+ publishReviewedAction: () => pendingPublish.promise,
+ }),
+ )
+ renderEditor('/workflow-editor/42')
+
+ fireEvent.click(await screen.findByRole('button', { name: '审核通过' }))
+ expect((screen.getByRole('button', { name: '审核通过' }) as HTMLButtonElement).disabled).toBe(
+ true,
+ )
+
+ fireEvent.click(screen.getByRole('button', { name: '添加动作分支' }))
+ expect((screen.getByRole('button', { name: '生成动作 ›' }) as HTMLButtonElement).disabled).toBe(
+ false,
+ )
+
+ await act(async () => {
+ pendingPublish.resolve(characterFixture())
+ await pendingPublish.promise
+ })
+ })
+
+ it('两条分支同时执行命令时,先起的那条不会因为后起的而解锁', async () => {
+ // 并行分支各自持锁:B 开始执行不能把 A 的锁顶掉,否则 A 的按钮会重新可点,
+ // 用户再点一次就是对同一个动作重复发布 + 重复审核。
+ const pendingA = deferred()
+ const pendingB = deferred()
+ const publishReviewedAction = vi.fn((reviewNodeId: string) =>
+ reviewNodeId === 'action-a:review' ? pendingA.promise : pendingB.promise,
+ )
+ defaultSessionLoader.mockResolvedValue(
+ createSession(reviewingActionWorkflow('action-a', 'action-b'), {
+ character: characterFixture(),
+ publishReviewedAction,
+ }),
+ )
+ renderEditor('/workflow-editor/42')
+
+ const approveButtons = await screen.findAllByRole('button', { name: '审核通过' })
+ expect(approveButtons).toHaveLength(2)
+ const [approveA, approveB] = approveButtons as [HTMLButtonElement, HTMLButtonElement]
+
+ fireEvent.click(approveA)
+ expect(approveA.disabled).toBe(true)
+
+ fireEvent.click(approveB)
+ await waitFor(() => expect(publishReviewedAction).toHaveBeenCalledTimes(2))
+
+ // A 的请求仍未返回,它必须还锁着。
+ expect(approveA.disabled).toBe(true)
+ expect(approveB.disabled).toBe(true)
+
+ fireEvent.click(approveA)
+ expect(publishReviewedAction).toHaveBeenCalledTimes(2)
+
+ await act(async () => {
+ pendingA.resolve(characterFixture())
+ pendingB.resolve(characterFixture())
+ await Promise.all([pendingA.promise, pendingB.promise])
+ })
+ })
+
+ it('已经到终态的生成结果只读取一次,后续推进复用缓存', async () => {
+ // WorkflowRun 每推进一步都会 emit,若每次都重拉全部结果,一条多分支的流程
+ // 点一次按钮就是十几个 GET。终态结果不会再变,没有重读的理由。
+ const controlled = createRestartSelectionSession()
+ defaultSessionLoader.mockResolvedValue(controlled.session)
+ renderEditor('/workflow-editor/42')
+
+ await waitFor(() => expect(screen.getByRole('img', { name: '角色候选 1' })).toBeTruthy())
+ const reads = controlled.session.controller.getGeneration as ReturnType
+ const afterFirstRead = reads.mock.calls.length
+ expect(afterFirstRead).toBeGreaterThan(0)
+
+ act(() => controlled.emit(selectingTemplateWorkflow(7, 'old-task')))
+ act(() => controlled.emit(selectingTemplateWorkflow(8, 'old-task')))
+ await waitFor(() => expect(latestFlowProps().nodes).toHaveLength(2))
+
+ expect(reads.mock.calls.length).toBe(afterFirstRead)
+ })
+
+ it('未到终态的生成任务每次推进都重新读取', async () => {
+ const controlled = createRestartSelectionSession({ status: 'running' })
+ defaultSessionLoader.mockResolvedValue(controlled.session)
+ renderEditor('/workflow-editor/42')
+
+ const reads = controlled.session.controller.getGeneration as ReturnType
+ await waitFor(() => expect(reads.mock.calls.length).toBeGreaterThan(0))
+ const afterFirstRead = reads.mock.calls.length
+
+ act(() => controlled.emit(selectingTemplateWorkflow(7, 'old-task')))
+ await waitFor(() => expect(reads.mock.calls.length).toBeGreaterThan(afterFirstRead))
+ })
+
+ it('失败节点可以从当前节点重做', async () => {
+ const session = createSession(failedTemplateWorkflow())
+ defaultSessionLoader.mockResolvedValue(session)
+ renderEditor('/workflow-editor/42')
+
+ fireEvent.click(await screen.findByRole('button', { name: '从此节点重做' }))
+
+ expect(await screen.findByRole('button', { name: '生成角色候选' })).toBeTruthy()
+ expect(session.controller.getWorkflow().nodes[1]).toMatchObject({
+ status: 'active',
+ phase: 'ready',
+ error: null,
+ })
+ })
+
+ it('节点重做后不会沿用上一轮 Generation 的候选选择', async () => {
+ const controlled = createRestartSelectionSession()
+ defaultSessionLoader.mockResolvedValue(controlled.session)
+ renderEditor('/workflow-editor/42')
+
+ fireEvent.click(await screen.findByRole('button', { name: '选择角色候选 1' }))
+ expect(
+ (screen.getByRole('button', { name: '确认身份母版' }) as HTMLButtonElement).disabled,
+ ).toBe(false)
+
+ act(() => controlled.emit(failedTemplateWorkflow()))
+ fireEvent.click(await screen.findByRole('button', { name: '从此节点重做' }))
+ act(() => controlled.emit(selectingTemplateWorkflow(6, 'new-task')))
+ await waitFor(() =>
+ expect(screen.getByRole('img', { name: '角色候选 1' }).getAttribute('src')).toContain(
+ '/new.png',
+ ),
+ )
+
+ expect(
+ (screen.getByRole('button', { name: '确认身份母版' }) as HTMLButtonElement).disabled,
+ ).toBe(true)
+ })
+
+ it('Generation 候选集合变化时使旧选择失效', async () => {
+ const controlled = createRestartSelectionSession()
+ defaultSessionLoader.mockResolvedValue(controlled.session)
+ renderEditor('/workflow-editor/42')
+
+ fireEvent.click(await screen.findByRole('button', { name: '选择角色候选 1' }))
+ act(() => controlled.emit(selectingTemplateWorkflow(4, 'new-task')))
+ await waitFor(() =>
+ expect(screen.getByRole('img', { name: '角色候选 1' }).getAttribute('src')).toContain(
+ '/new.png',
+ ),
+ )
+
+ expect(
+ (screen.getByRole('button', { name: '确认身份母版' }) as HTMLButtonElement).disabled,
+ ).toBe(true)
+ })
+
+ it('生成接口提交失败后仍保留角色候选重试入口', async () => {
+ defaultSessionLoader.mockResolvedValue(createSession())
+ renderEditor('/workflow-editor/42')
+
+ fireEvent.click(await screen.findByRole('button', { name: '生成角色候选' }))
+
+ expect((await screen.findByRole('alert')).textContent).toContain('Generation 后端尚未实现')
+ expect(screen.getByRole('button', { name: '生成角色候选' })).toBeTruthy()
+ })
+
+ it('恢复候选读取失败时展示错误并允许重新读取', async () => {
+ const generationApis = generationApisFixture()
+ defaultSessionLoader.mockResolvedValue(
+ createSession(selectingTemplateWorkflow(3, 'existing-task'), { generationApis }),
+ )
+ renderEditor('/workflow-editor/42')
+
+ expect((await screen.findByRole('alert')).textContent).toContain('Generation 后端尚未实现')
+ fireEvent.click(screen.getByRole('button', { name: '重试读取生成结果' }))
+
+ await waitFor(() => expect(generationApis.get).toHaveBeenCalledTimes(2))
+ })
+
+ it('恢复生成中节点失败后仍可从该节点重做', async () => {
+ const session = createSession(generatingTemplateWorkflow())
+ defaultSessionLoader.mockResolvedValue(session)
+ renderEditor('/workflow-editor/42')
+
+ expect((await screen.findByRole('alert')).textContent).toContain('Generation 后端尚未实现')
+ fireEvent.click(await screen.findByRole('button', { name: '从此节点重做' }))
+
+ expect(await screen.findByRole('button', { name: '生成角色候选' })).toBeTruthy()
+ expect(session.controller.getWorkflow().nodes[1]).toMatchObject({
+ status: 'active',
+ phase: 'ready',
+ error: null,
+ })
+ })
+
+ it('展示 Controller 的异步错误订阅', async () => {
+ let reportError: ((error: Error) => void) | null = null
+ const session = createSession()
+ session.subscribeErrors = vi.fn((listener) => {
+ reportError = listener
+ return () => undefined
+ })
+ defaultSessionLoader.mockResolvedValue(session)
+ renderEditor('/workflow-editor/42')
+ await screen.findByText('真实 WorkflowRun 接口')
+
+ act(() => reportError?.(new Error('异步保存失败')))
+
+ expect(screen.getByRole('alert').textContent).toContain('异步保存失败')
+ })
+
+ it('把 React Flow 限定为系统节点的拖动画布,不允许自由连线、重连或删除', async () => {
+ defaultSessionLoader.mockResolvedValue(createSession())
+ renderEditor('/workflow-editor/42')
+
+ await waitFor(() => expect(latestFlowProps().nodes).toHaveLength(2))
+
+ expect(latestFlowProps()).toMatchObject({
+ nodesConnectable: false,
+ edgesReconnectable: false,
+ deleteKeyCode: null,
+ })
+ expect('onConnect' in latestFlowProps()).toBe(false)
+ expect(latestFlowProps().nodes).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ deletable: false,
+ draggable: true,
+ dragHandle: '.workflow-card__handle',
+ }),
+ ]),
+ )
+ expect(latestFlowProps().edges).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ selectable: false,
+ deletable: false,
+ // 未确认的连线画成流动虚线,样式由 workflow-edge--flowing 承担。
+ className: 'workflow-edge--flowing',
+ }),
+ ]),
+ )
+ })
+
+ it('没有 runId 时不创建替代 WorkflowRun', () => {
+ render(
+
+
+ } />
+
+ ,
+ )
+
+ expect(screen.getByRole('heading', { name: '工作流编辑器' })).toBeTruthy()
+ expect(screen.getByText('需要从已有 WorkflowRun 进入')).toBeTruthy()
+ expect(defaultSessionLoader).not.toHaveBeenCalled()
})
})
+
+function renderEditor(path: string) {
+ return render(
+
+
+ } />
+
+ ,
+ )
+}
+
+interface SessionFixtureOptions {
+ character?: Character | null
+ generationApis?: GenerationApis
+ publishReviewedAction?(reviewNodeId: string): Promise
+}
+
+function createSession(
+ initialWorkflow = workflowFixture(),
+ options: SessionFixtureOptions = {},
+): WorkflowEditorSession {
+ let workflow = structuredClone(initialWorkflow)
+ const workflowRunApis: WorkflowRunApis = {
+ async create() {
+ return structuredClone(workflow)
+ },
+ async get() {
+ return structuredClone(workflow)
+ },
+ async update(next) {
+ workflow = { ...structuredClone(next), version: workflow.version + 1 }
+ return structuredClone(workflow)
+ },
+ async remove() {},
+ }
+ const generationApis = options.generationApis ?? generationApisFixture()
+ const controller = createWorkflowController({
+ workflow,
+ workflowRunApis,
+ generationApis,
+ onAsyncError: vi.fn(),
+ })
+
+ return {
+ controller,
+ project: projectFixture(),
+ character: options.character ?? null,
+ publishReviewedAction:
+ options.publishReviewedAction ?? (() => Promise.reject(new Error('资产发布未装配'))),
+ subscribeErrors: () => () => undefined,
+ dispose: () => controller.dispose(),
+ }
+}
+
+function generationApisFixture(overrides: Partial = {}): GenerationApis {
+ return {
+ create: vi.fn(() =>
+ Promise.reject(new Error('Generation 后端尚未实现')),
+ ) as GenerationApis['create'],
+ get: vi.fn(() => Promise.reject(new Error('Generation 后端尚未实现'))),
+ subscribe: vi.fn(() => () => undefined),
+ ...overrides,
+ }
+}
+
+function latestFlowProps() {
+ if (!flowProps.current) throw new Error('React Flow 尚未渲染')
+ return flowProps.current as {
+ nodes: TestCanvasNode[]
+ edges: Array>
+ nodesConnectable?: boolean
+ edgesReconnectable?: boolean
+ deleteKeyCode?: null
+ } & Record
+}
+
+function workflowFixture(): WorkflowRun {
+ return {
+ id: '42',
+ projectId: '1',
+ version: 3,
+ storageStatus: 'active',
+ nodes: [
+ {
+ id: 'character-setup',
+ type: 'character-setup',
+ status: 'active',
+ phase: 'configuring',
+ dependsOnNodeIds: [],
+ generations: [],
+ error: null,
+ input: { prompt: '短发少年冒险家', referenceMedia: [] },
+ },
+ {
+ id: 'character-template',
+ type: 'character-template',
+ status: 'locked',
+ phase: 'ready',
+ dependsOnNodeIds: ['character-setup'],
+ generations: [],
+ error: null,
+ selectedImageUrl: null,
+ },
+ ],
+ }
+}
+
+function completedTemplateWorkflow(id: string): WorkflowRun {
+ return {
+ id,
+ projectId: '1',
+ version: 3,
+ storageStatus: 'active',
+ nodes: [
+ {
+ id: 'character-setup',
+ type: 'character-setup',
+ status: 'passed',
+ phase: 'completed',
+ dependsOnNodeIds: [],
+ generations: [],
+ error: null,
+ input: { prompt: '短发少年冒险家', referenceMedia: [] },
+ },
+ {
+ id: 'character-template',
+ type: 'character-template',
+ status: 'passed',
+ phase: 'completed',
+ dependsOnNodeIds: ['character-setup'],
+ generations: [],
+ error: null,
+ selectedImageUrl: `https://assets.windup.test/${id}.png`,
+ },
+ ],
+ }
+}
+
+function reviewingActionWorkflow(...actionIds: string[]): WorkflowRun {
+ const workflow = completedTemplateWorkflow('42')
+ workflow.version = 7
+ for (const actionId of actionIds.length > 0 ? actionIds : ['action-walk']) {
+ workflow.nodes.push(
+ {
+ id: actionId,
+ type: 'action-first-frame',
+ status: 'passed',
+ phase: 'completed',
+ dependsOnNodeIds: ['character-template'],
+ generations: [],
+ error: null,
+ input: {
+ outfitId: 'day',
+ name: '行走',
+ type: 'walk',
+ prompt: null,
+ fps: 12,
+ },
+ selectedFirstFrameUrl: 'https://assets.windup.test/walk-01.png',
+ },
+ {
+ id: `${actionId}:method`,
+ type: 'action-generation-method',
+ status: 'passed',
+ phase: 'completed',
+ dependsOnNodeIds: [actionId],
+ generations: [],
+ error: null,
+ method: 'video-cropping',
+ },
+ {
+ id: `${actionId}:full-frame`,
+ type: 'action-full-frame',
+ status: 'passed',
+ phase: 'completed',
+ dependsOnNodeIds: [`${actionId}:method`],
+ generations: [{ taskId: `generation-${actionId}`, role: 'complete_animation' }],
+ error: null,
+ },
+ {
+ id: `${actionId}:review`,
+ type: 'review',
+ status: 'active',
+ phase: 'reviewing',
+ dependsOnNodeIds: [`${actionId}:full-frame`],
+ generations: [],
+ error: null,
+ },
+ )
+ }
+ return workflow
+}
+
+function failedTemplateWorkflow(): WorkflowRun {
+ const workflow = completedTemplateWorkflow('42')
+ workflow.nodes[1] = {
+ id: 'character-template',
+ type: 'character-template',
+ status: 'failed',
+ phase: 'generating',
+ dependsOnNodeIds: ['character-setup'],
+ generations: [{ taskId: 'failed-task', role: 'character_template' }],
+ error: '候选生成失败',
+ selectedImageUrl: null,
+ }
+ return workflow
+}
+
+function selectingTemplateWorkflow(version: number, taskId: string): WorkflowRun {
+ const workflow = completedTemplateWorkflow('42')
+ workflow.version = version
+ workflow.nodes[1] = {
+ id: 'character-template',
+ type: 'character-template',
+ status: 'active',
+ phase: 'selecting',
+ dependsOnNodeIds: ['character-setup'],
+ generations: [{ taskId, role: 'character_template' }],
+ error: null,
+ selectedImageUrl: null,
+ }
+ return workflow
+}
+
+function generatingTemplateWorkflow(): WorkflowRun {
+ const workflow = completedTemplateWorkflow('42')
+ workflow.nodes[1] = {
+ id: 'character-template',
+ type: 'character-template',
+ status: 'active',
+ phase: 'generating',
+ dependsOnNodeIds: ['character-setup'],
+ generations: [{ taskId: 'in-flight-task', role: 'character_template' }],
+ error: null,
+ selectedImageUrl: null,
+ }
+ return workflow
+}
+
+function createGenerationRaceSession(
+ oldRead: Deferred,
+ latestRead: Deferred,
+): WorkflowEditorSession {
+ const first = selectingTemplateWorkflow(3, 'old-task')
+ const latest = selectingTemplateWorkflow(4, 'new-task')
+ let listener: ((workflow: WorkflowRun) => void) | null = null
+ let readCount = 0
+ const controller = {
+ getWorkflow: () => structuredClone(latest),
+ subscribe(nextListener: (workflow: WorkflowRun) => void) {
+ listener = nextListener
+ nextListener(structuredClone(first))
+ return () => {
+ listener = null
+ }
+ },
+ async resume() {
+ listener?.(structuredClone(latest))
+ },
+ getGeneration: vi.fn(() => {
+ readCount += 1
+ return readCount === 1 ? oldRead.promise : latestRead.promise
+ }),
+ dispose: vi.fn(),
+ } as unknown as WorkflowController
+
+ return {
+ controller,
+ project: projectFixture(),
+ character: null,
+ publishReviewedAction: vi.fn(async () => Promise.reject(new Error('资产发布未装配'))),
+ subscribeErrors: () => () => undefined,
+ dispose: () => controller.dispose(),
+ }
+}
+
+function createRestartSelectionSession(options: { status?: Generation['status'] } = {}): {
+ session: WorkflowEditorSession
+ emit(workflow: WorkflowRun): void
+} {
+ let current = selectingTemplateWorkflow(3, 'old-task')
+ let listener: ((workflow: WorkflowRun) => void) | null = null
+ const emit = (workflow: WorkflowRun) => {
+ current = structuredClone(workflow)
+ listener?.(structuredClone(current))
+ }
+ const controller = {
+ getWorkflow: () => structuredClone(current),
+ subscribe(nextListener: (workflow: WorkflowRun) => void) {
+ listener = nextListener
+ nextListener(structuredClone(current))
+ return () => {
+ listener = null
+ }
+ },
+ resume: vi.fn(async () => undefined),
+ getGeneration: vi.fn(async () => {
+ const taskId = current.nodes[1]?.generations[0]?.taskId
+ if (!taskId) return null
+ const generation = characterGeneration(taskId.startsWith('new') ? 'new' : 'old')
+ return options.status ? { ...generation, status: options.status } : generation
+ }),
+ restartFromNode: vi.fn(async () => {
+ const ready = completedTemplateWorkflow('42')
+ ready.version = current.version + 1
+ ready.nodes[1] = {
+ id: 'character-template',
+ type: 'character-template',
+ status: 'active',
+ phase: 'ready',
+ dependsOnNodeIds: ['character-setup'],
+ generations: [],
+ error: null,
+ selectedImageUrl: null,
+ }
+ emit(ready)
+ }),
+ confirmCharacterTemplate: vi.fn(async () => undefined),
+ dispose: vi.fn(),
+ } as unknown as WorkflowController
+
+ return {
+ session: {
+ controller,
+ project: projectFixture(),
+ character: null,
+ publishReviewedAction: vi.fn(async () => Promise.reject(new Error('资产发布未装配'))),
+ subscribeErrors: () => () => undefined,
+ dispose: () => controller.dispose(),
+ },
+ emit,
+ }
+}
+
+function characterGeneration(label: string): Generation {
+ return {
+ id: `${label}-task`,
+ projectId: '1',
+ type: 'character_template',
+ status: 'completed',
+ result: {
+ type: 'character_template',
+ images: [{ url: `https://assets.windup.test/${label}.png` }],
+ },
+ error: null,
+ }
+}
+
+function completeAnimationGeneration(): Generation<'complete_animation'> {
+ return {
+ id: 'generation-walk',
+ projectId: '1',
+ type: 'complete_animation',
+ status: 'completed',
+ result: {
+ type: 'complete_animation',
+ frames: [
+ { url: 'https://assets.windup.test/walk-01.png' },
+ { url: 'https://assets.windup.test/walk-02.png' },
+ ],
+ },
+ error: null,
+ }
+}
+
+function characterFixture(): Character {
+ return {
+ id: '9',
+ projectId: '1',
+ workflowRunId: '42',
+ name: '正式角色',
+ description: null,
+ referenceImageUrl: null,
+ dataVersion: 1,
+ status: 1,
+ outfits: [
+ {
+ id: 'day',
+ characterId: '9',
+ name: '常态装',
+ description: null,
+ previewUrl: null,
+ actions: [],
+ },
+ {
+ id: 'night',
+ characterId: '9',
+ name: '夜行装',
+ description: null,
+ previewUrl: null,
+ actions: [],
+ },
+ ],
+ }
+}
+
+interface Deferred {
+ promise: Promise
+ resolve(value: T): void
+ reject(reason: unknown): void
+}
+
+function deferred(): Deferred {
+ let resolve!: (value: T) => void
+ let reject!: (reason: unknown) => void
+ const promise = new Promise((resolvePromise, rejectPromise) => {
+ resolve = resolvePromise
+ reject = rejectPromise
+ })
+ return { promise, resolve, reject }
+}
+
+function projectFixture(): Project {
+ return {
+ id: '1',
+ ownerId: '7',
+ workflowId: null,
+ name: '正式项目',
+ perspective: 'side',
+ directionalMovement: 'single',
+ spriteSize: { width: 64, height: 64 },
+ gameStyle: null,
+ sampleImageUrl: null,
+ createdAt: '2026-08-10T00:00:00.000Z',
+ updatedAt: '2026-08-10T00:00:00.000Z',
+ }
+}
diff --git a/frontend/src/pages/workflow-editor/index.tsx b/frontend/src/pages/workflow-editor/index.tsx
index ef527e41..cab7bd7a 100644
--- a/frontend/src/pages/workflow-editor/index.tsx
+++ b/frontend/src/pages/workflow-editor/index.tsx
@@ -1,90 +1,1150 @@
+import {
+ Controls,
+ Handle,
+ Position,
+ ReactFlow,
+ applyNodeChanges,
+ type Edge,
+ type Node,
+ type NodeChange,
+ type NodeProps,
+ useReactFlow,
+} from '@xyflow/react'
+import '@xyflow/react/dist/style.css'
+import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { useParams } from 'react-router'
-import type { ActionGenerationMethod } from '@/entities'
-import { PageContainer } from '@/shared/ui'
-
-const WORKFLOW_CARDS = [
- { index: '01', title: '角色设定', detail: '输入身份、外观与参考图' },
- { index: '02', title: '角色母版', detail: '生成并确认角色母版' },
- { index: '03', title: '动作首帧', detail: '生成并确认动作起始帧' },
- { index: '04', title: '资产生成方式', detail: '选择视频裁剪或 3D 转 2D' },
- { index: '05', title: '完整动画', detail: '按所选路线生成 32 帧动画' },
- { index: '06', title: '审核', detail: '核验结果并进入 Playtest' },
-] as const
+import {
+ CHARACTER_PERSPECTIVE,
+ DIRECTIONAL_MOVEMENT,
+ type ActionFirstFrameWorkflowNode,
+ type ActionFullFrameWorkflowNode,
+ type ActionGenerationMethodWorkflowNode,
+ type Character,
+ type CharacterSetupWorkflowNode,
+ type CharacterTemplateWorkflowNode,
+ type Generation,
+ type Project,
+ type ReviewWorkflowNode,
+ type WorkflowGenerationRole,
+ type WorkflowNode,
+ type WorkflowRun,
+} from '@/entities'
+import type { WorkflowController } from '@/features/workflow-controller'
+import { createDefaultRealWorkflowEditorSession, type WorkflowEditorSession } from './runtime'
+import './workflow-editor.css'
export interface WorkflowEditorPageProps {
- selectedGenerationMethod?: ActionGenerationMethod | null
- onSelectGenerationMethod?: (method: ActionGenerationMethod) => void
+ loadSession?: (runId: string) => Promise
}
-/** 工作流画布骨架;真实状态只来自 WorkflowRun,不在页面内伪造节点推进。 */
-export function WorkflowEditorPage({
- selectedGenerationMethod = null,
- onSelectGenerationMethod,
-}: WorkflowEditorPageProps = {}) {
+type WorkflowCardData = {
+ title: string
+ eyebrow: string
+ status: WorkflowNode['status']
+ content: ReactNode
+}
+
+type WorkflowCardNode = Node
+type ActionMenuLevel = 'root' | 'outfits' | 'actions'
+
+/**
+ * 菜单里的预设动作。label 只用于展示,name 是落进 WorkflowRun 的动作名——
+ * 两者分开写,改菜单文案不会连带改掉已经落库的数据。
+ */
+const ACTION_PRESETS = [
+ {
+ type: 'idle',
+ label: 'Idle 待机',
+ name: '待机',
+ prompt: '平稳呼吸,重心轻微起伏',
+ },
+ { type: 'walk', label: 'Walk 行走', name: '行走', prompt: '轻快地向前行走' },
+ {
+ type: 'attack',
+ label: 'Attack 攻击',
+ name: '攻击',
+ prompt: '蓄力后向前攻击并回到准备姿态',
+ },
+] as const
+
+const ACTION_PRESET_HINT = '预设动作 · 逐帧生成'
+
+/** 角色设定与身份母版为所有动作分支共用,归在这条虚拟分支下。 */
+const SHARED_BRANCH = 'shared'
+
+/*
+ 卡片内部复用三次以上的样式串。原来靠 .workflow-card button 这类后代选择器统一施加,
+ 搬成工具类后写在这里,好处是能看见哪些元素共用同一套外观,而不是被选择器隐式波及。
+ nodrag/nopan/nowheel 是 React Flow 的约定类:让卡片内的交互不被画布手势吞掉。
+*/
+const CARD_STACK = 'grid gap-[17px] nodrag nopan nowheel'
+
+const CARD_BUTTON =
+ 'min-h-[42px] rounded-lg border border-[#34533d] bg-[#34533d] px-3 py-[9px] text-[11px] ' +
+ 'font-[750] text-[#edf4ee] enabled:hover:border-[#263f2e] enabled:hover:bg-[#263f2e] ' +
+ 'aria-pressed:border-[#263f2e] aria-pressed:bg-[#263f2e] disabled:cursor-not-allowed ' +
+ 'disabled:border-[#e2e5e1] disabled:bg-[#e2e5e1] disabled:text-[#969d97]'
+
+/** 缩略图按钮:沿用卡片按钮的尺寸约定,但换成浅底,让图片自己当主角。 */
+const THUMB_BUTTON =
+ 'min-h-[42px] rounded-lg border border-[var(--editor-line)] bg-[#fbfcfb] p-1 ' +
+ 'aria-pressed:border-[var(--editor-ink)] aria-pressed:bg-white ' +
+ 'aria-pressed:shadow-[0_0_0_2px_rgb(31_35_41_/_12%)] disabled:cursor-not-allowed'
+
+const THUMB_IMAGE = 'block aspect-square w-full rounded-lg object-cover'
+
+/** 已确认的母版/首帧:像素资产按原样放大,不做平滑。 */
+const MASTER_IMAGE =
+ 'block aspect-square w-full rounded-xl border border-[var(--editor-line)] bg-[#f6f7f6] ' +
+ 'object-cover [image-rendering:pixelated]'
+
+const CARD_SUMMARY =
+ 'm-0 rounded-[10px] border border-[var(--editor-line)] bg-[#f6f7f6] px-3 py-2.5 ' +
+ 'text-[11px] leading-[1.6] text-[var(--editor-muted)]'
+
+const CARD_TEXT = 'm-0 text-[11px] leading-[1.6] text-[var(--editor-muted)]'
+
+/** 加号菜单里的条目:撑满菜单宽度的两行文字,跟卡片主按钮完全不同。 */
+const MENU_ITEM =
+ 'flex min-h-0 cursor-pointer flex-col gap-0.5 border-0 px-3 py-[9px] text-left ' +
+ 'text-[var(--editor-ink)] not-first:border-t not-first:border-t-[rgb(31_35_41_/_6%)] ' +
+ 'enabled:hover:bg-[#f2f4f2] disabled:cursor-not-allowed disabled:opacity-45'
+
+/**
+ * 菜单里的头一条:返回上一级,或 root 层的主入口。比其余条目弱一档,
+ * 原样式靠 :first-child 选择器实现,这里改成显式挂类,位置换了也不会失灵。
+ */
+const MENU_ITEM_LEAD = 'font-medium text-[var(--editor-muted)]'
+
+const MENU_ITEM_TITLE = 'text-xs font-semibold'
+const MENU_ITEM_HINT = 'text-[10px] text-[var(--editor-muted)]'
+
+const nodeTypes = { 'workflow-card': WorkflowCard }
+
+/**
+ * 页面只订阅 Controller 的 WorkflowRun 并把它投影为画布;选择、菜单、位置和 busy
+ * 都是临时 UI 状态,不会写出第二份流程状态机。
+ */
+export function WorkflowEditorPage({ loadSession }: WorkflowEditorPageProps = {}) {
const { runId } = useParams<{ runId: string }>()
+ const [session, setSession] = useState(null)
+ const [character, setCharacter] = useState(null)
+ const [run, setRun] = useState(null)
+ const [generations, setGenerations] = useState>({})
+ const [selectedImages, setSelectedImages] = useState>({})
+ const [actionMenuOpen, setActionMenuOpen] = useState(false)
+ const [actionMenuLevel, setActionMenuLevel] = useState('root')
+ const [selectedOutfitId, setSelectedOutfitId] = useState(null)
+ /** 正在执行命令的分支。必须是集合:并行分支各自持锁,后起的不能顶掉先起的。 */
+ const [busyBranches, setBusyBranches] = useState>(() => new Set())
+ const [error, setError] = useState(null)
+ const [resumeError, setResumeError] = useState(null)
+ const [generationReadError, setGenerationReadError] = useState(null)
+ const [canvasNodes, setCanvasNodes] = useState([])
+ /** 当前会话的有效期。换 WorkflowRun 就 abort,所有异步回调只认这一个信号。 */
+ const sessionAbortRef = useRef(null)
+ /** 最近一次生成结果读取。同一会话内被新的读取顶掉时 abort,避免旧结果盖住新结果。 */
+ const latestReadAbortRef = useRef(null)
+ /** 已到终态的生成结果,按 taskId 记住,避免每次推进都重拉一遍。 */
+ const settledGenerationsRef = useRef(new Map())
+
+ const requestGenerations = useCallback(
+ (targetSession: WorkflowEditorSession, targetRun: WorkflowRun, sessionSignal: AbortSignal) => {
+ latestReadAbortRef.current?.abort()
+ const readAbort = new AbortController()
+ latestReadAbortRef.current = readAbort
+ const outdated = () => sessionSignal.aborted || readAbort.signal.aborted
+ void readGenerations(targetSession.controller, targetRun, settledGenerationsRef.current)
+ .then((results) => {
+ if (outdated()) return
+ setGenerations(results)
+ setGenerationReadError(null)
+ })
+ .catch((cause: unknown) => {
+ if (outdated()) return
+ setGenerationReadError(errorMessage(cause, '读取生成结果失败'))
+ })
+ },
+ [],
+ )
+
+ const runCommand = useCallback(
+ (branchKey: string, command: () => Promise) => {
+ // 同一分支内互斥,防重复提交;别的分支照常能操作,也不会被这条命令解锁。
+ if (busyBranches.has(branchKey)) return
+ const sessionSignal = sessionAbortRef.current?.signal
+ setBusyBranches((current) => new Set(current).add(branchKey))
+ setError(null)
+ void command()
+ .catch((cause: unknown) => {
+ if (sessionSignal?.aborted) return
+ setError(errorMessage(cause, '工作流命令执行失败'))
+ })
+ .finally(() => {
+ if (sessionSignal?.aborted) return
+ setBusyBranches((current) => {
+ if (!current.has(branchKey)) return current
+ const next = new Set(current)
+ next.delete(branchKey)
+ return next
+ })
+ })
+ },
+ [busyBranches],
+ )
+
+ useEffect(() => {
+ const sessionAbort = new AbortController()
+ sessionAbortRef.current = sessionAbort
+ latestReadAbortRef.current?.abort()
+ settledGenerationsRef.current = new Map()
+ setSession(null)
+ setCharacter(null)
+ setRun(null)
+ setGenerations({})
+ setSelectedImages({})
+ setActionMenuOpen(false)
+ setActionMenuLevel('root')
+ setSelectedOutfitId(null)
+ setBusyBranches(new Set())
+ setError(null)
+ setResumeError(null)
+ setGenerationReadError(null)
+ setCanvasNodes([])
+ if (!runId) return
+
+ let loaded: WorkflowEditorSession | null = null
+ let unsubscribe: () => void = () => undefined
+ let unsubscribeErrors: () => void = () => undefined
+ const loader = loadSession ?? createDefaultRealWorkflowEditorSession
+ const signal = sessionAbort.signal
+
+ void loader(runId)
+ .then(async (nextSession) => {
+ if (signal.aborted) {
+ nextSession.dispose()
+ return
+ }
+ loaded = nextSession
+ setSession(nextSession)
+ setCharacter(nextSession.character)
+ unsubscribeErrors = nextSession.subscribeErrors((nextError) => {
+ if (signal.aborted) return
+ setError(errorMessage(nextError, '工作流异步处理失败'))
+ })
+ unsubscribe = nextSession.controller.subscribe((nextRun) => {
+ if (signal.aborted) return
+ setRun(nextRun)
+ requestGenerations(nextSession, nextRun, signal)
+ })
+ try {
+ await nextSession.controller.resume()
+ } catch (cause: unknown) {
+ if (signal.aborted) return
+ setResumeError(errorMessage(cause, '恢复 WorkflowRun 失败'))
+ }
+ })
+ .catch((cause: unknown) => {
+ if (signal.aborted) return
+ setError(errorMessage(cause, '恢复 WorkflowRun 失败'))
+ })
+
+ return () => {
+ sessionAbort.abort()
+ latestReadAbortRef.current?.abort()
+ unsubscribe()
+ unsubscribeErrors()
+ loaded?.dispose()
+ }
+ }, [loadSession, requestGenerations, runId])
+
+ const projected = useMemo(
+ () =>
+ run && session
+ ? projectCanvas({
+ run,
+ controller: session.controller,
+ publishReviewedAction: session.publishReviewedAction,
+ project: session.project,
+ character,
+ generations,
+ selectedImages,
+ actionMenuOpen,
+ actionMenuLevel,
+ selectedOutfitId,
+ busyBranches,
+ resumeBlocked: Boolean(resumeError),
+ setSelectedImages,
+ setActionMenuOpen,
+ setActionMenuLevel,
+ setSelectedOutfitId,
+ setCharacter,
+ runCommand,
+ })
+ : { nodes: [] as WorkflowCardNode[], edges: [] as Edge[] },
+ [
+ actionMenuOpen,
+ actionMenuLevel,
+ busyBranches,
+ character,
+ generations,
+ run,
+ runCommand,
+ resumeError,
+ selectedImages,
+ selectedOutfitId,
+ session,
+ ],
+ )
+
+ useEffect(() => {
+ setCanvasNodes((previous) =>
+ projected.nodes.map((node) => ({
+ ...node,
+ position: previous.find((candidate) => candidate.id === node.id)?.position ?? node.position,
+ })),
+ )
+ }, [projected.nodes])
+
+ useEffect(() => {
+ if (
+ resumeError &&
+ run &&
+ !run.nodes.some(
+ (node) => !node.deletedAt && node.status === 'active' && node.phase === 'generating',
+ )
+ ) {
+ setResumeError(null)
+ }
+ }, [resumeError, run])
+
+ function onNodesChange(changes: NodeChange[]) {
+ const safeChanges = changes.filter((change) => change.type !== 'remove')
+ setCanvasNodes((nodes) => applyNodeChanges(safeChanges, nodes))
+ }
+
+ if (!runId) {
+ return
+ }
+
+ if (error && !run) {
+ return
+ }
+
+ if (!session || !run) {
+ return
+ }
+
+ const constraints = [
+ CHARACTER_PERSPECTIVE[session.project.perspective],
+ DIRECTIONAL_MOVEMENT[session.project.directionalMovement],
+ `${session.project.spriteSize.width} × ${session.project.spriteSize.height}`,
+ session.project.gameStyle ?? '未设置画风',
+ ]
+ const visibleError = error ?? resumeError ?? generationReadError
+
return (
-
-
-
- WORKFLOW EDITOR
-
- {runId ? `工作流 ${runId.slice(0, 8)}` : '工作流画布'}
-
-
- 首帧确认后先选择资产生产路线,再进入完整动画生成。
+
+
+ {visibleError ? (
+
+ {visibleError}
+ {!error && !resumeError && generationReadError ? (
+
+ ) : null}
+
+ ) : null}
+
+
+ nodes={canvasNodes}
+ edges={projected.edges}
+ nodeTypes={nodeTypes}
+ onNodesChange={onNodesChange}
+ nodesDraggable
+ nodesConnectable={false}
+ edgesReconnectable={false}
+ elementsSelectable
+ deleteKeyCode={null}
+ fitView
+ fitViewOptions={{ padding: 0.14, maxZoom: 0.82 }}
+ minZoom={0.3}
+ maxZoom={1.2}
+ >
+ node.id)} />
+
+
-
+
)
}
-function GenerationMethodChoice({
- selected,
- onSelect,
+/**
+ * 只有画布上的节点增减时才重新取景,让新长出来的分支进入视野。
+ * 节点内容变化(生成结果到达、状态推进、版本号增加)一律不动视角——
+ * 用户拖过、缩放过的位置是他自己选的,命令执行不该把它拽回全景。
+ *
+ * 延迟一帧再取景:新节点要等 React Flow 量完尺寸,立刻调会按旧尺寸算错取景框。
+ */
+function FitViewOnNodeSetChange({ nodeIds }: { nodeIds: string[] }) {
+ const { fitView } = useReactFlow()
+ const signature = nodeIds.join(',')
+ useEffect(() => {
+ if (!signature) return
+ const timer = window.setTimeout(() => {
+ void fitView({ padding: 0.14, maxZoom: 0.82, duration: 180 })
+ }, 32)
+ return () => window.clearTimeout(timer)
+ }, [fitView, signature])
+ return null
+}
+
+interface ProjectionInput {
+ run: WorkflowRun
+ controller: WorkflowController
+ publishReviewedAction(reviewNodeId: ReviewWorkflowNode['id']): Promise
+ project: Project
+ character: Character | null
+ generations: Record
+ selectedImages: Record
+ actionMenuOpen: boolean
+ actionMenuLevel: ActionMenuLevel
+ selectedOutfitId: string | null
+ busyBranches: ReadonlySet
+ resumeBlocked: boolean
+ setSelectedImages: React.Dispatch>>
+ setActionMenuOpen(open: boolean): void
+ setActionMenuLevel(level: ActionMenuLevel): void
+ setSelectedOutfitId(outfitId: string | null): void
+ setCharacter(character: Character): void
+ runCommand(branchKey: string, command: () => Promise): void
+}
+
+/** 卡片自己所属的分支;命令与禁用判断都以它为准。 */
+function branchKeyOf(node: WorkflowNode, input: ProjectionInput): string {
+ return branchKeyFor(node, new Map(input.run.nodes.map((candidate) => [candidate.id, candidate])))
+}
+
+function projectCanvas(input: ProjectionInput): {
+ nodes: WorkflowCardNode[]
+ edges: Edge[]
+} {
+ const activeNodes = input.run.nodes.filter((node) => !node.deletedAt)
+ const actionRootIds = activeNodes
+ .filter((node) => node.type === 'action-first-frame')
+ .map((node) => node.id)
+ const nodesById = new Map(activeNodes.map((node) => [node.id, node]))
+ const nodes = activeNodes.map((node) =>
+ toCanvasNode(node, branchIndexFor(branchKeyFor(node, nodesById), actionRootIds), input),
+ )
+ const edges: Edge[] = activeNodes.flatMap((node) => {
+ const confirmed = node.status === 'passed'
+ return node.dependsOnNodeIds.map((source) => ({
+ id: `${source}->${node.id}`,
+ source,
+ target: node.id,
+ selectable: false,
+ deletable: false,
+ className: confirmed ? 'workflow-edge--confirmed' : 'workflow-edge--flowing',
+ }))
+ })
+
+ return { nodes, edges }
+}
+
+function toCanvasNode(
+ node: WorkflowNode,
+ branchIndex: number,
+ input: ProjectionInput,
+): WorkflowCardNode {
+ return {
+ id: node.id,
+ type: 'workflow-card',
+ position: positionFor(node.type, branchIndex),
+ zIndex: node.type === 'character-template' && input.actionMenuOpen ? 1000 : 0,
+ draggable: true,
+ dragHandle: '.workflow-card__handle',
+ deletable: false,
+ data: {
+ eyebrow: CARD_LABELS[node.type].eyebrow,
+ title: CARD_LABELS[node.type].title,
+ status: node.status,
+ content: contentFor(node, input),
+ },
+ }
+}
+
+function contentFor(node: WorkflowNode, input: ProjectionInput): ReactNode {
+ if (node.type === 'character-setup') return
+ if (node.type === 'character-template') {
+ return
+ }
+ if (node.type === 'action-first-frame') return
+ if (node.type === 'action-generation-method') return
+ if (node.type === 'action-full-frame') return
+ return
+}
+
+function CharacterSetupContent({
+ node,
+ input,
}: {
- selected: ActionGenerationMethod | null
- onSelect?: (method: ActionGenerationMethod) => void
+ node: CharacterSetupWorkflowNode
+ input: ProjectionInput
}) {
+ const branchKey = branchKeyOf(node, input)
+ const branchBusy = input.busyBranches.has(branchKey)
+ const [prompt, setPrompt] = useState(node.input.prompt)
+ useEffect(() => setPrompt(node.input.prompt), [node.id, node.input.prompt])
+
+ if (node.status === 'failed') return
+ if (node.status === 'passed') return 角色描述已确认
return (
-
+
+
+
+ )
+}
+
+function CharacterTemplateContent({
+ node,
+ input,
+}: {
+ node: CharacterTemplateWorkflowNode
+ input: ProjectionInput
+}) {
+ const branchKey = branchKeyOf(node, input)
+ const branchBusy = input.busyBranches.has(branchKey)
+ if (node.status === 'failed') return
+ if (node.phase === 'ready' && node.status === 'active') {
+ const setupNode = findDependency(input.run, node, 'character-setup')
+ return (
+ )
+ }
+ if (node.phase === 'selecting') {
+ const result = input.generations[generationKey(node.id, 'character_template')]?.result
+ const images = result?.type === 'character_template' ? result.images : []
+ const selectedImageUrl =
+ images.find((image) => image.url === input.selectedImages[node.id])?.url ?? null
+ return (
+
+
+ {images.map((image, index) => (
+
+ ))}
+
+
+
+ )
+ }
+ if (node.status === 'passed' && node.selectedImageUrl) {
+ return (
+
+

+
身份已锁定
+
+ {input.actionMenuOpen ? (
+
+ ) : null}
+
+ )
+ }
+ return
+}
+
+function ActionMenu({ input, templateNodeId }: { input: ProjectionInput; templateNodeId: string }) {
+ const outfits = input.character?.outfits ?? []
+ const selectedOutfit = outfits.find((outfit) => outfit.id === input.selectedOutfitId) ?? null
+ // 菜单挂在身份母版上,新增分支属于共享区的操作。
+ const branchBusy = input.busyBranches.has(SHARED_BRANCH)
+
+ if (input.actionMenuLevel === 'root') {
+ return (
+
+
+
+
+
+ )
+ }
+
+ if (input.actionMenuLevel === 'outfits') {
+ return (
+
+
+ {outfits.map((outfit) => (
+
+ ))}
+
+ )
+ }
+
+ return (
+
+
+ {ACTION_PRESETS.map((preset) => (
+
+ ))}
+
+
+ )
+}
+
+function FirstFrameContent({
+ node,
+ input,
+}: {
+ node: ActionFirstFrameWorkflowNode
+ input: ProjectionInput
+}) {
+ const branchKey = branchKeyOf(node, input)
+ const branchBusy = input.busyBranches.has(branchKey)
+ const result = input.generations[generationKey(node.id, 'first_frame')]?.result
+ const image = result?.type === 'first_frame' ? result.image : null
+ if (node.status === 'failed') return
+ if (node.phase === 'configuring') {
+ const character = characterOwningOutfit(input.character, node.input.outfitId)
+ return (
+
+
+ {node.input.name} · {node.input.fps} FPS
+
+
{node.input.prompt ?? '无额外动作描述'}
+
+
+ )
+ }
+ if (node.phase === 'selecting' && image) {
+ const selectedImageUrl = input.selectedImages[node.id] === image.url ? image.url : null
+ return (
+
+
+
+
+ )
+ }
+ if (node.phase === 'completed' && node.selectedFirstFrameUrl) {
+ return

+ }
+ return
+}
+
+function MethodContent({
+ node,
+ input,
+}: {
+ node: ActionGenerationMethodWorkflowNode
+ input: ProjectionInput
+}) {
+ const branchKey = branchKeyOf(node, input)
+ const branchBusy = input.busyBranches.has(branchKey)
+ if (node.status === 'failed') return
+ if (node.phase === 'completed') return
视频裁剪
+ if (node.status !== 'active') return
+ return (
+
+
+
+
+ )
+}
+
+function AnimationContent({
+ node,
+ input,
+}: {
+ node: ActionFullFrameWorkflowNode
+ input: ProjectionInput
+}) {
+ const branchKey = branchKeyOf(node, input)
+ const branchBusy = input.busyBranches.has(branchKey)
+ const result = input.generations[generationKey(node.id, 'complete_animation')]?.result
+ const frames = result?.type === 'complete_animation' ? result.frames : []
+ if (node.status === 'failed') return
+ if (node.phase === 'ready' && node.status === 'active') {
+ // 依赖链是 首帧 → 生产方式 → 完整动画,所以要往上翻两层才拿得到首帧的造型。
+ const methodNode = findDependency(input.run, node, 'action-generation-method')
+ const firstFrameNode = methodNode
+ ? findDependency(input.run, methodNode, 'action-first-frame')
+ : null
+ const character = firstFrameNode
+ ? characterOwningOutfit(input.character, firstFrameNode.input.outfitId)
+ : null
+ return (
+
+ )
+ }
+ if (node.phase === 'completed' && frames.length) {
+ return (
+
+ {frames.map((frame, index) => (
+

+ ))}
+
+ )
+ }
+ return
+}
+
+function ReviewContent({ node, input }: { node: ReviewWorkflowNode; input: ProjectionInput }) {
+ const branchKey = branchKeyOf(node, input)
+ const branchBusy = input.busyBranches.has(branchKey)
+ if (node.status === 'failed') return
+ if (node.phase === 'completed') return
审核已通过
+ if (node.status !== 'active') return
+ return (
+
+
确认完整动画后完成本动作审核。
+
)
}
+
+function WorkflowCard({ data }: NodeProps
) {
+ return (
+
+
+
+
+ {data.eyebrow}
+
+ {data.title}
+
+ {data.content}
+
+
+ )
+}
+
+function StatusText({ node, input }: { node: WorkflowNode; input: ProjectionInput }) {
+ const branchKey = branchKeyOf(node, input)
+ const branchBusy = input.busyBranches.has(branchKey)
+ const resumeBlocked =
+ input.resumeBlocked && node.status === 'active' && node.phase === 'generating'
+ if (node.status === 'failed' || resumeBlocked) {
+ return (
+
+
+ {node.status === 'failed' ? (node.error ?? '生成失败') : '生成任务恢复失败'}
+
+
+
+ )
+ }
+ const label =
+ node.status === 'locked' ? '等待上游节点' : node.phase === 'generating' ? '生成中…' : '处理中…'
+ return {label}
+}
+
+function EditorBoundary({ message }: { message: string }) {
+ return (
+
+
+ MANUAL WORKFLOW
+
+
工作流编辑器
+
{message}
+
+ )
+}
+
+/**
+ * 一个节点可以同时挂多个角色的生成任务,所以字典的键必须带上角色,
+ * 只用节点 ID 会让后读到的那条静默覆盖前一条。已删节点不再读取。
+ */
+function generationKey(nodeId: WorkflowNode['id'], role: WorkflowGenerationRole) {
+ return `${nodeId}:${role}`
+}
+
+/**
+ * 读取节点的生成结果。WorkflowRun 每推进一步都会 emit,而已经到终态的任务不会再变,
+ * 所以按 taskId 记住它们;只有还在跑的任务才值得重新问后端。
+ */
+async function readGenerations(
+ controller: WorkflowController,
+ run: WorkflowRun,
+ settled: Map,
+): Promise> {
+ const entries = await Promise.all(
+ run.nodes
+ .filter((node) => !node.deletedAt)
+ .flatMap((node) =>
+ node.generations.map(async (reference) => {
+ const key = generationKey(node.id, reference.role)
+ const cached = settled.get(reference.taskId)
+ if (cached) return [key, cached] as const
+ const generation = await controller.getGeneration(node.id, reference.role)
+ if (generation && (generation.status === 'completed' || generation.status === 'failed')) {
+ settled.set(generation.id, generation)
+ }
+ return [key, generation] as const
+ }),
+ ),
+ )
+ return Object.fromEntries(entries)
+}
+
+/**
+ * 节点属于哪条动作分支:顺着依赖往上爬到本分支的首帧节点,用它的 ID 当分支标识。
+ * 角色设定与身份母版为所有分支共用,爬不到首帧,归到 SHARED_BRANCH。
+ */
+function branchKeyFor(node: WorkflowNode, nodesById: ReadonlyMap): string {
+ let current: WorkflowNode | undefined = node
+ const visited = new Set()
+ while (current && !visited.has(current.id)) {
+ visited.add(current.id)
+ if (current.type === 'action-first-frame') return current.id
+ current = current.dependsOnNodeIds
+ .map((dependencyId) => nodesById.get(dependencyId))
+ .find((dependency) => dependency?.type !== 'character-template')
+ }
+ return SHARED_BRANCH
+}
+
+/** 分支在画布上排第几行。共享节点与找不到根的节点都落在第 0 行。 */
+function branchIndexFor(branchKey: string, actionRootIds: string[]): number {
+ return Math.max(0, actionRootIds.indexOf(branchKey))
+}
+
+function positionFor(type: WorkflowNode['type'], branchIndex: number) {
+ const x: Record = {
+ 'character-setup': 70,
+ 'character-template': 510,
+ 'action-first-frame': 950,
+ 'action-generation-method': 1390,
+ 'action-full-frame': 1820,
+ review: 2250,
+ }
+ const isActionBranch = type.startsWith('action') || type === 'review'
+ return {
+ x: x[type],
+ y: isActionBranch ? 60 + branchIndex * 510 : 280,
+ }
+}
+
+/** 卡片抬头文案。序号是流程顺序,与 positionFor 的横向排布一致。 */
+const CARD_LABELS: Record = {
+ 'character-setup': { eyebrow: '01 · ORIGIN', title: '角色设定' },
+ 'character-template': { eyebrow: '02 · MASTER', title: '身份母版' },
+ 'action-first-frame': { eyebrow: '03 · FIRST FRAME', title: '动作首帧' },
+ 'action-generation-method': { eyebrow: '04 · METHOD', title: '生产方式' },
+ 'action-full-frame': { eyebrow: '05 · ANIMATION', title: '完整动画' },
+ review: { eyebrow: '06 · REVIEW', title: '动画审核' },
+}
+
+/**
+ * 造型属于当前角色时返回该角色,否则返回 null。
+ * 一条 WorkflowRun 只绑定一个角色,所以这里不是查找,是归属校验。
+ */
+function characterOwningOutfit(character: Character | null, outfitId: string) {
+ return character?.outfits.some((outfit) => outfit.id === outfitId) ? character : null
+}
+
+function findDependency(
+ run: WorkflowRun,
+ node: WorkflowNode,
+ type: T,
+): Extract | null {
+ const dependencies = run.nodes.filter((candidate) => node.dependsOnNodeIds.includes(candidate.id))
+ const match = dependencies.find((candidate) => candidate.type === type)
+ return (match as Extract | undefined) ?? null
+}
+
+function errorMessage(cause: unknown, fallback: string) {
+ return cause instanceof Error && cause.message ? cause.message : fallback
+}
diff --git a/frontend/src/pages/workflow-editor/runtime.test.ts b/frontend/src/pages/workflow-editor/runtime.test.ts
new file mode 100644
index 00000000..1a1187b0
--- /dev/null
+++ b/frontend/src/pages/workflow-editor/runtime.test.ts
@@ -0,0 +1,383 @@
+import { describe, expect, it, vi } from 'vitest'
+
+import type {
+ Character,
+ Generation,
+ GenerationApis,
+ Project,
+ WorkflowRun,
+ WorkflowRunApis,
+} from '@/entities'
+import { createRealWorkflowEditorSession, createUnavailableGenerationApis } from './runtime'
+
+describe('createRealWorkflowEditorSession', () => {
+ it('只用主仓库公开接口恢复 WorkflowRun 并装配 Controller', async () => {
+ const workflow = workflowFixture()
+ const project = projectFixture()
+ const character = characterFixture()
+ const workflowRunApis: WorkflowRunApis = {
+ create: vi.fn(),
+ get: vi.fn().mockResolvedValue(workflow),
+ update: vi.fn(async (run) => ({ ...structuredClone(run), version: run.version + 1 })),
+ remove: vi.fn(),
+ }
+ const generationApis: GenerationApis = {
+ create: vi.fn() as GenerationApis['create'],
+ get: vi.fn(),
+ subscribe: vi.fn(() => () => undefined),
+ }
+ const projectApis = { get: vi.fn().mockResolvedValue(project) }
+ const unrelatedCharacter = { ...characterFixture(), id: '10', workflowRunId: '99' }
+ const characterApis = {
+ listByProject: vi
+ .fn()
+ .mockResolvedValueOnce({
+ items: [unrelatedCharacter],
+ total: 101,
+ page: 1,
+ pageSize: 100,
+ })
+ .mockResolvedValueOnce({
+ items: [character],
+ total: 101,
+ page: 2,
+ pageSize: 100,
+ }),
+ update: vi.fn(),
+ }
+
+ const session = await createRealWorkflowEditorSession('42', {
+ workflowRunApis,
+ generationApis,
+ projectApis,
+ characterApis,
+ onAsyncError: vi.fn(),
+ })
+
+ expect(workflowRunApis.get).toHaveBeenCalledWith('42')
+ expect(projectApis.get).toHaveBeenCalledWith('1')
+ expect(characterApis.listByProject).toHaveBeenNthCalledWith(1, '1', {
+ page: 1,
+ pageSize: 100,
+ })
+ expect(characterApis.listByProject).toHaveBeenNthCalledWith(2, '1', {
+ page: 2,
+ pageSize: 100,
+ })
+ expect(session.controller.getWorkflow()).toEqual(workflow)
+ expect(session.project).toEqual(project)
+ expect(session.character).toEqual(character)
+ expect('mode' in session).toBe(false)
+ expect('playtestTarget' in session).toBe(false)
+ })
+
+ it('拒绝把同一 WorkflowRun 绑定到多个角色', async () => {
+ const workflow = workflowFixture()
+ const characterApis = {
+ listByProject: vi.fn().mockResolvedValue({
+ items: [characterFixture(), { ...characterFixture(), id: '10' }],
+ total: 2,
+ page: 1,
+ pageSize: 100,
+ }),
+ update: vi.fn(),
+ }
+
+ await expect(
+ createRealWorkflowEditorSession('42', {
+ workflowRunApis: {
+ create: vi.fn(),
+ get: vi.fn().mockResolvedValue(workflow),
+ update: vi.fn(),
+ remove: vi.fn(),
+ },
+ generationApis: {
+ create: vi.fn() as GenerationApis['create'],
+ get: vi.fn(),
+ subscribe: vi.fn(() => () => undefined),
+ },
+ projectApis: { get: vi.fn().mockResolvedValue(projectFixture()) },
+ characterApis,
+ onAsyncError: vi.fn(),
+ }),
+ ).rejects.toThrow('WorkflowRun 42 关联了多个角色')
+ })
+
+ it('把 Controller 异步错误同时交给装配层和页面订阅者', async () => {
+ const onAsyncError = vi.fn()
+ const workflow = workflowFixture()
+ const session = await createRealWorkflowEditorSession('42', {
+ workflowRunApis: {
+ create: vi.fn(),
+ get: vi.fn().mockResolvedValue(workflow),
+ update: vi.fn(async (run) => ({ ...structuredClone(run), version: run.version + 1 })),
+ remove: vi.fn(),
+ },
+ generationApis: {
+ create: vi.fn() as GenerationApis['create'],
+ get: vi.fn(),
+ subscribe: vi.fn(() => () => undefined),
+ },
+ projectApis: { get: vi.fn().mockResolvedValue(projectFixture()) },
+ characterApis: {
+ listByProject: vi.fn().mockResolvedValue({
+ items: [],
+ total: 0,
+ page: 1,
+ pageSize: 100,
+ }),
+ update: vi.fn(),
+ },
+ onAsyncError,
+ })
+ const pageError = vi.fn()
+ session.subscribeErrors(pageError)
+ let notificationCount = 0
+ session.controller.subscribe(() => {
+ notificationCount += 1
+ if (notificationCount > 1) throw new Error('异步保存回调失败')
+ })
+
+ await session.controller.restartFromNode('setup')
+
+ expect(onAsyncError).toHaveBeenCalledWith(
+ expect.objectContaining({ message: '异步保存回调失败' }),
+ )
+ expect(pageError).toHaveBeenCalledWith(expect.objectContaining({ message: '异步保存回调失败' }))
+ })
+
+ it('发布 Character 动作资产后由调用方单独推进审核节点', async () => {
+ const events: string[] = []
+ const workflow = reviewingWorkflowFixture()
+ const session = await createRealWorkflowEditorSession('42', {
+ workflowRunApis: {
+ create: vi.fn(),
+ get: vi.fn().mockResolvedValue(workflow),
+ update: vi.fn(async (run) => {
+ events.push('approve')
+ return { ...structuredClone(run), version: run.version + 1 }
+ }),
+ remove: vi.fn(),
+ },
+ generationApis: {
+ create: vi.fn() as GenerationApis['create'],
+ get: vi.fn().mockResolvedValue(completeAnimationFixture()),
+ subscribe: vi.fn(() => () => undefined),
+ },
+ projectApis: { get: vi.fn().mockResolvedValue(projectFixture()) },
+ characterApis: {
+ listByProject: vi.fn().mockResolvedValue({
+ items: [characterWithOutfitFixture()],
+ total: 1,
+ page: 1,
+ pageSize: 100,
+ }),
+ update: vi.fn(async (character) => {
+ events.push('publish')
+ return structuredClone(character)
+ }),
+ },
+ onAsyncError: vi.fn(),
+ })
+
+ const published = await session.publishReviewedAction('action-walk:review')
+
+ expect(events).toEqual(['publish'])
+ expect(published.outfits[0]?.actions).toEqual([
+ expect.objectContaining({ id: 'action-walk', frameCount: 2 }),
+ ])
+ expect(
+ session.controller.getWorkflow().nodes.find((node) => node.id === 'action-walk:review'),
+ ).toMatchObject({ status: 'active', phase: 'reviewing' })
+
+ await session.controller.approveReview('action-walk:review')
+
+ expect(events).toEqual(['publish', 'approve'])
+ expect(
+ session.controller.getWorkflow().nodes.find((node) => node.id === 'action-walk:review'),
+ ).toMatchObject({ status: 'passed', phase: 'completed' })
+ })
+})
+
+describe('createUnavailableGenerationApis', () => {
+ it('在 main 尚无 Generation HTTP 适配器时明确失败,不返回演示结果', async () => {
+ const apis = createUnavailableGenerationApis()
+
+ await expect(apis.create(characterGenerationInput())).rejects.toThrow(
+ 'GenerationApis 尚未接入真实后端',
+ )
+ await expect(apis.get('1', '9')).rejects.toThrow('GenerationApis 尚未接入真实后端')
+ expect(() => apis.subscribe('1', '9', vi.fn())).not.toThrow()
+ })
+})
+
+function workflowFixture(): WorkflowRun {
+ return {
+ id: '42',
+ projectId: '1',
+ version: 3,
+ storageStatus: 'active',
+ nodes: [
+ {
+ id: 'setup',
+ type: 'character-setup',
+ status: 'active',
+ phase: 'configuring',
+ dependsOnNodeIds: [],
+ generations: [],
+ error: null,
+ input: { prompt: '冒险家', referenceMedia: [] },
+ },
+ ],
+ }
+}
+
+function projectFixture(): Project {
+ return {
+ id: '1',
+ ownerId: '7',
+ workflowId: null,
+ name: '正式项目',
+ perspective: 'side',
+ directionalMovement: 'single',
+ spriteSize: { width: 64, height: 64 },
+ gameStyle: null,
+ sampleImageUrl: null,
+ createdAt: '2026-08-10T00:00:00.000Z',
+ updatedAt: '2026-08-10T00:00:00.000Z',
+ }
+}
+
+function characterFixture(): Character {
+ return {
+ id: '9',
+ projectId: '1',
+ workflowRunId: '42',
+ name: '正式角色',
+ description: null,
+ referenceImageUrl: null,
+ dataVersion: 1,
+ status: 1,
+ outfits: [],
+ }
+}
+
+function characterWithOutfitFixture(): Character {
+ return {
+ ...characterFixture(),
+ outfits: [
+ {
+ id: 'outfit-default',
+ characterId: '9',
+ name: '常态造型',
+ description: null,
+ previewUrl: null,
+ actions: [],
+ },
+ ],
+ }
+}
+
+function reviewingWorkflowFixture(): WorkflowRun {
+ return {
+ id: '42',
+ projectId: '1',
+ version: 7,
+ storageStatus: 'active',
+ nodes: [
+ {
+ id: 'setup',
+ type: 'character-setup',
+ status: 'passed',
+ phase: 'completed',
+ dependsOnNodeIds: [],
+ generations: [],
+ error: null,
+ input: { prompt: '冒险家', referenceMedia: [] },
+ },
+ {
+ id: 'template',
+ type: 'character-template',
+ status: 'passed',
+ phase: 'completed',
+ dependsOnNodeIds: ['setup'],
+ generations: [],
+ error: null,
+ selectedImageUrl: 'https://assets.windup.test/master.png',
+ },
+ {
+ id: 'action-walk',
+ type: 'action-first-frame',
+ status: 'passed',
+ phase: 'completed',
+ dependsOnNodeIds: ['template'],
+ generations: [],
+ error: null,
+ input: {
+ outfitId: 'outfit-default',
+ name: '行走',
+ type: 'walk',
+ prompt: null,
+ fps: 12,
+ },
+ selectedFirstFrameUrl: 'https://assets.windup.test/walk-01.png',
+ },
+ {
+ id: 'action-walk:method',
+ type: 'action-generation-method',
+ status: 'passed',
+ phase: 'completed',
+ dependsOnNodeIds: ['action-walk'],
+ generations: [],
+ error: null,
+ method: 'video-cropping',
+ },
+ {
+ id: 'action-walk:full-frame',
+ type: 'action-full-frame',
+ status: 'passed',
+ phase: 'completed',
+ dependsOnNodeIds: ['action-walk:method'],
+ generations: [{ taskId: 'generation-walk', role: 'complete_animation' }],
+ error: null,
+ },
+ {
+ id: 'action-walk:review',
+ type: 'review',
+ status: 'active',
+ phase: 'reviewing',
+ dependsOnNodeIds: ['action-walk:full-frame'],
+ generations: [],
+ error: null,
+ },
+ ],
+ }
+}
+
+function completeAnimationFixture(): Generation<'complete_animation'> {
+ return {
+ id: 'generation-walk',
+ projectId: '1',
+ type: 'complete_animation',
+ status: 'completed',
+ error: null,
+ result: {
+ type: 'complete_animation',
+ frames: [
+ { url: 'https://assets.windup.test/walk-01.png' },
+ { url: 'https://assets.windup.test/walk-02.png' },
+ ],
+ },
+ }
+}
+
+function characterGenerationInput() {
+ return {
+ type: 'character_template' as const,
+ projectId: '1',
+ prompt: '冒险家',
+ spriteWidth: 64,
+ spriteHeight: 64,
+ referenceMedia: [],
+ }
+}
diff --git a/frontend/src/pages/workflow-editor/runtime.ts b/frontend/src/pages/workflow-editor/runtime.ts
new file mode 100644
index 00000000..d709bc62
--- /dev/null
+++ b/frontend/src/pages/workflow-editor/runtime.ts
@@ -0,0 +1,151 @@
+import type {
+ Character,
+ CharacterApis,
+ GenerationApis,
+ Project,
+ ProjectApis,
+ ReviewWorkflowNode,
+ WorkflowRunApis,
+} from '@/entities'
+import { characterApis, projectApis, workflowRunApis } from '@/entities'
+import { createCharacterAssetPublisher } from '@/features/export'
+import { createWorkflowController, type WorkflowController } from '@/features/workflow-controller'
+
+export interface WorkflowEditorSession {
+ controller: WorkflowController
+ project: Project
+ /** 后端用 workflow_run_id 建立的唯一角色;尚未产出正式角色时为 null。 */
+ character: Character | null
+ /** 幂等发布动作资产;审核节点仍由页面随后通过 Controller 推进。 */
+ publishReviewedAction(reviewNodeId: ReviewWorkflowNode['id']): Promise
+ subscribeErrors(listener: (error: Error) => void): () => void
+ dispose(): void
+}
+
+export interface RealWorkflowEditorDependencies {
+ workflowRunApis: WorkflowRunApis
+ generationApis: GenerationApis
+ projectApis: Pick
+ characterApis: Pick
+ onAsyncError(error: Error): void
+}
+
+/**
+ * 页面只消费这一份正式会话:WorkflowRun 决定流程状态,Project / Character 只提供
+ * 只读上下文,所有业务推进都交给同一个 WorkflowController。
+ */
+export async function createRealWorkflowEditorSession(
+ runId: string,
+ dependencies: RealWorkflowEditorDependencies,
+): Promise {
+ const workflow = await dependencies.workflowRunApis.get(runId)
+ const [project, loadedCharacter] = await Promise.all([
+ dependencies.projectApis.get(workflow.projectId),
+ loadWorkflowCharacter(dependencies.characterApis, workflow.projectId, workflow.id),
+ ])
+ let currentCharacter = loadedCharacter
+ const errorListeners = new Set<(error: Error) => void>()
+ const reportAsyncError = (error: Error) => {
+ try {
+ dependencies.onAsyncError(error)
+ } catch {
+ // 错误上报器不能反过来破坏已经完成的 WorkflowRun 持久化。
+ }
+ for (const listener of errorListeners) {
+ try {
+ listener(error)
+ } catch {
+ // 页面卸载竞态或错误边界异常不应中断其他订阅者。
+ }
+ }
+ }
+ const controller = createWorkflowController({
+ workflow,
+ workflowRunApis: dependencies.workflowRunApis,
+ generationApis: dependencies.generationApis,
+ onAsyncError: reportAsyncError,
+ })
+ const publisher = createCharacterAssetPublisher(dependencies.characterApis)
+
+ return {
+ controller,
+ project,
+ character: loadedCharacter,
+ async publishReviewedAction(reviewNodeId) {
+ if (!currentCharacter) throw new Error('当前 WorkflowRun 尚未关联 Character')
+ const currentWorkflow = controller.getWorkflow()
+ const reviewNode = currentWorkflow.nodes.find((node) => node.id === reviewNodeId)
+ if (!reviewNode || reviewNode.type !== 'review') throw new Error('目标节点不是动作审核')
+ if (reviewNode.dependsOnNodeIds.length !== 1) {
+ throw new Error(`${reviewNode.id} 必须且只能依赖一个完整动画节点`)
+ }
+ const fullFrameNodeId = reviewNode.dependsOnNodeIds[0]!
+ const generation = await controller.getGeneration(fullFrameNodeId, 'complete_animation')
+ if (!generation) throw new Error('完整动画生成结果不存在')
+
+ currentCharacter = await publisher.publishReviewedAction({
+ character: currentCharacter,
+ workflow: currentWorkflow,
+ reviewNodeId,
+ generation,
+ })
+ return currentCharacter
+ },
+ subscribeErrors(listener) {
+ errorListeners.add(listener)
+ return () => errorListeners.delete(listener)
+ },
+ dispose() {
+ errorListeners.clear()
+ controller.dispose()
+ },
+ }
+}
+
+/**
+ * main 已提供真实 WorkflowRun、Project 与 Character 适配器;Generation 只有公开接口,
+ * 尚无可合入的 HTTP 实现。这里明确拒绝生成,避免用演示数据污染正式 WorkflowRun。
+ */
+export function createDefaultRealWorkflowEditorSession(
+ runId: string,
+): Promise {
+ return createRealWorkflowEditorSession(runId, {
+ workflowRunApis,
+ generationApis: createUnavailableGenerationApis(),
+ projectApis,
+ characterApis,
+ onAsyncError: () => undefined,
+ })
+}
+
+export function createUnavailableGenerationApis(): GenerationApis {
+ const unavailable = () =>
+ Promise.reject(new Error('GenerationApis 尚未接入真实后端,不能执行或恢复生成任务'))
+
+ return {
+ create: unavailable as GenerationApis['create'],
+ get: unavailable,
+ subscribe: () => () => undefined,
+ }
+}
+
+async function loadWorkflowCharacter(
+ apis: Pick,
+ projectId: Project['id'],
+ workflowRunId: string,
+): Promise {
+ const pageSize = 100
+ const matches: Character[] = []
+
+ for (let page = 1; ; page += 1) {
+ const result = await apis.listByProject(projectId, { page, pageSize })
+ matches.push(...result.items.filter((character) => character.workflowRunId === workflowRunId))
+ if (matches.length > 1) {
+ throw new Error(`WorkflowRun ${workflowRunId} 关联了多个角色,无法进入单角色画布`)
+ }
+ const totalPages = Math.ceil(result.total / result.pageSize)
+ if (page >= totalPages || result.items.length === 0) break
+ }
+
+ return matches[0] ?? null
+}
diff --git a/frontend/src/pages/workflow-editor/workflow-editor.css b/frontend/src/pages/workflow-editor/workflow-editor.css
new file mode 100644
index 00000000..c20b069f
--- /dev/null
+++ b/frontend/src/pages/workflow-editor/workflow-editor.css
@@ -0,0 +1,76 @@
+/*
+ 这里只留 Tailwind 工具类表达不了的三类东西,页面自身的样式一律写在标签上:
+ 1. 画布配色变量——连线、控件、卡片共用,改一处全体生效;
+ 2. React Flow 自己生成的 DOM(.react-flow__*)挂不上我们的 className,只能用选择器够;
+ 3. 关键帧动画。
+*/
+
+.workflow-editor-shell {
+ --editor-ink: #1d1d1f;
+ --editor-muted: #737378;
+ --editor-paper: #dfe3df;
+ --editor-line: rgb(27 38 30 / 20%);
+ --editor-shadow: 0 14px 36px rgb(30 39 32 / 13%), 0 2px 5px rgb(30 39 32 / 8%);
+}
+
+/* 画布底纹:radial-gradient 平铺,工具类写不出来。 */
+.workflow-editor-canvas {
+ background-color: var(--editor-paper);
+ background-image: radial-gradient(circle, rgb(46 62 50 / 18%) 1px, transparent 1.25px);
+ background-size: 21px 21px;
+}
+
+/* ── 以下都是 React Flow 内部节点,只能靠选择器覆盖 ───────────────────── */
+
+.workflow-editor-canvas .react-flow__controls {
+ overflow: hidden;
+ border: 1px solid rgb(36 49 39 / 12%);
+ border-radius: 10px;
+ background: rgb(249 250 247 / 90%);
+ box-shadow: 0 8px 24px rgb(31 40 33 / 9%);
+ backdrop-filter: blur(14px);
+}
+
+.workflow-editor-canvas .react-flow__controls-button {
+ border-color: var(--editor-line);
+ color: var(--editor-ink);
+ background: rgb(255 255 255 / 76%);
+}
+
+.workflow-editor-canvas .react-flow__handle {
+ top: 38px;
+ width: 22px;
+ height: 22px;
+ border: 4px solid #f5f7f4;
+ background: #68836e;
+ box-shadow: 0 0 0 1px #435849;
+}
+
+/*
+ 连线两态:上游已确认画实线,未确认画流动虚线。
+ 颜色写在这里而不是节点数据里,改配色只动这一处。
+*/
+.workflow-editor-canvas .workflow-edge--confirmed path {
+ stroke: #69866f;
+ stroke-width: 3;
+}
+
+.workflow-editor-canvas .workflow-edge--flowing path {
+ stroke: rgb(72 101 80 / 42%);
+ stroke-width: 3;
+ stroke-dasharray: 9 8;
+ animation: workflow-edge-flow 900ms linear infinite;
+ will-change: stroke-dashoffset;
+}
+
+@keyframes workflow-edge-flow {
+ to {
+ stroke-dashoffset: -17;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .workflow-editor-canvas .workflow-edge--flowing path {
+ animation: none;
+ }
+}
diff --git a/frontend/src/test/project-assets-backend.ts b/frontend/src/test/project-assets-backend.ts
index f6cdf526..0d65afad 100644
--- a/frontend/src/test/project-assets-backend.ts
+++ b/frontend/src/test/project-assets-backend.ts
@@ -49,6 +49,7 @@ const characterDtos = [
{
id: 51,
project_id: 42,
+ workflow_run_id: 501,
name: '轻装信使',
description: '负责远途投递的年轻信使',
reference_image_url: 'https://cdn.windup.test/messenger-reference.png',
@@ -115,6 +116,7 @@ const characterDtos = [
{
id: 52,
project_id: 42,
+ workflow_run_id: 502,
name: '待定角色',
description: null,
reference_image_url: null,