-
- 状态 {run.status} · 当前步骤 {run.currentStepId ?? '(无)'} · 可执行{' '}
- {availableCommands(run).join('、') || '(无)'}
-
- {activeFocus ? (
-
{describeFocus(activeFocus)}
- ) : null}
-
画布待实现,下面按节点切换显示各 Feature。
-
- {characterId !== null ? (
- <>
-
-
+
+
+
+ {WORKFLOW_NODE_ORDER.map((type, index) => {
+ const node = getNodeByType(revision, type)
+ const selected = type === activeType
+ return (
+ -
+
+
+ )
+ })}
+
+
+
+
+
+ {readOnly ? '历史只读' : '当前版本'} · {revision.id} · 生成 {revision.generationStatus}
+
+ {canRestart ? (
- >
- ) : null}
+ ) : null}
+
+
+ {activeNode ? (
+
+ ) : (
+
该节点不在当前执行线上。
+ )}
+
+
+ 版本历史
+
+ {[...run.revisions].reverse().map((item) => (
+ -
+
+
+ ))}
+
+
)
}
-/** 把定位压成可比较的字符串,用于判断 URL 里的定位是否真的变了。 */
-function serializeFocus(focus: WorkflowEditorFocus | undefined): string {
- if (!focus) return ''
- if (focus.kind === 'step') return `step:${focus.stepId}`
- return `frame:${focus.stepId}:${focus.actionId}:${focus.frameIndex}`
+function StageContent({
+ run,
+ revision,
+ type,
+ readOnly,
+ onOpenPreview,
+}: {
+ run: WorkflowRun
+ revision: WorkflowRevision
+ type: WorkflowNodeType
+ readOnly: boolean
+ onOpenPreview: (characterId: string) => void
+}) {
+ if (type === 'asset') {
+ return readOnly ? (
+
+ ) : (
+
+ )
+ }
+ if (type === 'generation') return
+ if (type === 'candidate') {
+ return (
+
+ )
+ }
+ if (type === 'review') {
+ return run.characterId ? (
+
+ ) : (
+
+ )
+ }
+
+ return (
+
+ {run.characterId ?
:
}
+ {run.characterId && canImportToPlaytest(run, revision.id) ? (
+
+ ) : null}
+ {revision.playtestStatus === 'issues_found' ? (
+
核验台发现问题,建议重新生成;当前结果仍允许导出。
+ ) : null}
+
+ )
}
-function describeFocus(focus: WorkflowEditorFocus): string {
- if (focus.kind === 'step') return `定位步骤 ${focus.stepId}`
- return `定位步骤 ${focus.stepId}/动作 ${focus.actionId} 第 ${focus.frameIndex + 1} 帧`
+function ReadOnlyNotice({ text }: { text: string }) {
+ return
{text}
}
diff --git a/frontend/src/pages/workflow-editor/index.tsx b/frontend/src/pages/workflow-editor/index.tsx
index 1e2aec6..cdb7697 100644
--- a/frontend/src/pages/workflow-editor/index.tsx
+++ b/frontend/src/pages/workflow-editor/index.tsx
@@ -1,46 +1,92 @@
+import { useEffect } from 'react'
import { useNavigate, useParams, useSearchParams } from 'react-router'
+import {
+ canEnterNode,
+ getFirstAccessibleNodeType,
+ getNodeByType,
+ getRevision,
+ isWorkflowNodeType,
+ submitWorkflowCommand,
+ useWorkflowRun,
+} from '@/entities'
import { PageHeader } from '@/shared/ui'
import { WorkflowEditor } from './editor'
-import type { WorkflowEditorFocus } from './editor'
-/**
- * Workflow Editor 的路由适配器。
- *
- * 只读 URL、渲染页头和处理跨页跳转;编辑器内部交互属于 ./editor 模块。
- */
+/** Workflow Editor 的 Router 适配器;编辑器本体不读取 URL。 */
export function WorkflowEditorPage() {
const navigate = useNavigate()
- const { runId = '' } = useParams()
+ const { runId = '', stage } = useParams()
const [search] = useSearchParams()
+ const query = useWorkflowRun(runId)
+ const requestedRevisionId = search.get('revision')
+ const run = query.data
+ const revision = run
+ ? getRevision(run, requestedRevisionId ?? run.currentRevisionId)
+ : null
+ const requestedType = isWorkflowNodeType(stage) ? stage : null
+ const fallbackType = revision ? getFirstAccessibleNodeType(revision) : null
+ const activeType =
+ run && revision && requestedType && canEnterNode(run, revision.id, requestedType)
+ ? requestedType
+ : fallbackType
+
+ useEffect(() => {
+ if (!run || !revision || !activeType) return
+ if (stage === activeType && requestedType) return
+ const suffix = requestedRevisionId ? `?revision=${encodeURIComponent(requestedRevisionId)}` : ''
+ navigate(`/workflow-editor/${run.id}/${activeType}${suffix}`, { replace: true })
+ }, [activeType, navigate, requestedRevisionId, requestedType, revision, run, stage])
+
+ if (query.loading) return
加载工作流…
+ if (query.error) return
加载失败:{query.error.message}
+ if (!run) return null
+ if (!revision) return
指定的历史版本不存在。
+ if (!activeType) return
工作流没有可访问节点。
+
+ const readOnly = revision.id !== run.currentRevisionId
return (
<>
-
navigate(-1)} />
+ navigate(-1)}
+ />
navigate(`/playtest/${characterId}`)}
+ run={run}
+ revision={revision}
+ activeType={activeType}
+ readOnly={readOnly}
+ onSelectNode={(type) => {
+ const suffix = readOnly ? `?revision=${encodeURIComponent(revision.id)}` : ''
+ navigate(`/workflow-editor/${run.id}/${type}${suffix}`)
+ }}
+ onOpenRevision={(revisionId) => {
+ const target = getRevision(run, revisionId)
+ if (!target) return
+ const type = getFirstAccessibleNodeType(target)
+ const suffix = revisionId === run.currentRevisionId ? '' : `?revision=${encodeURIComponent(revisionId)}`
+ navigate(`/workflow-editor/${run.id}/${type}${suffix}`)
+ }}
+ onRestartNode={async (nodeId) => {
+ const updated = await submitWorkflowCommand(run.id, {
+ kind: 'restart-from-node',
+ sourceRevisionId: revision.id,
+ nodeId,
+ })
+ const nextRevision = getRevision(updated, updated.currentRevisionId)!
+ const nextNode = getNodeByType(nextRevision, activeType)
+ navigate(`/workflow-editor/${updated.id}/${nextNode?.type ?? getFirstAccessibleNodeType(nextRevision)}`)
+ query.refresh()
+ }}
+ onOpenPreview={(characterId) =>
+ navigate(
+ `/playtest/${encodeURIComponent(characterId)}?runId=${encodeURIComponent(run.id)}` +
+ `&revision=${encodeURIComponent(revision.id)}`,
+ )
+ }
/>
>
)
}
-
-/** 只接受完整的定位组合,不把半套或 NaN 参数传进编辑器。 */
-function readFocus(search: URLSearchParams): WorkflowEditorFocus | undefined {
- const stepId = search.get('stepId')
- const rawActionId = search.get('actionId')
- const rawFrameIndex = search.get('frameIndex')
-
- if (!stepId) return undefined
- if (rawActionId === null && rawFrameIndex === null) return { kind: 'step', stepId }
- if (rawActionId === null || rawFrameIndex === null) return undefined
-
- const actionId = Number(rawActionId)
- const frameIndex = Number(rawFrameIndex)
- if (!Number.isInteger(actionId) || actionId <= 0) return undefined
- if (!Number.isInteger(frameIndex) || frameIndex < 0) return undefined
-
- return { kind: 'frame', stepId, actionId, frameIndex }
-}
diff --git a/frontend/src/pages/workflow-editor/steps/asset-step/README.md b/frontend/src/pages/workflow-editor/steps/asset-step/README.md
new file mode 100644
index 0000000..ba7e367
--- /dev/null
+++ b/frontend/src/pages/workflow-editor/steps/asset-step/README.md
@@ -0,0 +1,4 @@
+# Asset Step
+
+资产设置阶段页面:项目、角色、造型、基准帧和动作输入的页面组合入口。
+
diff --git a/frontend/src/pages/workflow-editor/steps/candidate-step/README.md b/frontend/src/pages/workflow-editor/steps/candidate-step/README.md
new file mode 100644
index 0000000..a00ae8a
--- /dev/null
+++ b/frontend/src/pages/workflow-editor/steps/candidate-step/README.md
@@ -0,0 +1,4 @@
+# Candidate Step
+
+候选交付阶段页面:展示通过系统质检的候选并提供人工处理入口。
+
diff --git a/frontend/src/pages/workflow-editor/steps/export-step/README.md b/frontend/src/pages/workflow-editor/steps/export-step/README.md
new file mode 100644
index 0000000..66c7378
--- /dev/null
+++ b/frontend/src/pages/workflow-editor/steps/export-step/README.md
@@ -0,0 +1,4 @@
+# Export Step
+
+导出阶段页面:组合导出配置、任务状态和下载入口。Playtest 未通过只提供建议,不阻断导出。
+
diff --git a/frontend/src/pages/workflow-editor/steps/generation-step/README.md b/frontend/src/pages/workflow-editor/steps/generation-step/README.md
new file mode 100644
index 0000000..f8d3aef
--- /dev/null
+++ b/frontend/src/pages/workflow-editor/steps/generation-step/README.md
@@ -0,0 +1,4 @@
+# Generation Step
+
+生成阶段页面:Provider Session、确认、Job 进度和质量门禁的页面组合入口。
+
diff --git a/frontend/src/pages/workflow-editor/steps/review-step/README.md b/frontend/src/pages/workflow-editor/steps/review-step/README.md
new file mode 100644
index 0000000..02007d3
--- /dev/null
+++ b/frontend/src/pages/workflow-editor/steps/review-step/README.md
@@ -0,0 +1,4 @@
+# Review Step
+
+审核与修复阶段页面:组合 Review Feature,回退时创建新的 WorkflowRun Revision。
+
diff --git a/frontend/src/shared/api/generated/README.md b/frontend/src/shared/api/generated/README.md
new file mode 100644
index 0000000..eff1766
--- /dev/null
+++ b/frontend/src/shared/api/generated/README.md
@@ -0,0 +1,4 @@
+# Generated API 预留
+
+这里预留未来由 OpenAPI/后端契约生成的客户端代码。当前不放置不存在的生成文件,业务代码不得直接依赖本目录。
+
diff --git a/frontend/src/shared/api/index.ts b/frontend/src/shared/api/index.ts
index ca650c3..47eccf2 100644
--- a/frontend/src/shared/api/index.ts
+++ b/frontend/src/shared/api/index.ts
@@ -8,8 +8,8 @@ import type { ApiListResponse, ApiResponse, Paged } from './client/mappers'
import { realRequest } from './client/real'
import type { RequestOptions } from './client/types'
-/** 两种实现对外一致,业务代码不感知。.env.local 里 VITE_USE_MOCK=false 切真后端。 */
-const USE_MOCK = import.meta.env.VITE_USE_MOCK !== 'false'
+/** 开发默认 Mock;生产构建永远使用真实 transport,不能回退演示数据。 */
+const USE_MOCK = !import.meta.env.PROD && import.meta.env.VITE_USE_MOCK !== 'false'
const send = (path: string, options: RequestOptions): Promise =>
USE_MOCK ? mockRequest(path, options) : realRequest(path, options)
@@ -30,3 +30,4 @@ export async function requestList(
export { ApiError } from './client/mappers'
export type { ApiListResponse, ApiResponse, Paged, PageQuery } from './client/mappers'
export type { RequestOptions } from './client/types'
+export { uploadFile } from './upload'
diff --git a/frontend/src/shared/api/request.ts b/frontend/src/shared/api/request.ts
new file mode 100644
index 0000000..0d484b7
--- /dev/null
+++ b/frontend/src/shared/api/request.ts
@@ -0,0 +1,5 @@
+/**
+ * JSON transport 公开边界。
+ * 真实实现待后端契约稳定后补入;业务层不得直接调用 fetch。
+ */
+
diff --git a/frontend/src/shared/api/stream.ts b/frontend/src/shared/api/stream.ts
new file mode 100644
index 0000000..725bf45
--- /dev/null
+++ b/frontend/src/shared/api/stream.ts
@@ -0,0 +1,5 @@
+/**
+ * SSE/流式任务 transport 公开边界。
+ * 真实实现待 Provider Job 事件协议稳定后补入。
+ */
+
diff --git a/frontend/src/shared/api/upload.ts b/frontend/src/shared/api/upload.ts
new file mode 100644
index 0000000..1582eba
--- /dev/null
+++ b/frontend/src/shared/api/upload.ts
@@ -0,0 +1,24 @@
+import { ApiError } from './client/mappers'
+
+/**
+ * 文件上传 transport。业务层只负责校验业务允许的文件类型,
+ * URL、multipart 和响应错误统一由 shared 处理。
+ */
+export async function uploadFile(path: string, file: File): Promise {
+ const form = new FormData()
+ form.append('file', file)
+
+ const response = await fetch(`${import.meta.env.VITE_API_BASE_URL ?? '/api'}${path}`, {
+ method: 'POST',
+ body: form,
+ })
+ const payload = (await response.json()) as {
+ code: number
+ message: string
+ data: T | null
+ }
+ if (!response.ok || payload.code !== 200 || payload.data === null) {
+ throw new ApiError(payload.message || `上传失败:HTTP ${response.status}`, payload.code || response.status)
+ }
+ return payload.data
+}
diff --git a/frontend/src/shared/ui/README.md b/frontend/src/shared/ui/README.md
new file mode 100644
index 0000000..762041e
--- /dev/null
+++ b/frontend/src/shared/ui/README.md
@@ -0,0 +1,4 @@
+# Shared UI 预留
+
+这里记录业务无关基础组件的约定。当前只维护已经存在的 PageHeader;Button、Modal、Toast、Skeleton 等组件在真实使用场景出现后再实现。
+
diff --git a/tests/integration/architecture.test.ts b/tests/integration/architecture.test.ts
index 135f465..c3ff06d 100644
--- a/tests/integration/architecture.test.ts
+++ b/tests/integration/architecture.test.ts
@@ -33,6 +33,10 @@ const PAGE_MODULE_ROOTS = [
'pages/playtest/inspection-preview',
]
+function relativeSrc(file: string): string {
+ return relative(SRC, file).replaceAll('\\', '/')
+}
+
function walk(dir: string): string[] {
return readdirSync(dir).flatMap((name) => {
const full = join(dir, name)
@@ -59,7 +63,7 @@ interface Violation {
}
function sourceLocation(file: string): SourceLocation {
- const parts = relative(SRC, file).split('/')
+ const parts = relativeSrc(file).split('/')
const layer = ALLOWED[parts[0]] ? parts[0] : null
if (!layer) return { layer: null, slice: null }
return {
@@ -74,7 +78,7 @@ function targetLocation(file: string, specifier: string): TargetLocation | null
parts = specifier.slice(2).split('/')
} else if (specifier.startsWith('.')) {
const target = resolve(dirname(file), specifier)
- const rel = relative(SRC, target)
+ const rel = relativeSrc(target)
if (rel.startsWith('..')) return null
parts = rel.split('/')
} else {
@@ -93,7 +97,7 @@ function targetLocation(file: string, specifier: string): TargetLocation | null
function targetRelativePath(file: string, specifier: string): string | null {
if (specifier.startsWith('@/')) return specifier.slice(2)
if (!specifier.startsWith('.')) return null
- const rel = relative(SRC, resolve(dirname(file), specifier))
+ const rel = relativeSrc(resolve(dirname(file), specifier))
return rel.startsWith('..') ? null : rel
}
@@ -131,7 +135,7 @@ function moduleSpecifiers(file: string, source: string): string[] {
}
function violationFor(file: string, specifier: string): Violation | null {
- const rel = relative(SRC, file)
+ const rel = relativeSrc(file)
const source = sourceLocation(file)
if (
@@ -229,7 +233,7 @@ function collectCycles(): string[][] {
function visit(file: string): void {
if (visiting.has(file)) {
const start = stack.indexOf(file)
- cycles.push([...stack.slice(start), file].map((item) => relative(SRC, item)))
+ cycles.push([...stack.slice(start), file].map((item) => relativeSrc(item)))
return
}
if (visited.has(file)) return
@@ -302,4 +306,28 @@ describe('依赖边界', () => {
it('源码依赖图没有循环', () => {
expect(collectCycles()).toEqual([])
})
+
+ it('业务层不得直接发网络请求或依赖测试辅助', () => {
+ const offenders: string[] = []
+ for (const file of walk(SRC)) {
+ const rel = relative(SRC, file).replaceAll('\\', '/')
+ const source = readFileSync(file, 'utf8')
+ const isTransport =
+ rel.startsWith('shared/api/client/real/') ||
+ rel === 'shared/api/upload.ts' ||
+ rel === 'shared/api/stream.ts'
+ if (!isTransport && /\bfetch\s*\(/.test(source)) {
+ offenders.push(`${rel}: direct fetch`)
+ }
+ for (const specifier of moduleSpecifiers(file, source)) {
+ if (specifier === '@/shared/testing' || specifier.startsWith('@/shared/testing/')) {
+ offenders.push(`${rel}: ${specifier}`)
+ }
+ if (specifier === '@/tests' || specifier.startsWith('@/tests/')) {
+ offenders.push(`${rel}: ${specifier}`)
+ }
+ }
+ }
+ expect(offenders).toEqual([])
+ })
})
diff --git a/tests/integration/workflow-run-store.test.ts b/tests/integration/workflow-run-store.test.ts
index 6833963..4339006 100644
--- a/tests/integration/workflow-run-store.test.ts
+++ b/tests/integration/workflow-run-store.test.ts
@@ -1,63 +1,156 @@
-import { describe, expect, it } from 'vitest'
+import { beforeEach, describe, expect, it } from 'vitest'
import {
- availableCommands,
+ canImportToPlaytest,
createWorkflowRun,
fetchWorkflowRun,
- submitWorkflowStep,
- suggestNextCommand,
+ getCurrentNode,
+ getCurrentRevision,
+ getNodeByType,
+ getRevision,
+ submitWorkflowCommand,
} from '@/entities'
-/**
- * entities/workflow-run 的存取契约:创建后能用同一 runId 取回。
- * 只覆盖数据层,不渲染页面、不走路由——页面竖线尚未自动验证。
- */
-describe('entities/workflow-run 存取契约', () => {
- it('创建后拿得到 runId,且用它取回的是同一份运行数据', async () => {
- const created = await createWorkflowRun({ projectId: 1, driver: 'ai', prompt: '像素小骑士' })
- expect(created.id).toBeTruthy()
- expect(created.projectId).toBe(1)
- expect(created.driver).toBe('ai')
+describe('entities/workflow-run Revision 契约', () => {
+ beforeEach(() => {
+ globalThis.localStorage?.clear()
+ })
+
+ it('Quick Start 和手动入口创建同一种 WorkflowRun', async () => {
+ const ai = await createWorkflowRun({
+ projectId: 'quick-start',
+ driver: 'ai',
+ prompt: '像素小骑士',
+ })
+ const manual = await createWorkflowRun({ projectId: 'project-1', driver: 'manual' })
+ expect(getCurrentNode(ai)?.type).toBe('generation')
+ expect(getNodeByType(getCurrentRevision(ai), 'asset')?.status).toBe('passed')
+ expect(getCurrentNode(manual)?.type).toBe('asset')
+ expect(ai.revisions).toHaveLength(1)
+ expect(manual.revisions).toHaveLength(1)
+ })
+
+ it('用同一个 runId 持久化并取回同一版本', async () => {
+ const created = await createWorkflowRun({ projectId: 'project-1', driver: 'manual' })
const loaded = await fetchWorkflowRun(created.id)
expect(loaded.id).toBe(created.id)
- expect(loaded.steps.length).toBe(created.steps.length)
+ expect(loaded.currentRevisionId).toBe(created.currentRevisionId)
})
- it('新建的工作流第一步只能生成母版', async () => {
- const run = await createWorkflowRun({ projectId: 1, driver: 'manual' })
- expect(availableCommands(run)).toEqual(['generate-template'])
+ it('生成完成后进入质量门禁,连续失败两次才阻断', async () => {
+ let run = await createWorkflowRun({ projectId: 'quick-start', driver: 'ai', prompt: '骑士' })
+ const generation = getCurrentNode(run)!
+ run = await submitWorkflowCommand(run.id, {
+ kind: 'complete-node',
+ nodeId: generation.id,
+ output: { jobId: 'job-1' },
+ })
+ const candidate = getCurrentNode(run)!
+
+ run = await submitWorkflowCommand(run.id, {
+ kind: 'record-quality-result',
+ nodeId: candidate.id,
+ passed: false,
+ report: { reason: '抖动' },
+ })
+ expect(getCurrentNode(run)?.qualityFailureCount).toBe(1)
+ expect(getCurrentNode(run)?.status).toBe('active')
+
+ run = await submitWorkflowCommand(run.id, {
+ kind: 'record-quality-result',
+ nodeId: candidate.id,
+ passed: false,
+ report: { reason: '轮廓断裂' },
+ })
+ expect(getNodeByType(getCurrentRevision(run), 'candidate')?.status).toBe('failed')
+ expect(getCurrentRevision(run).generationStatus).toBe('failed')
+ expect(run.status).toBe('failed')
})
- it('AI 建议与手动命令共用同一提交接口推进运行', async () => {
- const manualRun = await createWorkflowRun({ projectId: 1, driver: 'manual' })
- const manuallyAdvanced = await submitWorkflowStep(manualRun.id, {
- kind: 'generate-template',
- description: '手动输入的母版描述',
+ it('质量门禁通过后形成可导入 Playtest 的历史版本', async () => {
+ let run = await createWorkflowRun({ projectId: 'quick-start', driver: 'ai', prompt: '骑士' })
+ run = await submitWorkflowCommand(run.id, {
+ kind: 'complete-node',
+ nodeId: getCurrentNode(run)!.id,
+ })
+ run = await submitWorkflowCommand(run.id, {
+ kind: 'record-quality-result',
+ nodeId: getCurrentNode(run)!.id,
+ passed: true,
+ report: { passed: true },
})
- expect(manuallyAdvanced.steps[0].status).toBe('done')
- const plan = { description: '像素小骑士', actionNames: ['行走'] }
- let run = await createWorkflowRun({ projectId: 1, driver: 'ai', prompt: plan.description })
+ expect(getCurrentRevision(run).generationStatus).toBe('completed')
+ expect(getCurrentNode(run)?.type).toBe('review')
+ expect(canImportToPlaytest(run, run.currentRevisionId)).toBe(true)
+ })
- for (let index = 0; index < 5; index += 1) {
- const command = suggestNextCommand(run, plan)
- expect(command).not.toBeNull()
- run = await submitWorkflowStep(run.id, command!)
- }
+ it('导出必须进入 export 节点,不能绕过 review', async () => {
+ let run = await createWorkflowRun({ projectId: 'quick-start', driver: 'ai', prompt: '骑士' })
+ run = await submitWorkflowCommand(run.id, {
+ kind: 'complete-node',
+ nodeId: getCurrentNode(run)!.id,
+ })
+ run = await submitWorkflowCommand(run.id, {
+ kind: 'record-quality-result',
+ nodeId: getCurrentNode(run)!.id,
+ passed: true,
+ })
+
+ await expect(
+ submitWorkflowCommand(run.id, { kind: 'set-export-status', status: 'exported' }),
+ ).rejects.toThrow(/不允许命令/)
+
+ run = await submitWorkflowCommand(run.id, {
+ kind: 'complete-node',
+ nodeId: getCurrentNode(run)!.id,
+ })
+ expect(getCurrentNode(run)?.type).toBe('export')
+
+ run = await submitWorkflowCommand(run.id, {
+ kind: 'set-export-status',
+ status: 'exported',
+ })
+ expect(getCurrentRevision(run).exportStatus).toBe('exported')
+ expect(getNodeByType(getCurrentRevision(run), 'export')?.status).toBe('passed')
+ })
+
+ it('从历史节点重启会保留前缀引用并移除后续执行线', async () => {
+ let run = await createWorkflowRun({ projectId: 'quick-start', driver: 'ai', prompt: '骑士' })
+ run = await submitWorkflowCommand(run.id, {
+ kind: 'complete-node',
+ nodeId: getCurrentNode(run)!.id,
+ })
+ run = await submitWorkflowCommand(run.id, {
+ kind: 'record-quality-result',
+ nodeId: getCurrentNode(run)!.id,
+ passed: true,
+ })
+ const sourceRevision = getCurrentRevision(run)
+ const generationNode = getNodeByType(sourceRevision, 'generation')!
+
+ run = await submitWorkflowCommand(run.id, {
+ kind: 'restart-from-node',
+ sourceRevisionId: sourceRevision.id,
+ nodeId: generationNode.id,
+ })
+ const restarted = getCurrentRevision(run)
- expect(run.status).toBe('completed')
- expect(run.currentStepId).toBeNull()
- expect(run.steps.map((step) => step.type)).toEqual([
- 'generate-template',
- 'confirm-template',
- 'add-action',
- 'generate-action',
+ expect(run.revisions).toHaveLength(2)
+ expect(restarted.basedOnRevisionId).toBe(sourceRevision.id)
+ expect(restarted.nodes.map((node) => node.type)).toEqual(['asset', 'generation'])
+ expect(getCurrentNode(run)?.type).toBe('generation')
+ expect(getNodeByType(restarted, 'generation')?.referenceNodeIds).toContain(generationNode.id)
+ expect(getRevision(run, sourceRevision.id)?.nodes.map((node) => node.type)).toEqual([
+ 'asset',
+ 'generation',
+ 'candidate',
+ 'review',
])
- expect(suggestNextCommand(run, plan)).toBeNull()
})
- it('取一个不存在的工作流会报错,不会返回空对象', async () => {
+ it('不存在的工作流明确报错', async () => {
await expect(fetchWorkflowRun('run-does-not-exist')).rejects.toThrow()
})
})
From a41d74bb95c4b495316d6f80dfe445835d4403ec Mon Sep 17 00:00:00 2001
From: huyan <2971427998@qq.com>
Date: Mon, 27 Jul 2026 22:01:32 +0800
Subject: [PATCH 12/21] feat(mock): move the workflow state machine into the
fake backend
Workflow rules lived in entities and wrote straight to localStorage, so the frontend owned a state machine that belongs to the server.
Add a mock workflow-run backend holding the DTO shape, storage, node advancement, quality gate and restart-revision logic, exposed over create, fetch and command routes, and register it in the mock dispatcher.
Validation and state transitions now sit behind a network call; dropping the directory is all it takes once the real API lands.
---
frontend/src/shared/api/client/mock/index.ts | 7 +-
.../api/client/mock/workflow-run/index.ts | 47 ++++
.../api/client/mock/workflow-run/machine.ts | 217 ++++++++++++++++++
.../api/client/mock/workflow-run/store.ts | 104 +++++++++
.../api/client/mock/workflow-run/types.ts | 47 ++++
5 files changed, 419 insertions(+), 3 deletions(-)
create mode 100644 frontend/src/shared/api/client/mock/workflow-run/index.ts
create mode 100644 frontend/src/shared/api/client/mock/workflow-run/machine.ts
create mode 100644 frontend/src/shared/api/client/mock/workflow-run/store.ts
create mode 100644 frontend/src/shared/api/client/mock/workflow-run/types.ts
diff --git a/frontend/src/shared/api/client/mock/index.ts b/frontend/src/shared/api/client/mock/index.ts
index 876bc3c..6015a5a 100644
--- a/frontend/src/shared/api/client/mock/index.ts
+++ b/frontend/src/shared/api/client/mock/index.ts
@@ -1,6 +1,7 @@
import type { RequestOptions } from '../types'
import { projectMockHandlers } from './project-handlers'
import type { MockRoute } from './types'
+import { workflowRunMockHandlers } from './workflow-run'
export type { MockHandler, MockRoute } from './types'
@@ -8,10 +9,10 @@ export type { MockHandler, MockRoute } from './types'
* mock 分发。与真实现形状一致,业务代码换实现不用改。
* 后端接口到位后删掉对应 handler 即可,不必整体切换。
*/
-const routes: MockRoute[] = [...projectMockHandlers]
+const routes: MockRoute[] = [...projectMockHandlers, ...workflowRunMockHandlers]
-/** 让加载态在开发时看得见。 */
-const MOCK_LATENCY_MS = 200
+/** 让加载态在开发时看得见;测试里不必等。 */
+const MOCK_LATENCY_MS = import.meta.env.MODE === 'test' ? 0 : 200
export async function mockRequest(path: string, options: RequestOptions): Promise {
const method = options.method ?? 'GET'
diff --git a/frontend/src/shared/api/client/mock/workflow-run/index.ts b/frontend/src/shared/api/client/mock/workflow-run/index.ts
new file mode 100644
index 0000000..353d016
--- /dev/null
+++ b/frontend/src/shared/api/client/mock/workflow-run/index.ts
@@ -0,0 +1,47 @@
+import type { MockRoute } from '../types'
+import { advanceRun } from './machine'
+import { createRun, loadRun } from './store'
+import type { CommandDto, RunDto } from './types'
+
+/** 后端 workflow 接口就绪后,删掉整个 workflow-run/ 目录即可。路径待 #60 确认。 */
+const ok = (data: RunDto, message = 'success') => ({ code: 200, message, data })
+const fail = (code: number, message: string) => ({ code, message, data: null })
+
+export const workflowRunMockHandlers: MockRoute[] = [
+ {
+ method: 'POST',
+ pattern: /^\/workflows$/,
+ handler: (options) => {
+ const body = options.body as Partial | undefined
+ if (!body?.project_id) return fail(400, '缺少 project_id')
+ return ok(
+ createRun({
+ projectId: body.project_id,
+ driver: body.driver ?? 'manual',
+ prompt: body.prompt ?? null,
+ }),
+ '创建成功',
+ )
+ },
+ },
+ {
+ method: 'GET',
+ pattern: /^\/workflows\/([^/]+)$/,
+ handler: (_options, params) => {
+ const run = loadRun(params[0])
+ return run ? ok(run) : fail(404, `工作流 ${params[0]} 不存在`)
+ },
+ },
+ {
+ method: 'POST',
+ pattern: /^\/workflows\/([^/]+)\/commands$/,
+ handler: (options, params) => {
+ if (!loadRun(params[0])) return fail(404, `工作流 ${params[0]} 不存在`)
+ try {
+ return ok(advanceRun(params[0], options.body as CommandDto))
+ } catch (cause) {
+ return fail(400, cause instanceof Error ? cause.message : String(cause))
+ }
+ },
+ },
+]
diff --git a/frontend/src/shared/api/client/mock/workflow-run/machine.ts b/frontend/src/shared/api/client/mock/workflow-run/machine.ts
new file mode 100644
index 0000000..9896278
--- /dev/null
+++ b/frontend/src/shared/api/client/mock/workflow-run/machine.ts
@@ -0,0 +1,217 @@
+import { loadRun, newId, saveRun } from './store'
+import { NODE_ORDER, type CommandDto, type NodeDto, type RevisionDto, type RunDto } from './types'
+
+/** 工作流状态机。entities 的同名规则只是客户端预判,以这里为准。 */
+export function advanceRun(runId: string, command: CommandDto): RunDto {
+ const run = loadRun(runId)
+ if (!run) throw new Error(`工作流 ${runId} 不存在`)
+ if (!canSubmit(run, command)) throw new Error(`工作流 ${runId} 当前不允许命令 ${command.kind}`)
+
+ if (command.kind === 'record-playtest') {
+ return saveRun({
+ ...run,
+ revisions: run.revisions.map((item) =>
+ item.id === command.revision_id ? { ...item, playtest_status: command.status } : item,
+ ),
+ })
+ }
+
+ if (command.kind === 'restart-from-node') {
+ const revision = restartRevision(revisionOf(run, command.source_revision_id)!, command.node_id)
+ return saveRun({
+ ...run,
+ status: 'active',
+ current_revision_id: revision.id,
+ revisions: [...run.revisions, revision],
+ })
+ }
+
+ const current = currentRevision(run)
+
+ if (command.kind === 'set-export-status') {
+ const exportNode = current.nodes.find((node) => node.type === 'export')!
+ const updated = replaceNode(current, exportNode.id, (node) => ({
+ ...node,
+ status:
+ command.status === 'exported' ? 'passed' : command.status === 'failed' ? 'failed' : node.status,
+ }))
+ const exported = command.status === 'exported'
+ return saveRun(
+ replaceRevision(
+ run,
+ { ...updated, export_status: command.status, status: exported ? 'completed' : updated.status },
+ exported ? 'completed' : run.status,
+ ),
+ )
+ }
+
+ if (command.kind === 'record-quality-result') {
+ return saveRun(applyQualityResult(run, current, command.node_id, command.passed, command.report))
+ }
+
+ if (command.kind === 'fail-node') {
+ const failed = replaceNode(current, command.node_id, (node) => ({
+ ...node,
+ status: 'failed',
+ output: { error: command.error },
+ }))
+ return saveRun(replaceRevision(run, { ...failed, status: 'failed' }, 'failed'))
+ }
+
+ const completed = replaceNode(current, command.node_id, (node) => ({
+ ...node,
+ status: 'passed',
+ output: command.output ?? null,
+ }))
+ return saveRun(
+ replaceRevision(run, appendNextNode(completed, nodeOf(completed, command.node_id))),
+ )
+}
+
+function canSubmit(run: RunDto, command: CommandDto): boolean {
+ if (command.kind === 'record-playtest') {
+ return revisionOf(run, command.revision_id)?.generation_status === 'completed'
+ }
+
+ if (command.kind === 'restart-from-node') {
+ const node = revisionOf(run, command.source_revision_id)?.nodes.find(
+ (item) => item.id === command.node_id,
+ )
+ return Boolean(node && node.status !== 'locked' && node.status !== 'available')
+ }
+
+ const revision = currentRevision(run)
+ if (revision.status === 'abandoned') return false
+
+ if (command.kind === 'set-export-status') {
+ const exportNode = revision.nodes.find((node) => node.type === 'export')
+ return revision.generation_status === 'completed' && exportNode?.status === 'active'
+ }
+
+ const node = revision.nodes.find((item) => item.id === command.node_id)
+ if (!node || node.status !== 'active') return false
+ if (command.kind === 'record-quality-result') return node.type === 'candidate'
+ if (command.kind === 'complete-node') return node.type !== 'candidate'
+ return true
+}
+
+function applyQualityResult(
+ run: RunDto,
+ revision: RevisionDto,
+ nodeId: string,
+ passed: boolean,
+ report: unknown,
+): RunDto {
+ if (passed) {
+ const completed = replaceNode(revision, nodeId, (node) => ({
+ ...node,
+ status: 'passed',
+ output: report ?? null,
+ }))
+ return replaceRevision(run, {
+ ...appendNextNode(completed, nodeOf(completed, nodeId)),
+ generation_status: 'completed',
+ })
+ }
+
+ let failureCount = 0
+ const failed = replaceNode(revision, nodeId, (node) => {
+ failureCount = node.quality_failure_count + 1
+ return {
+ ...node,
+ quality_failure_count: failureCount,
+ status: failureCount >= 2 ? 'failed' : 'active',
+ output: report ?? null,
+ }
+ })
+ if (failureCount < 2) return replaceRevision(run, failed)
+ return replaceRevision(run, { ...failed, generation_status: 'failed', status: 'failed' }, 'failed')
+}
+
+/** 从某节点重开:保留该节点及之前的前缀作为参考,后续执行线不带过来。 */
+function restartRevision(source: RevisionDto, nodeId: string): RevisionDto {
+ const selectedIndex = source.nodes.findIndex((node) => node.id === nodeId)
+ if (selectedIndex < 0) throw new Error(`版本 ${source.id} 不包含节点 ${nodeId}`)
+
+ const nodes = source.nodes.slice(0, selectedIndex + 1).map((node, index) => ({
+ ...node,
+ id: newId('node'),
+ order: index,
+ status: index === selectedIndex ? ('active' as const) : ('passed' as const),
+ output: index === selectedIndex ? null : node.output,
+ reference_node_ids: [...node.reference_node_ids, node.id],
+ quality_failure_count: index === selectedIndex ? 0 : node.quality_failure_count,
+ }))
+ const selectedOrder = NODE_ORDER.indexOf(nodes.at(-1)!.type)
+
+ return {
+ id: newId('revision'),
+ based_on_revision_id: source.id,
+ restart_node_id: nodeId,
+ status: 'active',
+ nodes,
+ generation_status:
+ selectedOrder <= NODE_ORDER.indexOf('candidate') ? 'in_progress' : source.generation_status,
+ export_status: 'not_exported',
+ playtest_status: 'not_tested',
+ created_at: new Date().toISOString(),
+ }
+}
+
+function appendNextNode(revision: RevisionDto, source: NodeDto): RevisionDto {
+ const type = NODE_ORDER[NODE_ORDER.indexOf(source.type) + 1]
+ if (!type) return revision
+ return {
+ ...revision,
+ nodes: [
+ ...revision.nodes,
+ {
+ id: newId('node'),
+ type,
+ order: source.order + 1,
+ status: 'active',
+ input: null,
+ output: null,
+ reference_node_ids: [source.id],
+ quality_failure_count: 0,
+ },
+ ],
+ }
+}
+
+function revisionOf(run: RunDto, revisionId: string): RevisionDto | null {
+ return run.revisions.find((revision) => revision.id === revisionId) ?? null
+}
+
+function currentRevision(run: RunDto): RevisionDto {
+ const revision = revisionOf(run, run.current_revision_id)
+ if (!revision) throw new Error(`工作流 ${run.id} 缺少当前版本 ${run.current_revision_id}`)
+ return revision
+}
+
+function nodeOf(revision: RevisionDto, nodeId: string): NodeDto {
+ return revision.nodes.find((node) => node.id === nodeId)!
+}
+
+function replaceNode(
+ revision: RevisionDto,
+ nodeId: string,
+ update: (node: NodeDto) => NodeDto,
+): RevisionDto {
+ return {
+ ...revision,
+ nodes: revision.nodes.map((node) => (node.id === nodeId ? update(node) : node)),
+ }
+}
+
+function replaceRevision(
+ run: RunDto,
+ revision: RevisionDto,
+ status: RunDto['status'] = run.status,
+): RunDto {
+ return {
+ ...run,
+ status,
+ revisions: run.revisions.map((item) => (item.id === revision.id ? revision : item)),
+ }
+}
diff --git a/frontend/src/shared/api/client/mock/workflow-run/store.ts b/frontend/src/shared/api/client/mock/workflow-run/store.ts
new file mode 100644
index 0000000..b6ea719
--- /dev/null
+++ b/frontend/src/shared/api/client/mock/workflow-run/store.ts
@@ -0,0 +1,104 @@
+import type { NodeDto, RevisionDto, RunDto } from './types'
+
+/** 假后端的落盘。前端不再直接读这个键。 */
+const STORAGE_KEY = 'windup.mock.workflow-runs.v3'
+
+type RunMap = Record
+
+let memory: RunMap = {}
+
+function storage(): Storage | null {
+ try {
+ return globalThis.localStorage ?? null
+ } catch {
+ return null
+ }
+}
+
+function isRunMap(value: unknown): value is RunMap {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return false
+ return Object.values(value).every(
+ (run: RunDto) => typeof run?.id === 'string' && Array.isArray(run?.revisions),
+ )
+}
+
+function readAll(): RunMap {
+ try {
+ const raw = storage()?.getItem(STORAGE_KEY)
+ if (!raw) return memory
+ const parsed: unknown = JSON.parse(raw)
+ return isRunMap(parsed) ? parsed : memory
+ } catch {
+ return memory
+ }
+}
+
+export function saveRun(run: RunDto): RunDto {
+ memory = { ...readAll(), [run.id]: run }
+ try {
+ storage()?.setItem(STORAGE_KEY, JSON.stringify(memory))
+ } catch {
+ // 落盘失败(无痕模式等)时退回内存,本次会话内仍能读回。
+ }
+ return run
+}
+
+export function loadRun(runId: string): RunDto | null {
+ return readAll()[runId] ?? null
+}
+
+export function newId(prefix: 'run' | 'revision' | 'node'): string {
+ return `${prefix}-${globalThis.crypto.randomUUID().slice(0, 8)}`
+}
+
+function node(type: NodeDto['type'], status: NodeDto['status'], input: unknown): NodeDto {
+ return {
+ id: newId('node'),
+ type,
+ order: 0,
+ status,
+ input,
+ output: null,
+ reference_node_ids: [],
+ quality_failure_count: 0,
+ }
+}
+
+/** AI 入口跳过资产设置直接进生成,手动入口从资产设置开始。 */
+export function initialRevision(driver: RunDto['driver'], prompt: string | null): RevisionDto {
+ const asset = node('asset', driver === 'ai' ? 'passed' : 'active', { prompt })
+ const nodes =
+ driver === 'ai'
+ ? [asset, { ...node('generation', 'active', { prompt }), order: 1, reference_node_ids: [asset.id] }]
+ : [asset]
+
+ return {
+ id: newId('revision'),
+ based_on_revision_id: null,
+ restart_node_id: null,
+ status: 'active',
+ nodes,
+ generation_status: 'in_progress',
+ export_status: 'not_exported',
+ playtest_status: 'not_tested',
+ created_at: new Date().toISOString(),
+ }
+}
+
+export function createRun(input: {
+ projectId: string
+ driver: RunDto['driver']
+ prompt: string | null
+}): RunDto {
+ const revision = initialRevision(input.driver, input.prompt)
+ return saveRun({
+ id: newId('run'),
+ project_id: input.projectId,
+ character_id: null,
+ driver: input.driver,
+ status: 'active',
+ current_revision_id: revision.id,
+ revisions: [revision],
+ prompt: input.prompt,
+ })
+}
diff --git a/frontend/src/shared/api/client/mock/workflow-run/types.ts b/frontend/src/shared/api/client/mock/workflow-run/types.ts
new file mode 100644
index 0000000..751a67b
--- /dev/null
+++ b/frontend/src/shared/api/client/mock/workflow-run/types.ts
@@ -0,0 +1,47 @@
+/** 后端形状,snake_case 对齐 Project(PR #57)。id 类型待 #60 确认,暂用 string。 */
+export const NODE_ORDER = ['asset', 'generation', 'candidate', 'review', 'export'] as const
+
+export type NodeTypeDto = (typeof NODE_ORDER)[number]
+export type NodeStatusDto = 'locked' | 'available' | 'active' | 'passed' | 'failed'
+
+export interface NodeDto {
+ id: string
+ type: NodeTypeDto
+ order: number
+ status: NodeStatusDto
+ input: unknown
+ output: unknown
+ reference_node_ids: string[]
+ quality_failure_count: number
+}
+
+export interface RevisionDto {
+ id: string
+ based_on_revision_id: string | null
+ restart_node_id: string | null
+ status: 'active' | 'completed' | 'failed' | 'abandoned'
+ nodes: NodeDto[]
+ generation_status: 'in_progress' | 'completed' | 'failed'
+ export_status: 'not_exported' | 'exporting' | 'exported' | 'failed'
+ playtest_status: 'not_tested' | 'passed' | 'issues_found'
+ created_at: string
+}
+
+export interface RunDto {
+ id: string
+ project_id: string
+ character_id: string | null
+ driver: 'ai' | 'manual'
+ status: 'active' | 'completed' | 'failed'
+ current_revision_id: string
+ revisions: RevisionDto[]
+ prompt: string | null
+}
+
+export type CommandDto =
+ | { kind: 'complete-node'; node_id: string; output?: unknown }
+ | { kind: 'fail-node'; node_id: string; error: string }
+ | { kind: 'record-quality-result'; node_id: string; passed: boolean; report?: unknown }
+ | { kind: 'restart-from-node'; source_revision_id: string; node_id: string }
+ | { kind: 'set-export-status'; status: RevisionDto['export_status'] }
+ | { kind: 'record-playtest'; revision_id: string; status: 'passed' | 'issues_found' }
From 177a7fb85999b5ce4435d570462c3a60a9fed16d Mon Sep 17 00:00:00 2001
From: huyan <2971427998@qq.com>
Date: Mon, 27 Jul 2026 22:01:55 +0800
Subject: [PATCH 13/21] refactor(entities): call the workflow API instead of
local storage
createWorkflowRun, fetchWorkflowRun and submitWorkflowCommand each carried their own persistence and rule checks.
Reduce them to request() calls with DTO translation, add the mapper module, and delete the local store.
The public interface is unchanged, so pages and features keep compiling untouched.
---
.../workflow-run/api/create-workflow-run.ts | 25 +--
frontend/src/entities/workflow-run/api/dto.ts | 103 +++++++++
.../workflow-run/api/get-workflow-run.ts | 16 +-
.../entities/workflow-run/api/local-store.ts | 107 ---------
.../api/submit-workflow-command.ts | 212 +-----------------
5 files changed, 130 insertions(+), 333 deletions(-)
create mode 100644 frontend/src/entities/workflow-run/api/dto.ts
delete mode 100644 frontend/src/entities/workflow-run/api/local-store.ts
diff --git a/frontend/src/entities/workflow-run/api/create-workflow-run.ts b/frontend/src/entities/workflow-run/api/create-workflow-run.ts
index 61f4e68..76d7cd5 100644
--- a/frontend/src/entities/workflow-run/api/create-workflow-run.ts
+++ b/frontend/src/entities/workflow-run/api/create-workflow-run.ts
@@ -1,18 +1,17 @@
+import { request } from '@/shared/api'
import type { CreateWorkflowRunInput, WorkflowRun } from '../model/types'
-import { initialRevision, newId, saveRun } from './local-store'
+import { toWorkflowRun, type WorkflowRunDto } from './dto'
-/** 创建运行。未来 Python adapter 必须保持相同领域返回值。 */
+/** POST /workflows */
export async function createWorkflowRun(input: CreateWorkflowRunInput): Promise {
- const prompt = input.prompt?.trim() || null
- const revision = initialRevision({ driver: input.driver, prompt })
- return saveRun({
- id: newId('run'),
- projectId: input.projectId,
- characterId: null,
- driver: input.driver,
- status: 'active',
- currentRevisionId: revision.id,
- revisions: [revision],
- prompt,
+ const dto = await request('/workflows', {
+ method: 'POST',
+ body: {
+ project_id: input.projectId,
+ driver: input.driver,
+ prompt: input.prompt?.trim() || null,
+ },
})
+ if (!dto) throw new Error('创建工作流未返回数据')
+ return toWorkflowRun(dto)
}
diff --git a/frontend/src/entities/workflow-run/api/dto.ts b/frontend/src/entities/workflow-run/api/dto.ts
new file mode 100644
index 0000000..c1d926c
--- /dev/null
+++ b/frontend/src/entities/workflow-run/api/dto.ts
@@ -0,0 +1,103 @@
+import type {
+ WorkflowCommand,
+ WorkflowNode,
+ WorkflowRevision,
+ WorkflowRun,
+} from '../model/types'
+
+/** 后端原始形状,只在 api/ 内出现;出了这里全项目只认 WorkflowRun。 */
+export interface WorkflowNodeDto {
+ id: string
+ type: WorkflowNode['type']
+ order: number
+ status: WorkflowNode['status']
+ input: unknown
+ output: unknown
+ reference_node_ids: string[]
+ quality_failure_count: number
+}
+
+export interface WorkflowRevisionDto {
+ id: string
+ based_on_revision_id: string | null
+ restart_node_id: string | null
+ status: WorkflowRevision['status']
+ nodes: WorkflowNodeDto[]
+ generation_status: WorkflowRevision['generationStatus']
+ export_status: WorkflowRevision['exportStatus']
+ playtest_status: WorkflowRevision['playtestStatus']
+ created_at: string
+}
+
+export interface WorkflowRunDto {
+ id: string
+ project_id: string
+ character_id: string | null
+ driver: WorkflowRun['driver']
+ status: WorkflowRun['status']
+ current_revision_id: string
+ revisions: WorkflowRevisionDto[]
+ prompt: string | null
+}
+
+export function toWorkflowRun(dto: WorkflowRunDto): WorkflowRun {
+ return {
+ id: dto.id,
+ projectId: dto.project_id,
+ characterId: dto.character_id,
+ driver: dto.driver,
+ status: dto.status,
+ currentRevisionId: dto.current_revision_id,
+ revisions: dto.revisions.map(toRevision),
+ prompt: dto.prompt,
+ }
+}
+
+function toRevision(dto: WorkflowRevisionDto): WorkflowRevision {
+ return {
+ id: dto.id,
+ basedOnRevisionId: dto.based_on_revision_id,
+ restartNodeId: dto.restart_node_id,
+ status: dto.status,
+ nodes: dto.nodes.map((node) => ({
+ id: node.id,
+ type: node.type,
+ order: node.order,
+ status: node.status,
+ input: node.input,
+ output: node.output,
+ referenceNodeIds: node.reference_node_ids,
+ qualityFailureCount: node.quality_failure_count,
+ })),
+ generationStatus: dto.generation_status,
+ exportStatus: dto.export_status,
+ playtestStatus: dto.playtest_status,
+ createdAt: dto.created_at,
+ }
+}
+
+export function toCommandDto(command: WorkflowCommand): Record {
+ switch (command.kind) {
+ case 'complete-node':
+ return { kind: command.kind, node_id: command.nodeId, output: command.output }
+ case 'fail-node':
+ return { kind: command.kind, node_id: command.nodeId, error: command.error }
+ case 'record-quality-result':
+ return {
+ kind: command.kind,
+ node_id: command.nodeId,
+ passed: command.passed,
+ report: command.report,
+ }
+ case 'restart-from-node':
+ return {
+ kind: command.kind,
+ source_revision_id: command.sourceRevisionId,
+ node_id: command.nodeId,
+ }
+ case 'set-export-status':
+ return { kind: command.kind, status: command.status }
+ case 'record-playtest':
+ return { kind: command.kind, revision_id: command.revisionId, status: command.status }
+ }
+}
diff --git a/frontend/src/entities/workflow-run/api/get-workflow-run.ts b/frontend/src/entities/workflow-run/api/get-workflow-run.ts
index cdc16a1..a7d82ea 100644
--- a/frontend/src/entities/workflow-run/api/get-workflow-run.ts
+++ b/frontend/src/entities/workflow-run/api/get-workflow-run.ts
@@ -1,14 +1,10 @@
+import { request } from '@/shared/api'
import type { WorkflowRun } from '../model/types'
-import { loadRun } from './local-store'
+import { toWorkflowRun, type WorkflowRunDto } from './dto'
-/**
- * 按 id 取回一次运行,刷新页面与从审核台跳回时靠它恢复现场。
- *
- * 后端暂不存 workflow。未来改成 GET /workflows/{id} 时以保持调用方稳定为目标,
- * 最终签名仍需前后端共同 Review。
- */
+/** GET /workflows/{id},不存在时抛 ApiError(404)。 */
export async function fetchWorkflowRun(runId: string): Promise {
- const run = loadRun(runId)
- if (!run) throw new Error(`工作流 ${runId} 不存在`)
- return run
+ const dto = await request(`/workflows/${runId}`)
+ if (!dto) throw new Error(`工作流 ${runId} 返回为空`)
+ return toWorkflowRun(dto)
}
diff --git a/frontend/src/entities/workflow-run/api/local-store.ts b/frontend/src/entities/workflow-run/api/local-store.ts
deleted file mode 100644
index 7da7bec..0000000
--- a/frontend/src/entities/workflow-run/api/local-store.ts
+++ /dev/null
@@ -1,107 +0,0 @@
-import type { WorkflowNode, WorkflowRevision, WorkflowRun } from '../model/types'
-
-/** 本地 adapter 只用于 Python WorkflowRun API 上线前,使用独立版本键隔离旧模型。 */
-const STORAGE_KEY = 'windup.workflow-runs.v3'
-
-type RunMap = Record
-
-let memory: RunMap = {}
-
-function storage(): Storage | null {
- try {
- return globalThis.localStorage ?? null
- } catch {
- return null
- }
-}
-
-function isRunMap(value: unknown): value is RunMap {
- if (!value || typeof value !== 'object' || Array.isArray(value)) return false
- return Object.values(value).every(
- (run) =>
- Boolean(run) &&
- typeof run === 'object' &&
- typeof (run as WorkflowRun).id === 'string' &&
- Array.isArray((run as WorkflowRun).revisions),
- )
-}
-
-function readAll(): RunMap {
- const store = storage()
- if (!store) return memory
- try {
- const raw = store.getItem(STORAGE_KEY)
- if (!raw) return memory
- const parsed: unknown = JSON.parse(raw)
- return isRunMap(parsed) ? parsed : memory
- } catch {
- return memory
- }
-}
-
-function writeAll(runs: RunMap): void {
- memory = runs
- try {
- storage()?.setItem(STORAGE_KEY, JSON.stringify(runs))
- } catch {
- // memory 已保存当前会话数据;持久化失败由调用界面另行提示。
- }
-}
-
-export function saveRun(run: WorkflowRun): WorkflowRun {
- writeAll({ ...readAll(), [run.id]: run })
- return run
-}
-
-export function loadRun(runId: string): WorkflowRun | null {
- return readAll()[runId] ?? null
-}
-
-export function newId(prefix: 'run' | 'revision' | 'node'): string {
- return `${prefix}-${globalThis.crypto.randomUUID().slice(0, 8)}`
-}
-
-function node(type: WorkflowNode['type'], status: WorkflowNode['status'], input: unknown): WorkflowNode {
- return {
- id: newId('node'),
- type,
- order: 0,
- status,
- input,
- output: null,
- referenceNodeIds: [],
- qualityFailureCount: 0,
- }
-}
-
-export function initialRevision(input: {
- driver: WorkflowRun['driver']
- prompt: string | null
-}): WorkflowRevision {
- const asset = node('asset', input.driver === 'ai' ? 'passed' : 'active', {
- prompt: input.prompt,
- })
- const nodes =
- input.driver === 'ai'
- ? [
- asset,
- {
- ...node('generation', 'active', { prompt: input.prompt }),
- order: 1,
- referenceNodeIds: [asset.id],
- },
- ]
- : [asset]
-
- return {
- id: newId('revision'),
- basedOnRevisionId: null,
- restartNodeId: null,
- status: 'active',
- nodes,
- generationStatus: 'in_progress',
- exportStatus: 'not_exported',
- playtestStatus: 'not_tested',
- createdAt: new Date().toISOString(),
- }
-}
diff --git a/frontend/src/entities/workflow-run/api/submit-workflow-command.ts b/frontend/src/entities/workflow-run/api/submit-workflow-command.ts
index 9f3c1f8..d3a203a 100644
--- a/frontend/src/entities/workflow-run/api/submit-workflow-command.ts
+++ b/frontend/src/entities/workflow-run/api/submit-workflow-command.ts
@@ -1,210 +1,16 @@
-import { canSubmitCommand, getCurrentRevision, getRevision, nextNodeType } from '../model/selectors'
-import {
- WORKFLOW_NODE_ORDER,
- type WorkflowCommand,
- type WorkflowNode,
- type WorkflowRevision,
- type WorkflowRun,
-} from '../model/types'
-import { loadRun, newId, saveRun } from './local-store'
+import { request } from '@/shared/api'
+import type { WorkflowCommand, WorkflowRun } from '../model/types'
+import { toCommandDto, toWorkflowRun, type WorkflowRunDto } from './dto'
-/** 所有 WorkflowRun 状态变化只经过这一命令入口。 */
+/** POST /workflows/{id}/commands —— 校验与状态推进都在服务端,这里只发命令。 */
export async function submitWorkflowCommand(
runId: string,
command: WorkflowCommand,
): Promise {
- const run = loadRun(runId)
- if (!run) throw new Error(`工作流 ${runId} 不存在`)
- if (!canSubmitCommand(run, command)) {
- throw new Error(`工作流 ${runId} 当前不允许命令 ${command.kind}`)
- }
-
- if (command.kind === 'record-playtest') {
- return saveRun(updateRevision(run, command.revisionId, (revision) => ({
- ...revision,
- playtestStatus: command.status,
- })))
- }
-
- if (command.kind === 'restart-from-node') {
- const source = getRevision(run, command.sourceRevisionId)!
- const revision = restartRevision(source, command.nodeId)
- return saveRun({
- ...run,
- status: 'active',
- currentRevisionId: revision.id,
- revisions: [...run.revisions, revision],
- })
- }
-
- const current = getCurrentRevision(run)
-
- if (command.kind === 'set-export-status') {
- const exportNode = current.nodes.find((node) => node.type === 'export')!
- const withExportStatus = replaceNode(current, exportNode.id, (node) => ({
- ...node,
- status:
- command.status === 'exported'
- ? 'passed'
- : command.status === 'failed'
- ? 'failed'
- : node.status,
- }))
- const updated = replaceRevision(run, {
- ...withExportStatus,
- exportStatus: command.status,
- status: command.status === 'exported' ? 'completed' : withExportStatus.status,
- })
- return saveRun({
- ...updated,
- status: command.status === 'exported' ? 'completed' : updated.status,
- })
- }
-
- if (command.kind === 'record-quality-result') {
- return saveRun(applyQualityResult(run, current, command.nodeId, command.passed, command.report))
- }
-
- if (command.kind === 'fail-node') {
- const updated = replaceNode(current, command.nodeId, (node) => ({
- ...node,
- status: 'failed',
- output: { error: command.error },
- }))
- return saveRun(replaceRevision(run, { ...updated, status: 'failed' }, 'failed'))
- }
-
- const completed = replaceNode(current, command.nodeId, (node) => ({
- ...node,
- status: 'passed',
- output: command.output ?? null,
- }))
- const sourceNode = completed.nodes.find((node) => node.id === command.nodeId)!
- const advanced = appendNextNode(completed, sourceNode)
- return saveRun(replaceRevision(run, advanced))
-}
-
-function applyQualityResult(
- run: WorkflowRun,
- revision: WorkflowRevision,
- nodeId: string,
- passed: boolean,
- report: unknown,
-): WorkflowRun {
- if (passed) {
- const completed = replaceNode(revision, nodeId, (node) => ({
- ...node,
- status: 'passed',
- output: report ?? null,
- }))
- const sourceNode = completed.nodes.find((node) => node.id === nodeId)!
- return replaceRevision(run, {
- ...appendNextNode(completed, sourceNode),
- generationStatus: 'completed',
- })
- }
-
- let failureCount = 0
- const failed = replaceNode(revision, nodeId, (node) => {
- failureCount = node.qualityFailureCount + 1
- return {
- ...node,
- qualityFailureCount: failureCount,
- status: failureCount >= 2 ? 'failed' : 'active',
- output: report ?? null,
- }
+ const dto = await request(`/workflows/${runId}/commands`, {
+ method: 'POST',
+ body: toCommandDto(command),
})
- if (failureCount < 2) return replaceRevision(run, failed)
- return replaceRevision(
- run,
- { ...failed, generationStatus: 'failed', status: 'failed' },
- 'failed',
- )
-}
-
-function restartRevision(source: WorkflowRevision, nodeId: string): WorkflowRevision {
- const selectedIndex = source.nodes.findIndex((node) => node.id === nodeId)
- if (selectedIndex < 0) throw new Error(`版本 ${source.id} 不包含节点 ${nodeId}`)
-
- const nodes = source.nodes.slice(0, selectedIndex + 1).map((sourceNode, index) => ({
- ...sourceNode,
- id: newId('node'),
- order: index,
- status: index === selectedIndex ? ('active' as const) : ('passed' as const),
- output: index === selectedIndex ? null : sourceNode.output,
- referenceNodeIds: [...sourceNode.referenceNodeIds, sourceNode.id],
- qualityFailureCount: index === selectedIndex ? 0 : sourceNode.qualityFailureCount,
- }))
- const selectedType = nodes.at(-1)!.type
- const selectedOrder = WORKFLOW_NODE_ORDER.indexOf(selectedType)
- const candidateOrder = WORKFLOW_NODE_ORDER.indexOf('candidate')
-
- return {
- id: newId('revision'),
- basedOnRevisionId: source.id,
- restartNodeId: nodeId,
- status: 'active',
- nodes,
- generationStatus: selectedOrder <= candidateOrder ? 'in_progress' : source.generationStatus,
- exportStatus: 'not_exported',
- playtestStatus: 'not_tested',
- createdAt: new Date().toISOString(),
- }
-}
-
-function appendNextNode(revision: WorkflowRevision, source: WorkflowNode): WorkflowRevision {
- const type = nextNodeType(source.type)
- if (!type) return revision
- return {
- ...revision,
- nodes: [
- ...revision.nodes,
- {
- id: newId('node'),
- type,
- order: source.order + 1,
- status: 'active',
- input: null,
- output: null,
- referenceNodeIds: [source.id],
- qualityFailureCount: 0,
- },
- ],
- }
-}
-
-function replaceNode(
- revision: WorkflowRevision,
- nodeId: string,
- update: (node: WorkflowNode) => WorkflowNode,
-): WorkflowRevision {
- return {
- ...revision,
- nodes: revision.nodes.map((node) => (node.id === nodeId ? update(node) : node)),
- }
-}
-
-function replaceRevision(
- run: WorkflowRun,
- revision: WorkflowRevision,
- status: WorkflowRun['status'] = run.status,
-): WorkflowRun {
- return {
- ...run,
- status,
- revisions: run.revisions.map((item) => (item.id === revision.id ? revision : item)),
- }
-}
-
-function updateRevision(
- run: WorkflowRun,
- revisionId: string,
- update: (revision: WorkflowRevision) => WorkflowRevision,
-): WorkflowRun {
- return {
- ...run,
- revisions: run.revisions.map((revision) =>
- revision.id === revisionId ? update(revision) : revision,
- ),
- }
+ if (!dto) throw new Error(`工作流 ${runId} 命令未返回数据`)
+ return toWorkflowRun(dto)
}
From 825cc1610945f8c8c56867dfdbc5a43551f3e839 Mon Sep 17 00:00:00 2001
From: huyan <2971427998@qq.com>
Date: Mon, 27 Jul 2026 22:02:15 +0800
Subject: [PATCH 14/21] fix(quick-start): keep ai runs on their own page
The AI entry navigated to the workflow editor as soon as a run was created, which contradicts the agreed flow where the two entries never hand off to each other.
Render the run status and generation panel inline, and offer the editor as a link the user chooses to follow.
The route stays at /quick-start after submission; the flow test asserts that again.
---
frontend/src/app/quick-start-flow.test.tsx | 12 +++---
frontend/src/pages/quick-start/index.tsx | 44 ++++++++++++++++------
2 files changed, 39 insertions(+), 17 deletions(-)
diff --git a/frontend/src/app/quick-start-flow.test.tsx b/frontend/src/app/quick-start-flow.test.tsx
index 0a1f22b..410cb16 100644
--- a/frontend/src/app/quick-start-flow.test.tsx
+++ b/frontend/src/app/quick-start-flow.test.tsx
@@ -14,7 +14,7 @@ describe('QuickStartPage', () => {
cleanup()
})
- it('从 Home 进入 Quick Start 后创建统一工作流并跳到 generation', async () => {
+ it('创建统一工作流后结果留在 Quick Start,不自动跳走', async () => {
render()
fireEvent.click(screen.getAllByRole('link', { name: /快速开始/ })[0])
@@ -22,11 +22,11 @@ describe('QuickStartPage', () => {
fireEvent.change(screen.getByPlaceholderText(/一个戴斗篷的像素小骑士/), {
target: { value: '像素小骑士,要走路' },
})
- fireEvent.click(screen.getByRole('button', { name: '进入工作流' }))
+ fireEvent.click(screen.getByRole('button', { name: '开始生成' }))
- expect(await screen.findByRole('heading', { name: '工作流' })).toBeTruthy()
- expect(screen.getByText(/Provider Session 尚未连接/)).toBeTruthy()
- expect(window.location.pathname).toMatch(/^\/workflow-editor\/run-[^/]+\/generation$/)
- expect(screen.queryByRole('heading', { name: '快速开始' })).toBeNull()
+ expect(await screen.findByText(/工作流 run-.+ · 生成 in_progress/)).toBeTruthy()
+ expect(window.location.pathname).toBe('/quick-start')
+ expect(screen.getByRole('heading', { name: '快速开始' })).toBeTruthy()
+ expect(screen.queryByRole('heading', { name: '工作流' })).toBeNull()
})
})
diff --git a/frontend/src/pages/quick-start/index.tsx b/frontend/src/pages/quick-start/index.tsx
index ea215ce..0a2edf4 100644
--- a/frontend/src/pages/quick-start/index.tsx
+++ b/frontend/src/pages/quick-start/index.tsx
@@ -1,13 +1,14 @@
import { useState } from 'react'
-import { useNavigate } from 'react-router'
+import { Link } from 'react-router'
-import { createWorkflowRun } from '@/entities'
+import { createWorkflowRun, getCurrentRevision, useWorkflowRun } from '@/entities'
+import { Generation } from '@/features/generation'
import { PageHeader } from '@/shared/ui'
-/** Quick Start 只生成统一工作流的初始输入,不维护第二套生成流程。 */
+/** AI 入口。与手动入口共用同一种 WorkflowRun,但结果留在本页,不自动跳走。 */
export function QuickStartPage() {
- const navigate = useNavigate()
const [description, setDescription] = useState('')
+ const [runId, setRunId] = useState(null)
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState(null)
@@ -21,12 +22,8 @@ export function QuickStartPage() {
setSubmitting(true)
setError(null)
try {
- const run = await createWorkflowRun({
- projectId: 'quick-start',
- driver: 'ai',
- prompt,
- })
- navigate(`/workflow-editor/${run.id}/generation`)
+ const run = await createWorkflowRun({ projectId: 'quick-start', driver: 'ai', prompt })
+ setRunId(run.id)
} catch (cause) {
setError(cause instanceof Error ? cause.message : String(cause))
} finally {
@@ -56,10 +53,35 @@ export function QuickStartPage() {
disabled={submitting}
className="rounded-lg bg-slate-900 px-4 py-2 text-sm text-white hover:bg-slate-700 disabled:opacity-50"
>
- {submitting ? '创建中…' : '进入工作流'}
+ {submitting ? '创建中…' : '开始生成'}
{error ? {error}
: null}
+ {runId ? : null}
>
)
}
+
+function QuickStartRun({ runId }: { runId: string }) {
+ const { data: run, loading, error } = useWorkflowRun(runId)
+
+ if (loading) return 加载生成进度…
+ if (error) return 加载失败:{error.message}
+ if (!run) return null
+
+ return (
+
+
+ 工作流 {run.id} · 生成 {getCurrentRevision(run).generationStatus}
+
+
+ {/* 跳转由用户主动触发;审核、导出这些节点只在编辑器里做。 */}
+
+ 打开完整工作流
+
+
+ )
+}
From 9749c7d97c1ee413aae66d8c044fbe13dbdecafa Mon Sep 17 00:00:00 2001
From: huyan <2971427998@qq.com>
Date: Mon, 27 Jul 2026 22:02:15 +0800
Subject: [PATCH 15/21] docs(entities): correct the workflow adapters note
The adapters README still described a localStorage adapter owned by entities.
Point it at the mock backend that now stands in for the server.
The directory keeps its placeholder role for real backend differences.
---
frontend/src/entities/workflow-run/adapters/README.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/frontend/src/entities/workflow-run/adapters/README.md b/frontend/src/entities/workflow-run/adapters/README.md
index ed01a24..ddc1e46 100644
--- a/frontend/src/entities/workflow-run/adapters/README.md
+++ b/frontend/src/entities/workflow-run/adapters/README.md
@@ -1,4 +1,4 @@
# WorkflowRun Adapters
-预留 localStorage adapter 与未来 Python API adapter。adapter 不是业务事实来源,必须服从 entities/workflow-run 的公开接口。
+工作流的存储与状态推进在服务端,开发期由 `shared/api/client/mock/workflow-run` 顶替。这里预留后端接口就绪后可能需要的差异适配,adapter 不是业务事实来源。
From d27fee7e2dac4ed03ac45e31e1b598946e8f68b8 Mon Sep 17 00:00:00 2001
From: xyh202131 <246811510+xyh202131@users.noreply.github.com>
Date: Tue, 28 Jul 2026 10:13:21 +0800
Subject: [PATCH 16/21] feat(quick-start): add simplified creation workspace
Keep AI-assisted creation in a focused route while sharing the existing workflow state underneath.
Co-Authored-By: Codex
---
frontend-architecture-v3.md | 7 +-
frontend/README.md | 6 +-
frontend/src/app/index.tsx | 1 +
frontend/src/app/quick-start-flow.test.tsx | 12 +-
frontend/src/pages/quick-start/index.tsx | 134 ++++++++++++++++-----
5 files changed, 123 insertions(+), 37 deletions(-)
diff --git a/frontend-architecture-v3.md b/frontend-architecture-v3.md
index c919177..251ae87 100644
--- a/frontend-architecture-v3.md
+++ b/frontend-architecture-v3.md
@@ -43,7 +43,8 @@ Account、Billing 和资产库复用 Feature 当前只在本文中预留,不
~~~
/ Home(目标入口)
-/quick-start Quick Start
+/quick-start Quick Start 输入页
+/quick-start/:runId Quick Start 简化创作台
/projects 项目列表
/projects/:projectId 项目详情
/workflow-editor/:runId 当前 Revision 的工作流入口
@@ -54,7 +55,7 @@ Account、Billing 和资产库复用 Feature 当前只在本文中预留,不
/ 不再承担 Quick Start 具体业务;Quick Start 使用 /quick-start。项目详情保留当前的 /projects/:projectId,不改为 /project/:projectId。
-Home 只提供 Quick Start 和从项目开始两个入口,不保存业务状态。Quick Start 负责自然语言输入和初始计划解析,创建与手动入口完全相同的 WorkflowRun 后跳转到 /workflow-editor/:runId。
+Home 只提供 Quick Start 和从项目开始两个入口,不保存业务状态。Quick Start 负责自然语言输入和初始计划解析,创建与手动入口完全相同的 WorkflowRun 后停留在独立的简化创作台(/quick-start/:runId)。该页面使用自然语言展示生成、检查和结果,不展示五个节点、Revision、WorkflowRun 或 Workflow Editor;后台仍复用同一套领域状态。需要精细控制时才进入 Workflow Editor。
ProjectsPage、ProjectDetailPage 和 AssetLibraryPage 当前直接使用 Entity;不提前创建 features/project 或 features/asset-library。出现复杂复用后再提取 Feature。
@@ -211,7 +212,7 @@ shared/api/
- 本地 WorkflowRun adapter、Revision、有序五节点和字符串领域 ID。
- 节点门禁、历史只读、从节点重启和后续执行线移除。
- 系统质检连续失败两次的领域规则,以及质检通过后的生成完成状态。
-- Quick Start 创建统一 WorkflowRun 并进入 generation。
+- Quick Start 创建统一 WorkflowRun 并进入独立的简化创作台;后台进入 generation,但页面不展示工作流内部结构。
- Workflow Editor 节点路由、历史 Revision URL 和重启交互。
- Playtest 的完整 Revision 导入门禁、核验结论记录和非阻断导出提示。
- 生产构建强制使用真实 API transport,业务层禁止直接 fetch。
diff --git a/frontend/README.md b/frontend/README.md
index 4ae9cad..2fbe738 100644
--- a/frontend/README.md
+++ b/frontend/README.md
@@ -18,7 +18,8 @@ npm run lint
默认页面是 Home:
- /:选择 Quick Start 或从项目开始
-- /quick-start:自然语言输入,创建 WorkflowRun 后进入 Workflow Editor
+- /quick-start:自然语言输入;创建 WorkflowRun 后进入独立的简化创作台,隐藏工作流节点与版本术语
+- /quick-start/:runId:Quick Start 的持续创作页;以自然语言展示生成、检查和结果状态
- /projects:项目列表
- /projects/:projectId:项目详情
- /workflow-editor/:runId/:stage:当前 Revision 的工作流节点
@@ -43,6 +44,9 @@ app -> pages -> features -> entities -> shared
Quick Start 与手动 Workflow 共用同一个 WorkflowRun。一个 run 可以有多个 Revision;
从某节点重新开始会保留节点及以前的参考,移除之后的当前执行线,旧 Revision 仍只读保留。
+Quick Start 不展示节点、Revision 或 Workflow Editor。它以简化创作台呈现自然语言进度,
+完成后可直接导出或导入核验台;Workflow Editor 则保留完整的人工控制能力。
+
当前本地 adapter 使用 localStorage 仅作为 Python WorkflowRun API 上线前的临时实现,
数据模型已经使用 Revision + 有序五节点:asset、generation、candidate、review、export。
diff --git a/frontend/src/app/index.tsx b/frontend/src/app/index.tsx
index 2f21173..cc79a32 100644
--- a/frontend/src/app/index.tsx
+++ b/frontend/src/app/index.tsx
@@ -20,6 +20,7 @@ export function App() {
} />
} />
+ } />
} />
} />
} />
diff --git a/frontend/src/app/quick-start-flow.test.tsx b/frontend/src/app/quick-start-flow.test.tsx
index 410cb16..e745f4b 100644
--- a/frontend/src/app/quick-start-flow.test.tsx
+++ b/frontend/src/app/quick-start-flow.test.tsx
@@ -14,7 +14,7 @@ describe('QuickStartPage', () => {
cleanup()
})
- it('创建统一工作流后结果留在 Quick Start,不自动跳走', async () => {
+ it('从 Home 进入 Quick Start 后创建统一运行并保留在简化创作台', async () => {
render()
fireEvent.click(screen.getAllByRole('link', { name: /快速开始/ })[0])
@@ -22,11 +22,13 @@ describe('QuickStartPage', () => {
fireEvent.change(screen.getByPlaceholderText(/一个戴斗篷的像素小骑士/), {
target: { value: '像素小骑士,要走路' },
})
- fireEvent.click(screen.getByRole('button', { name: '开始生成' }))
+ fireEvent.click(screen.getByRole('button', { name: '开始创作' }))
- expect(await screen.findByText(/工作流 run-.+ · 生成 in_progress/)).toBeTruthy()
- expect(window.location.pathname).toBe('/quick-start')
- expect(screen.getByRole('heading', { name: '快速开始' })).toBeTruthy()
+ expect(await screen.findByRole('heading', { name: '正在生成角色' })).toBeTruthy()
+ expect(screen.getByLabelText('创作进度')).toBeTruthy()
+ expect(screen.getByText('生成服务暂未连接')).toBeTruthy()
+ expect(window.location.pathname).toMatch(/^\/quick-start\/run-[^/]+$/)
expect(screen.queryByRole('heading', { name: '工作流' })).toBeNull()
+ expect(screen.queryByText(/Provider Session/)).toBeNull()
})
})
diff --git a/frontend/src/pages/quick-start/index.tsx b/frontend/src/pages/quick-start/index.tsx
index 0a2edf4..037e299 100644
--- a/frontend/src/pages/quick-start/index.tsx
+++ b/frontend/src/pages/quick-start/index.tsx
@@ -1,17 +1,35 @@
import { useState } from 'react'
-import { Link } from 'react-router'
+import { useNavigate, useParams } from 'react-router'
-import { createWorkflowRun, getCurrentRevision, useWorkflowRun } from '@/entities'
-import { Generation } from '@/features/generation'
+import {
+ canImportToPlaytest,
+ createWorkflowRun,
+ getCurrentRevision,
+ useWorkflowRun,
+} from '@/entities'
import { PageHeader } from '@/shared/ui'
-/** AI 入口。与手动入口共用同一种 WorkflowRun,但结果留在本页,不自动跳走。 */
+const CREATION_COPY = {
+ asset: ['正在理解你的设定', '已准备好角色创作所需的基础信息。'],
+ generation: ['正在生成角色', '正在连接生成服务并准备创作素材。'],
+ candidate: ['正在检查结果', '系统正在检查生成内容是否符合交付要求。'],
+ review: ['正在整理交付版本', '生成结果已准备好,正在完成最终整理。'],
+ export: ['正在准备文件', '正在整理可导出和可核验的角色文件。'],
+} as const
+
+/**
+ * Quick Start 是面向创作的独立体验。它复用 WorkflowRun 的领域状态,
+ * 但不向用户暴露节点、版本或编辑器术语。
+ */
export function QuickStartPage() {
+ const navigate = useNavigate()
+ const { runId } = useParams()
const [description, setDescription] = useState('')
- const [runId, setRunId] = useState(null)
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState(null)
+ if (runId) return navigate('/quick-start')} />
+
async function start() {
const prompt = description.trim()
if (!prompt) {
@@ -22,8 +40,12 @@ export function QuickStartPage() {
setSubmitting(true)
setError(null)
try {
- const run = await createWorkflowRun({ projectId: 'quick-start', driver: 'ai', prompt })
- setRunId(run.id)
+ const run = await createWorkflowRun({
+ projectId: 'quick-start',
+ driver: 'ai',
+ prompt,
+ })
+ navigate(`/quick-start/${run.id}`)
} catch (cause) {
setError(cause instanceof Error ? cause.message : String(cause))
} finally {
@@ -33,7 +55,7 @@ export function QuickStartPage() {
return (
<>
-
+
- {runId ? : null}
>
)
}
-function QuickStartRun({ runId }: { runId: string }) {
- const { data: run, loading, error } = useWorkflowRun(runId)
+function QuickStartCreation({ runId, onCreateAnother }: { runId: string; onCreateAnother: () => void }) {
+ const navigate = useNavigate()
+ const query = useWorkflowRun(runId)
+
+ if (query.loading) return 正在准备创作…
+ if (query.error) return 无法继续这次创作:{query.error.message}
+ if (!query.data) return 未找到这次创作记录。
- if (loading) return 加载生成进度…
- if (error) return 加载失败:{error.message}
- if (!run) return null
+ const run = query.data
+ const revision = getCurrentRevision(run)
+ const activeNode = revision.nodes.find((node) => node.status === 'active')
+ const [title, detail] = CREATION_COPY[activeNode?.type ?? 'export']
+ const completed = revision.generationStatus === 'completed'
+ const canOpenPlaytest = Boolean(run.characterId && canImportToPlaytest(run, revision.id))
return (
-
-
- 工作流 {run.id} · 生成 {getCurrentRevision(run).generationStatus}
-
-
- {/* 跳转由用户主动触发;审核、导出这些节点只在编辑器里做。 */}
-
- 打开完整工作流
-
-
+
+
+ 新建创作
+
+ }
+ />
+
+
+
+
{completed ? '已经为你准备好结果。' : title}
+
{completed ? '你可以继续导出或导入核验台。' : detail}
+
+
+ {['理解设定', '生成角色', '检查交付'].map((label, index) => {
+ const currentIndex = activeNode ? Math.min(activeNode.order, 2) : 2
+ const state = completed || index < currentIndex ? '已完成' : index === currentIndex ? '进行中' : '等待中'
+ return (
+ -
+
{label}
+ {state}
+
+ )
+ })}
+
+
+
+ {completed ? (
+
+ 下一步
+
+ 导出服务会在后端任务接入后显示下载入口。核验台可独立记录试用结果,不会影响当前完成状态。
+
+ {canOpenPlaytest ? (
+
+ ) : null}
+
+ ) : (
+
+ 生成服务暂未连接
+ 服务接入后,这里会持续更新创作进度和结果;当前不会显示虚假的完成结果。
+
+ )}
+
)
}
From ac3de1cd5fb5e2a465885bd657b17a6fa526093a Mon Sep 17 00:00:00 2001
From: xyh202131 <246811510+xyh202131@users.noreply.github.com>
Date: Tue, 28 Jul 2026 10:30:50 +0800
Subject: [PATCH 17/21] docs(frontend): align architecture with transport
boundaries
Document the public API facade, real route coverage, and the current Quick Start completion limits.
Co-Authored-By: Codex
---
frontend-architecture-v3.md | 8 ++++----
frontend/README.md | 9 +++++----
2 files changed, 9 insertions(+), 8 deletions(-)
diff --git a/frontend-architecture-v3.md b/frontend-architecture-v3.md
index 251ae87..5b140a3 100644
--- a/frontend-architecture-v3.md
+++ b/frontend-architecture-v3.md
@@ -5,7 +5,7 @@
## 1. 技术边界
- 前端:React + Vite + TypeScript + Tailwind CSS。
-- 后端:Python;WorkflowRun、Provider Job、质量门禁和导出任务最终由后端保存或执行。
+- 后端:Python;WorkflowRun、Provider Job、质量门禁和导出任务最终由后端保存或执行。开发和测试阶段经 Mock transport 验证接口契约,生产构建只使用真实 transport。
- 分层:app -> pages -> features -> entities -> shared。
- Quick Start 与手动 Workflow 是两种输入入口,最终进入同一套 WorkflowRun、Revision、生成、质检、历史、Playtest 和导出流程。
- 当前后端 WorkflowRun 尚未提供,本地 adapter 只用于开发和联调前的骨架验证。
@@ -17,7 +17,7 @@
| app | 启动、Router、Provider、全局布局、错误边界 | 已有基础实现 |
| pages | 完整路由页面、URL、页面临时状态和模块组合 | 已有部分页面,Workflow steps 待实现 |
| features | 用户对业务对象执行的完整操作 | 已有占位 Feature,按真实实现增量拆分 |
-| entities | 业务对象、查询、命令、选择器和领域规则 | Project 已接后端;WorkflowRun Revision/门禁已由本地 adapter 实现 |
+| entities | 业务对象、查询、命令、选择器和领域规则 | Project 已接后端;WorkflowRun Revision/门禁已经 shared API 门面实现,开发时由 Mock transport 承载 |
| shared | 通用 API transport、UI、工具和测试辅助 | 已有基础 API/UI,upload/stream 边界待补 |
Account、Billing 和资产库复用 Feature 当前只在本文中预留,不创建代码入口。
@@ -157,7 +157,7 @@ Playtest 保存独立核验记录,可回流到对应 Revision 的 Review,但
~ ~ ~
shared/api/
-├─ request.ts JSON 请求
+├─ index.ts JSON 请求的公开门面,以及 Mock/Real transport 切换
├─ upload.ts 文件上传
├─ stream.ts SSE/流式任务预留
├─ generated/ 预留,不伪造生成代码
@@ -209,7 +209,7 @@ shared/api/
已实现:
-- 本地 WorkflowRun adapter、Revision、有序五节点和字符串领域 ID。
+- WorkflowRun 的 shared API 门面、开发 Mock transport、Revision、有序五节点和字符串领域 ID。
- 节点门禁、历史只读、从节点重启和后续执行线移除。
- 系统质检连续失败两次的领域规则,以及质检通过后的生成完成状态。
- Quick Start 创建统一 WorkflowRun 并进入独立的简化创作台;后台进入 generation,但页面不展示工作流内部结构。
diff --git a/frontend/README.md b/frontend/README.md
index 2fbe738..aac5b99 100644
--- a/frontend/README.md
+++ b/frontend/README.md
@@ -22,6 +22,7 @@ npm run lint
- /quick-start/:runId:Quick Start 的持续创作页;以自然语言展示生成、检查和结果状态
- /projects:项目列表
- /projects/:projectId:项目详情
+- /workflow-editor/:runId:当前 Revision 的工作流入口
- /workflow-editor/:runId/:stage:当前 Revision 的工作流节点
- /playtest/:characterId?runId=:runId&revision=:revisionId:独立核验台
@@ -44,10 +45,10 @@ app -> pages -> features -> entities -> shared
Quick Start 与手动 Workflow 共用同一个 WorkflowRun。一个 run 可以有多个 Revision;
从某节点重新开始会保留节点及以前的参考,移除之后的当前执行线,旧 Revision 仍只读保留。
-Quick Start 不展示节点、Revision 或 Workflow Editor。它以简化创作台呈现自然语言进度,
-完成后可直接导出或导入核验台;Workflow Editor 则保留完整的人工控制能力。
+Quick Start 不展示节点、Revision 或 Workflow Editor。它以简化创作台呈现自然语言进度;
+完成后满足条件时可导入核验台,导出入口待后端任务接入。Workflow Editor 则保留完整的人工控制能力。
-当前本地 adapter 使用 localStorage 仅作为 Python WorkflowRun API 上线前的临时实现,
-数据模型已经使用 Revision + 有序五节点:asset、generation、candidate、review、export。
+WorkflowRun 经 shared API 门面访问:开发与测试使用 Mock transport(其存储临时使用 localStorage),
+生产构建只使用真实 API。数据模型已经使用 Revision + 有序五节点:asset、generation、candidate、review、export。
Provider、系统质量门禁、SSE、正式角色资产和导出任务尚待后端契约;未实现能力不会返回伪造成功。
From 56f37d4f6b96a80a8b08be7cc54421578b23cef6 Mon Sep 17 00:00:00 2001
From: xyh202131 <246811510+xyh202131@users.noreply.github.com>
Date: Tue, 28 Jul 2026 10:31:14 +0800
Subject: [PATCH 18/21] docs(frontend): clarify mock transport scope
Keep the architecture status aligned with the current API boundary implementation.
Co-Authored-By: Codex
---
frontend-architecture-v3.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/frontend-architecture-v3.md b/frontend-architecture-v3.md
index 5b140a3..0114490 100644
--- a/frontend-architecture-v3.md
+++ b/frontend-architecture-v3.md
@@ -8,7 +8,7 @@
- 后端:Python;WorkflowRun、Provider Job、质量门禁和导出任务最终由后端保存或执行。开发和测试阶段经 Mock transport 验证接口契约,生产构建只使用真实 transport。
- 分层:app -> pages -> features -> entities -> shared。
- Quick Start 与手动 Workflow 是两种输入入口,最终进入同一套 WorkflowRun、Revision、生成、质检、历史、Playtest 和导出流程。
-- 当前后端 WorkflowRun 尚未提供,本地 adapter 只用于开发和联调前的骨架验证。
+- 当前后端 WorkflowRun 尚未提供;Mock transport 仅用于开发、测试和联调前的接口骨架验证。
## 2. 分层职责
From c962ad89c14afcab645ee3cdd6893516fce658da Mon Sep 17 00:00:00 2001
From: xyh202131 <246811510+xyh202131@users.noreply.github.com>
Date: Tue, 28 Jul 2026 10:42:11 +0800
Subject: [PATCH 19/21] docs(frontend): document workflow editor module
Include the existing editor component and its interaction tests in the architecture tree.
Co-Authored-By: Codex
---
frontend-architecture-v3.md | 1 +
1 file changed, 1 insertion(+)
diff --git a/frontend-architecture-v3.md b/frontend-architecture-v3.md
index 0114490..34d4c76 100644
--- a/frontend-architecture-v3.md
+++ b/frontend-architecture-v3.md
@@ -67,6 +67,7 @@ ProjectsPage、ProjectDetailPage 和 AssetLibraryPage 当前直接使用 Entity
pages/workflow-editor/
├─ index.tsx
├─ canvas/
+├─ editor/ 编辑器组件与交互测试
└─ steps/
├─ asset-step/
├─ generation-step/
From cfcfffb49f3875a5de63fb6b867cef823ba4cc37 Mon Sep 17 00:00:00 2001
From: xyh202131 <246811510+xyh202131@users.noreply.github.com>
Date: Tue, 28 Jul 2026 11:39:20 +0800
Subject: [PATCH 20/21] fix(frontend): validate upload limits before request
Expose current API contract status and reject files beyond the backend upload limit locally.
Co-Authored-By: Codex
---
frontend/API_CONTRACT.md | 94 +++++++++++++++++++++++
frontend/README.md | 2 +
frontend/src/entities/project/api.test.ts | 21 +++++
frontend/src/entities/project/api.ts | 4 +
4 files changed, 121 insertions(+)
create mode 100644 frontend/API_CONTRACT.md
create mode 100644 frontend/src/entities/project/api.test.ts
diff --git a/frontend/API_CONTRACT.md b/frontend/API_CONTRACT.md
new file mode 100644
index 0000000..8dcad36
--- /dev/null
+++ b/frontend/API_CONTRACT.md
@@ -0,0 +1,94 @@
+# 前端 API 契约状态
+
+本文是前端当前调用和需求签名的清单,不替代未来由后端 OpenAPI 生成的客户端。
+在 OpenAPI 落地前,只有标为“已核对”的接口可以按真实后端接口联调;其余接口
+不能被描述为已接通后端。它们在开发或测试中可以由 Mock transport 承载,但生产请求
+仍需等待明确的后端契约。
+
+## 通用约定
+
+- 开发环境以 `/api` 为基址;Vite 将其代理到 `http://127.0.0.1:8000`。
+- 单项响应:`{ code, message, data, timestamp? }`;只有 `code === 200` 才是成功。
+- 列表响应额外包含 `total`、`page` 和 `page_size`。
+- 生产构建只使用真实 transport;不会回退到 Mock 数据。
+
+## 已核对:项目与上传
+
+以下接口的路径、方法、字段和响应壳已与后端 PR #57 对照。该 PR 尚未合并或部署,
+因此它们是“可联调契约”,不是当前已验证可访问的服务。
+
+### 项目
+
+- `GET /projects?page=1&page_size=20&user_id=1`
+- `GET /projects/{id}`
+- `POST /projects`
+- `DELETE /projects/{id}`
+
+创建项目示例:
+
+```json
+{
+ "user_id": 1,
+ "workflow_id": null,
+ "project_name": "Knight",
+ "character_perspective": 1,
+ "directional_movement": 1,
+ "sprite_width": 64,
+ "sprite_height": 64,
+ "game_style": null,
+ "sprite_sample_url": null
+}
+```
+
+`user_id` 目前是演示值;接入认证后应由后端从令牌推导,前端不再传递它。
+
+### 参考图片上传
+
+- `POST /upload/image`
+- `multipart/form-data`,字段名为 `file`
+- 允许 `image/jpeg`、`image/png`、`image/webp`、`image/gif`
+- 最大 10 MiB;前端会在发起网络请求前拒绝超限文件。
+
+```ts
+const url = await uploadImage(file)
+// 成功响应:{ code: 200, message: 'success', data: { url } }
+```
+
+## 未对齐:WorkflowRun
+
+前端目前有以下调用与 DTO:
+
+- `POST /workflows`
+- `GET /workflows/{id}`
+- `POST /workflows/{id}/commands`
+
+它们服务于 Quick Start 和 Workflow Editor 的页面状态,并在 Mock transport 中有完整状态机。
+生产 transport 会向这些路径发出真实请求,但当前仓库没有与之对应的后端路由、OpenAPI
+或 SSE 事件契约;后端目标架构也倾向让 MS2 前端直接调用项目、媒体、角色、生成、审核与
+导出等独立能力接口。因此这些路径在后端提供明确契约前不得视为可联调接口。
+
+命令请求的形式为:
+
+```json
+{
+ "kind": "restart-from-node",
+ "source_revision_id": "revision-1",
+ "node_id": "node-generation"
+}
+```
+
+后端如选择支持该接口,必须先确认 `WorkflowRunDto`、节点状态枚举、命令幂等性、错误码
+和进度事件;否则前端应改接独立业务 API。
+
+## 仅有需求签名:角色与资产
+
+以下函数当前会明确抛出 `not implemented`,不存在真实网络调用:
+
+- `GET /characters/{id}`、`POST /characters`
+- `GET /projects/{id}/characters`
+- `POST /characters/{id}/template/confirm`
+- `POST /characters/{id}/actions`、`POST /actions/{id}/confirm`
+- `GET /projects/{id}/action-templates`
+- `GET /projects/{id}/wearables`
+
+SSE 也只有前端 transport 预留,尚无订阅地址、事件名称或字段契约。
diff --git a/frontend/README.md b/frontend/README.md
index aac5b99..23c6594 100644
--- a/frontend/README.md
+++ b/frontend/README.md
@@ -15,6 +15,8 @@ npm run test
npm run lint
~~~
+接口联调状态、请求形式与未接通能力见 [API_CONTRACT.md](API_CONTRACT.md)。
+
默认页面是 Home:
- /:选择 Quick Start 或从项目开始
diff --git a/frontend/src/entities/project/api.test.ts b/frontend/src/entities/project/api.test.ts
new file mode 100644
index 0000000..5133c29
--- /dev/null
+++ b/frontend/src/entities/project/api.test.ts
@@ -0,0 +1,21 @@
+// @vitest-environment jsdom
+import { afterEach, describe, expect, it, vi } from 'vitest'
+
+import { uploadImage } from './api'
+
+afterEach(() => {
+ vi.unstubAllGlobals()
+})
+
+describe('uploadImage', () => {
+ it('rejects images larger than the backend upload limit before making a request', async () => {
+ const fetchMock = vi.fn()
+ vi.stubGlobal('fetch', fetchMock)
+ const file = new File([new Uint8Array(10 * 1024 * 1024 + 1)], 'large.png', {
+ type: 'image/png',
+ })
+
+ await expect(uploadImage(file)).rejects.toThrow('文件超过大小上限(10485760 字节)')
+ expect(fetchMock).not.toHaveBeenCalled()
+ })
+})
diff --git a/frontend/src/entities/project/api.ts b/frontend/src/entities/project/api.ts
index 901f9b5..9fe023a 100644
--- a/frontend/src/entities/project/api.ts
+++ b/frontend/src/entities/project/api.ts
@@ -3,6 +3,7 @@ import type { Paged, PageQuery } from '@/shared/api'
import type { CreateProjectInput, Project } from './types'
const SUPPORTED_IMAGE_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif'])
+const MAX_IMAGE_UPLOAD_BYTES = 10 * 1024 * 1024
/** 后端原始形状,只在本文件出现;出了这里全项目只认 Project。 */
interface ProjectDto {
@@ -95,6 +96,9 @@ export async function uploadImage(file: File): Promise {
if (!SUPPORTED_IMAGE_TYPES.has(file.type)) {
throw new ApiError('仅支持 jpg/png/webp/gif 图片', 400)
}
+ if (file.size > MAX_IMAGE_UPLOAD_BYTES) {
+ throw new ApiError(`文件超过大小上限(${MAX_IMAGE_UPLOAD_BYTES} 字节)`, 400)
+ }
const payload = await uploadFile<{ url: string }>('/upload/image', file)
return payload.url
From 6cf02334040a8321c51a5b66c8f61a1d70fcfa19 Mon Sep 17 00:00:00 2001
From: xyh202131 <246811510+xyh202131@users.noreply.github.com>
Date: Tue, 28 Jul 2026 12:24:11 +0800
Subject: [PATCH 21/21] fix(frontend): address review feedback
---
frontend-architecture-v3.md | 13 +-
frontend/.oxfmtrc.json | 9 +
frontend/API_CONTRACT.md | 100 +++-
frontend/MODULES.md | 19 +-
frontend/README.md | 5 +-
frontend/index.html | 1 -
frontend/package-lock.json | 504 +++++++++++++++++-
frontend/package.json | 11 +-
frontend/public/favicon.svg | 1 -
frontend/public/icons.svg | 24 -
frontend/src/app/asset-library-flow.test.tsx | 23 +
frontend/src/app/error-boundary.test.tsx | 43 +-
frontend/src/app/error-boundary.tsx | 13 +
frontend/src/app/index.tsx | 36 +-
frontend/src/app/layout/index.tsx | 8 +-
.../src/entities/action-template/index.ts | 19 +-
frontend/src/entities/character/index.ts | 19 +-
frontend/src/entities/character/types.ts | 30 +-
frontend/src/entities/index.ts | 13 +-
frontend/src/entities/project/api.test.ts | 11 +-
frontend/src/entities/project/api.ts | 41 +-
frontend/src/entities/project/index.ts | 11 +-
frontend/src/entities/project/types.ts | 33 +-
.../src/entities/public-contracts.test.ts | 61 +++
frontend/src/entities/wearable/index.ts | 4 +-
frontend/src/entities/workflow-run/api/dto.ts | 7 +-
frontend/src/entities/workflow-run/index.ts | 11 +-
.../workflow-run/model/selectors.test.ts | 9 +-
.../entities/workflow-run/model/selectors.ts | 12 +-
.../src/entities/workflow-run/model/task.ts | 14 +-
.../src/entities/workflow-run/model/types.ts | 8 +-
frontend/src/features/export/index.tsx | 2 +-
frontend/src/features/generation/index.tsx | 16 +-
frontend/src/pages/asset-library/index.tsx | 20 +-
frontend/src/pages/playtest/index.tsx | 16 +-
.../playtest/inspection-preview/index.tsx | 4 +-
frontend/src/pages/project-detail/index.tsx | 9 +-
frontend/src/pages/quick-start/index.tsx | 34 +-
.../workflow-editor/editor/index.test.tsx | 8 +-
.../pages/workflow-editor/editor/index.tsx | 16 +-
frontend/src/pages/workflow-editor/index.tsx | 13 +-
.../api/client/mock/project-handlers.ts | 4 +-
.../api/client/mock/workflow-run/machine.ts | 22 +-
.../api/client/mock/workflow-run/store.ts | 5 +-
frontend/src/shared/api/index.ts | 2 +-
frontend/src/shared/api/request.ts | 1 -
frontend/src/shared/api/stream.ts | 1 -
frontend/src/shared/api/upload.ts | 5 +-
.../src/shared/{lib => hooks}/async-state.ts | 0
frontend/src/shared/{lib => hooks}/index.ts | 1 -
frontend/tsconfig.json | 5 +-
frontend/vitest.config.ts | 7 +-
52 files changed, 1077 insertions(+), 227 deletions(-)
create mode 100644 frontend/.oxfmtrc.json
delete mode 100644 frontend/public/favicon.svg
delete mode 100644 frontend/public/icons.svg
create mode 100644 frontend/src/app/asset-library-flow.test.tsx
create mode 100644 frontend/src/entities/public-contracts.test.ts
rename frontend/src/shared/{lib => hooks}/async-state.ts (100%)
rename frontend/src/shared/{lib => hooks}/index.ts (71%)
diff --git a/frontend-architecture-v3.md b/frontend-architecture-v3.md
index 34d4c76..e2f8b5b 100644
--- a/frontend-architecture-v3.md
+++ b/frontend-architecture-v3.md
@@ -5,6 +5,7 @@
## 1. 技术边界
- 前端:React + Vite + TypeScript + Tailwind CSS。
+- 工具链:Oxlint 负责代码检查,Oxfmt 负责格式化;Tailwind 与 Vite 插件属于构建期开发依赖。
- 后端:Python;WorkflowRun、Provider Job、质量门禁和导出任务最终由后端保存或执行。开发和测试阶段经 Mock transport 验证接口契约,生产构建只使用真实 transport。
- 分层:app -> pages -> features -> entities -> shared。
- Quick Start 与手动 Workflow 是两种输入入口,最终进入同一套 WorkflowRun、Revision、生成、质检、历史、Playtest 和导出流程。
@@ -47,17 +48,17 @@ Account、Billing 和资产库复用 Feature 当前只在本文中预留,不
/quick-start/:runId Quick Start 简化创作台
/projects 项目列表
/projects/:projectId 项目详情
+/projects/:projectId/assets 项目资产库
/workflow-editor/:runId 当前 Revision 的工作流入口
/workflow-editor/:runId/:stage 当前 Revision 的指定节点
/playtest/:characterId 独立核验台
-/asset-library 资产库预留页面
~~~
/ 不再承担 Quick Start 具体业务;Quick Start 使用 /quick-start。项目详情保留当前的 /projects/:projectId,不改为 /project/:projectId。
Home 只提供 Quick Start 和从项目开始两个入口,不保存业务状态。Quick Start 负责自然语言输入和初始计划解析,创建与手动入口完全相同的 WorkflowRun 后停留在独立的简化创作台(/quick-start/:runId)。该页面使用自然语言展示生成、检查和结果,不展示五个节点、Revision、WorkflowRun 或 Workflow Editor;后台仍复用同一套领域状态。需要精细控制时才进入 Workflow Editor。
-ProjectsPage、ProjectDetailPage 和 AssetLibraryPage 当前直接使用 Entity;不提前创建 features/project 或 features/asset-library。出现复杂复用后再提取 Feature。
+ProjectsPage、ProjectDetailPage 和 AssetLibraryPage 当前直接使用 Entity;不提前创建 features/project 或 features/asset-library。Asset Library 以项目为上下文,展示项目 Character、项目 ActionTemplate、Wearable 以及系统内置 ActionTemplate。出现复杂复用后再提取 Feature。
## 5. Workflow Editor
@@ -179,10 +180,16 @@ shared/api/
- WorkflowRun、Revision、节点、命令和门禁归 entities/workflow-run。
- Project、Character、ActionTemplate、Wearable 归各自 Entity。
+- Task 快照包含 run、revision、状态、进度、错误和未冻结的 result;SSE 事件复用同一组状态字段。
+- Character 保留 variants 层;造型拥有各自的母版和 Action,MVP UI 只展示第一套造型。
+- Action 自身携带 fps;Frame 顺序由 Action.frames 数组表达,不重复保存 index。
+- Action 使用 sourceWorkflowRunId 回指 WorkflowRun;前端领域 ID 和枚举统一使用语义明确的字符串。
+- ActionTemplate 使用 system/project 作用域;系统模板不属于任何项目,项目资产页合并展示两类模板。
- URL、画布缩放、节点选中、资产筛选和当前审核位置归对应 Page。
- Generation、Review、Playtest 的局部交互状态归对应 Feature 或 Playtest Page。
- 不建立 Redux、Zustand 等全局业务 Store。
- 先保留 query key、query function、mutation 和 data/loading/error/refresh 语义,暂不绑定 React Query。
+- 跨 Entity 复用的异步 React 状态放在 shared/hooks,不使用含义宽泛的 shared/lib。
## 10. 目录增量规则
@@ -216,6 +223,8 @@ shared/api/
- Quick Start 创建统一 WorkflowRun 并进入独立的简化创作台;后台进入 generation,但页面不展示工作流内部结构。
- Workflow Editor 节点路由、历史 Revision URL 和重启交互。
- Playtest 的完整 Revision 导入门禁、核验结论记录和非阻断导出提示。
+- 项目资产库路由及系统/项目 ActionTemplate 作用域契约。
+- Task 快照、CharacterVariant、Action fps、Frame 数组顺序和角色母版的明确前端命名。
- 生产构建强制使用真实 API transport,业务层禁止直接 fetch。
仍待真实后端或业务实现:
diff --git a/frontend/.oxfmtrc.json b/frontend/.oxfmtrc.json
new file mode 100644
index 0000000..293ef6a
--- /dev/null
+++ b/frontend/.oxfmtrc.json
@@ -0,0 +1,9 @@
+{
+ "$schema": "./node_modules/oxfmt/configuration_schema.json",
+ "endOfLine": "lf",
+ "printWidth": 100,
+ "semi": false,
+ "singleQuote": true,
+ "trailingComma": "all",
+ "ignorePatterns": ["**/*.md"]
+}
diff --git a/frontend/API_CONTRACT.md b/frontend/API_CONTRACT.md
index 8dcad36..a418b3c 100644
--- a/frontend/API_CONTRACT.md
+++ b/frontend/API_CONTRACT.md
@@ -42,6 +42,17 @@
`user_id` 目前是演示值;接入认证后应由后端从令牌推导,前端不再传递它。
+后端当前用数字表达视角和移动方向,前端领域层统一使用字符串枚举,数字只在 Project mapper
+内转换:
+
+```ts
+type CharacterPerspective = 'side' | 'top-down' | 'isometric'
+type DirectionalMovement = 'single' | 'four-way' | 'eight-way'
+```
+
+`gameStyle` 是项目级画风约束,会进入本项目的生成上下文;`sampleImageUrl` 是项目级画风
+参考图,不是生成结果或角色母版。
+
### 参考图片上传
- `POST /upload/image`
@@ -86,9 +97,92 @@ const url = await uploadImage(file)
- `GET /characters/{id}`、`POST /characters`
- `GET /projects/{id}/characters`
-- `POST /characters/{id}/template/confirm`
-- `POST /characters/{id}/actions`、`POST /actions/{id}/confirm`
+- 造型母版确认、为造型添加动作、`POST /actions/{id}/confirm`(前两项路径待定)
- `GET /projects/{id}/action-templates`
- `GET /projects/{id}/wearables`
-SSE 也只有前端 transport 预留,尚无订阅地址、事件名称或字段契约。
+前端领域模型使用明确且互不冲突的名称:角色母版是 `baseImageUrl`,动作模板是
+`ActionTemplate`。`/characters/{id}/template/confirm` 是尚待后端确认的传输路径,不改变
+前端领域命名。
+
+```ts
+interface Frame {
+ imageUrl: string
+ qc: 'pending' | 'passed' | 'failed'
+ rejected: boolean
+}
+
+interface Action {
+ id: string
+ variantId: string
+ fps: number
+ frames: Frame[]
+ sourceWorkflowRunId: string | null
+}
+
+interface CharacterVariant {
+ id: string
+ characterId: string
+ name: string
+ baseImageUrl: string | null
+ actions: Action[]
+}
+
+interface Character {
+ id: string
+ projectId: string
+ name: string
+ variants: CharacterVariant[]
+}
+
+interface ActionTemplateBase {
+ id: string
+ name: string
+ previewImageUrl: string | null
+ frameCount: number
+ fps: number
+}
+
+type ActionTemplate = ActionTemplateBase &
+ (
+ | { scope: 'system'; projectId: null }
+ | { scope: 'project'; projectId: string }
+ )
+```
+
+即使 MVP UI 只展示一个造型,Character 也通过 `variants` 保留造型层,母版与动作归属具体
+CharacterVariant。`sourceWorkflowRunId` 明确引用 WorkflowRun,前端全部业务 ID 都是 string;
+后端数字 ID 只在 DTO mapper 中转换。
+
+`Action.frames` 的数组顺序就是播放顺序,因此 Frame 不重复携带 `index`。审核和页面定位可以
+临时使用 `frameIndex`;如果后端以后支持独立 Frame 资源,应另行提供稳定 ID。Action 的 `fps`
+由后端返回,预览和导出不得依赖前端全局常量。
+
+项目模板查询应返回“系统内置模板 + 当前项目自定义模板”的合集,并通过 `scope` 与
+`projectId` 区分归属。系统模板不虚构项目 ID。
+
+## 未对齐:异步任务与 SSE
+
+任务创建、查询和断线恢复需要完整快照;流式事件使用同一组状态字段:
+
+```ts
+type TaskStatus = 'queued' | 'running' | 'succeeded' | 'failed'
+
+interface Task {
+ id: string
+ runId: string
+ revisionId: string
+ status: TaskStatus
+ progress: number | null
+ error: string | null
+ result: unknown
+}
+
+interface TaskEvent extends Omit {
+ taskId: Task['id']
+}
+```
+
+`result` 在具体生成产物契约冻结前保持 `unknown`。SSE 当前只有 transport 预留,订阅地址、
+事件名称、断线重连、补发策略以及 Task 创建/查询接口均未与后端对齐,前端不得把它描述为
+已接通能力。
diff --git a/frontend/MODULES.md b/frontend/MODULES.md
index e85223b..e4201f1 100644
--- a/frontend/MODULES.md
+++ b/frontend/MODULES.md
@@ -28,6 +28,16 @@ WorkflowRun 的领域模型包含:
- 多个只读/当前 Revision。
- 当前先固定五个有序节点:asset、generation、candidate、review、export。
- 节点门禁、Revision 重启、历史查看和质量门禁 selector/command。
+- 创建、查询和流式事件共用字段的 Task 快照;具体 SSE 协议仍待后端冻结。
+
+Character 与动作资产的当前前端契约:
+
+- Character 始终包含 `variants`;MVP UI 可以只展示第一套造型,但不把造型层折叠掉。
+- 角色造型母版在前端称为 `baseImage`,避免与 `ActionTemplate` 混淆。
+- Action 自身携带 `fps`;预览和导出不使用全局帧率常量。
+- `Action.frames` 的数组顺序就是帧顺序,Frame 不重复保存 `index`。
+- `sourceWorkflowRunId` 引用 WorkflowRun;前端领域 ID 统一使用 string。
+- `ActionTemplate` 分为 `system` 和 `project` 作用域;系统模板的 `projectId` 为 `null`。
## 页面内模块
@@ -49,6 +59,13 @@ WorkflowRun 的领域模型包含:
Playtest 是独立核验台 Page,不是通用 Feature。它接收完整生成 Revision,保存独立核验结论,
问题可以回流 Review,但不会阻断导出。
+### Asset Library
+
+入口:`/projects/:projectId/assets`。
+
+页面以项目为上下文,展示当前项目的 Character、项目自定义 ActionTemplate 和 Wearable,
+同时展示可供该项目使用的系统内置 ActionTemplate。
+
## Features
Feature 表示用户操作,Feature 之间不互相 import。当前真实实现仍按功能增量推进;规划子目录使用
@@ -58,8 +75,8 @@ README 说明职责,未实现能力不得返回假成功。
- shared/api:传输、响应壳、错误、上传和流式任务。
- shared/api/generated:未来 OpenAPI 生成代码的接入位置,当前不放伪代码。
+- shared/hooks:跨 Entity 复用的 React hooks 和对应状态类型。
- shared/ui:业务无关 UI;只维护已经存在的组件。
-- shared/lib:通用工具和异步状态抽象。
- shared/testing:仅测试代码使用,生产代码不得导入。
## 测试
diff --git a/frontend/README.md b/frontend/README.md
index 23c6594..0bdc1ac 100644
--- a/frontend/README.md
+++ b/frontend/README.md
@@ -13,6 +13,8 @@ npm run typecheck
npm run build
npm run test
npm run lint
+npm run format
+npm run format:check
~~~
接口联调状态、请求形式与未接通能力见 [API_CONTRACT.md](API_CONTRACT.md)。
@@ -24,6 +26,7 @@ npm run lint
- /quick-start/:runId:Quick Start 的持续创作页;以自然语言展示生成、检查和结果状态
- /projects:项目列表
- /projects/:projectId:项目详情
+- /projects/:projectId/assets:项目资产库;展示项目资产及系统内置动作模板
- /workflow-editor/:runId:当前 Revision 的工作流入口
- /workflow-editor/:runId/:stage:当前 Revision 的工作流节点
- /playtest/:characterId?runId=:runId&revision=:revisionId:独立核验台
@@ -38,7 +41,7 @@ app -> pages -> features -> entities -> shared
- pages:路由、URL、页面临时状态和模块组合。
- features:生成、角色设置、审核和导出等用户操作。
- entities:Project、Character、WorkflowRun、Revision 和领域规则。
-- shared:通用 API transport、UI、工具和测试辅助。
+- shared:通用 API transport、React hooks、UI 和测试辅助。
跨模块只能走公开 index.ts;页面、Feature 和 Entity 不直接调用 fetch。
diff --git a/frontend/index.html b/frontend/index.html
index 04807c1..cd1df0c 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -2,7 +2,6 @@
-
Windup · 2D 角色资产生成
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 57c690e..d513d30 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -8,20 +8,21 @@
"name": "windup-frontend",
"version": "0.0.0",
"dependencies": {
- "@tailwindcss/vite": "^4.3.3",
"react": "^19.2.7",
"react-dom": "^19.2.7",
- "react-router": "^8.3.0",
- "tailwindcss": "^4.3.3"
+ "react-router": "^8.3.0"
},
"devDependencies": {
+ "@tailwindcss/vite": "^4.3.3",
"@testing-library/react": "^16.3.2",
"@types/node": "^24.13.2",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.3",
"jsdom": "^29.1.1",
+ "oxfmt": "^0.61.0",
"oxlint": "^1.71.0",
+ "tailwindcss": "^4.3.3",
"typescript": "~6.0.2",
"vite": "^8.1.1",
"vitest": "^4.1.10"
@@ -272,6 +273,7 @@
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
"integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
+ "dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -283,6 +285,7 @@
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
+ "dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -293,6 +296,7 @@
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
"integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
+ "dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -321,6 +325,7 @@
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0",
@@ -331,6 +336,7 @@
"version": "2.3.5",
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
@@ -341,6 +347,7 @@
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
@@ -350,12 +357,14 @@
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true,
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
@@ -366,6 +375,7 @@
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
"integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==",
+ "dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -384,11 +394,359 @@
"version": "0.139.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz",
"integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==",
+ "dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/Boshen"
}
},
+ "node_modules/@oxfmt/binding-android-arm-eabi": {
+ "version": "0.61.0",
+ "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.61.0.tgz",
+ "integrity": "sha512-BaS+1OVvg9sr+Xav0+KdWedQRcAzrdoEcwMZeqoc2F6ieC1s/t5eM35YQoRPQ7vAqkZ+p3tbQb1r9I9mrV5oGA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxfmt/binding-android-arm64": {
+ "version": "0.61.0",
+ "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm64/-/binding-android-arm64-0.61.0.tgz",
+ "integrity": "sha512-of8atAV0M1egGcVOMbgZCvc10sFOP3ayQBNQV5h5G3fNq8gACdEswfFk9bzGrdbM23rtg0Coxi7np7oPLcueNw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxfmt/binding-darwin-arm64": {
+ "version": "0.61.0",
+ "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-arm64/-/binding-darwin-arm64-0.61.0.tgz",
+ "integrity": "sha512-7l8+5ov4BGwtAcmpzvEik/TG3bciwyw/S3e6j5GKH7pcQqcgCVxD3AuJeP6upto+SOTBKQ4wrrdbMt0gq8fHSQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxfmt/binding-darwin-x64": {
+ "version": "0.61.0",
+ "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-x64/-/binding-darwin-x64-0.61.0.tgz",
+ "integrity": "sha512-Fnz4dDDXBb7udk+DmwelNjxbD6yptyxwCqwCH2ebo4RVLxVsRfFsn/AHJC49KIltPrVokamGv4SSOsiV50DTxQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxfmt/binding-freebsd-x64": {
+ "version": "0.61.0",
+ "resolved": "https://registry.npmjs.org/@oxfmt/binding-freebsd-x64/-/binding-freebsd-x64-0.61.0.tgz",
+ "integrity": "sha512-mddOebKNCP+AucmzfNsk3jgbr681qAUvgMqi865GW5gWLJ/AnzXbvjQRrny0e++NAN8aphav/aRSrfFxNsNjpA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxfmt/binding-linux-arm-gnueabihf": {
+ "version": "0.61.0",
+ "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.61.0.tgz",
+ "integrity": "sha512-svx59iYL+DbaZGZUIoice4W0CjRXGExnbz7Re+awIb60rVxBS2KrU7Hnlx+nZYanLGLpjneUEgo/VFEKkSZAyQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxfmt/binding-linux-arm-musleabihf": {
+ "version": "0.61.0",
+ "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.61.0.tgz",
+ "integrity": "sha512-BYK9MPJPCf6d+fLKMTruThmEyCtHzQ1zLcsrTlUVkmnoXIaHAbfpeLYQwX1tkjs7W11dyzoi6HFvKcdnvX1zNg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxfmt/binding-linux-arm64-gnu": {
+ "version": "0.61.0",
+ "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.61.0.tgz",
+ "integrity": "sha512-QUaCNLq2/EC6G5ljOuFanl9Lgw6ZWp4co7rs4+KOMUzbGfA4Lq58FHRjjF9sVIG+93XSbo343MxFATrOU1qctA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxfmt/binding-linux-arm64-musl": {
+ "version": "0.61.0",
+ "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.61.0.tgz",
+ "integrity": "sha512-S6uvJ6MXnRXl+zTs0CARNDvkE+cymj0EVWEKKsyKnlLlqTyQJBjw5s4D2pSIOZc+S46cy4STefzcr/sm0VzVPA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxfmt/binding-linux-ppc64-gnu": {
+ "version": "0.61.0",
+ "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.61.0.tgz",
+ "integrity": "sha512-6VDlRcytvZG6UlSIdAFKDLbppo9tvPxrWzle6vHldYFMeuDPQEfMKrkwezp7FaBq1wik9ra554ZZeRPsyIkFpg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxfmt/binding-linux-riscv64-gnu": {
+ "version": "0.61.0",
+ "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.61.0.tgz",
+ "integrity": "sha512-KkBTYbzExpbmn15XjKPLu2fRV2PVlq+KWt+brad5rwIa03vdYoaDRWiS7raHII/dCTR6Ro4UpYUCH4t6lif4WQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxfmt/binding-linux-riscv64-musl": {
+ "version": "0.61.0",
+ "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.61.0.tgz",
+ "integrity": "sha512-69tzIq7sJLVB9dxYYtvMzcSSsnZHSO+U2U19O2RqDqgj6+Q4O7HjSXdaszbcgqzhsUwzSH7z5kWvk8nmf6BHTg==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxfmt/binding-linux-s390x-gnu": {
+ "version": "0.61.0",
+ "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.61.0.tgz",
+ "integrity": "sha512-Oqi/N0OvtOVXsPKAOOhKgGH3msRYF8BLJaNBbWiupRiKoKVyc8JRKPCfarkQJC+RgP9U8raUKLe+bNwd0HUMiA==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxfmt/binding-linux-x64-gnu": {
+ "version": "0.61.0",
+ "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.61.0.tgz",
+ "integrity": "sha512-3TKwv/ed4uwJSemAA8P9XcoqETpjQI4waquF9UilhA9Mn/dhr1PdUEXWlL74mtc6ZNfmKPA9+NEJm01nRF8CVA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxfmt/binding-linux-x64-musl": {
+ "version": "0.61.0",
+ "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-musl/-/binding-linux-x64-musl-0.61.0.tgz",
+ "integrity": "sha512-uFso4u4nLkVSlMCpgjyvWV60Gt7GvDQHnk1mmRxHIkZTMB0ljpUKwCD9FYGgN9H97x2wYl0UwEjgRZaPIuhEhw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxfmt/binding-openharmony-arm64": {
+ "version": "0.61.0",
+ "resolved": "https://registry.npmjs.org/@oxfmt/binding-openharmony-arm64/-/binding-openharmony-arm64-0.61.0.tgz",
+ "integrity": "sha512-keGLkzeOvkMpNmPp4hffXWpfoSsY6e1K8++KXD4mSSfxdvM8q9QUDsYY689TB1k6Co832DZn1MnaaVx6cIBMWQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxfmt/binding-win32-arm64-msvc": {
+ "version": "0.61.0",
+ "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.61.0.tgz",
+ "integrity": "sha512-VzsAISkFxmNhJ5LBDEL9VuH6tJsVJMtqYit2LyIUf/HLnsCe4Pg9SMOjjVQzGWt0bnpyfJ94CrqTqcpNZzK+ug==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxfmt/binding-win32-ia32-msvc": {
+ "version": "0.61.0",
+ "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.61.0.tgz",
+ "integrity": "sha512-xv4t7yzwJoYaLB6Zv28B3W+j7brEjsyv50rLTAQgmxJzddce9fAMCxed8dSAkbWES0zz2J29nYK5FaTuD2YBHg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxfmt/binding-win32-x64-msvc": {
+ "version": "0.61.0",
+ "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.61.0.tgz",
+ "integrity": "sha512-6EZXFkqOwxdDYjIn3TSNnPk3ST5E5GiYd4FiM6UF/mCL/LZSfr6D6UygTfW3R1PCQP2quCKpCEGRlij8E3VYbg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
"node_modules/@oxlint/binding-android-arm-eabi": {
"version": "1.75.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.75.0.tgz",
@@ -743,6 +1101,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -759,6 +1118,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -775,6 +1135,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -791,6 +1152,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -807,6 +1169,7 @@
"cpu": [
"arm"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -823,6 +1186,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"libc": [
"glibc"
],
@@ -842,6 +1206,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"libc": [
"musl"
],
@@ -861,6 +1226,7 @@
"cpu": [
"ppc64"
],
+ "dev": true,
"libc": [
"glibc"
],
@@ -880,6 +1246,7 @@
"cpu": [
"s390x"
],
+ "dev": true,
"libc": [
"glibc"
],
@@ -899,6 +1266,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"libc": [
"glibc"
],
@@ -918,6 +1286,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"libc": [
"musl"
],
@@ -937,6 +1306,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -953,6 +1323,7 @@
"cpu": [
"wasm32"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -971,6 +1342,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -987,6 +1359,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1000,6 +1373,7 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
+ "dev": true,
"license": "MIT"
},
"node_modules/@standard-schema/spec": {
@@ -1013,6 +1387,7 @@
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz",
"integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/remapping": "^2.3.5",
@@ -1028,6 +1403,7 @@
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
+ "dev": true,
"license": "MPL-2.0",
"dependencies": {
"detect-libc": "^2.0.3"
@@ -1060,6 +1436,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -1080,6 +1457,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -1100,6 +1478,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -1120,6 +1499,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -1140,6 +1520,7 @@
"cpu": [
"arm"
],
+ "dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -1160,6 +1541,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"libc": [
"glibc"
],
@@ -1183,6 +1565,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"libc": [
"musl"
],
@@ -1206,6 +1589,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"libc": [
"glibc"
],
@@ -1229,6 +1613,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"libc": [
"musl"
],
@@ -1252,6 +1637,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -1272,6 +1658,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -1289,6 +1676,7 @@
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz",
"integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==",
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">= 20"
@@ -1315,6 +1703,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1331,6 +1720,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1347,6 +1737,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1363,6 +1754,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1379,6 +1771,7 @@
"cpu": [
"arm"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1395,6 +1788,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"libc": [
"glibc"
],
@@ -1414,6 +1808,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"libc": [
"musl"
],
@@ -1433,6 +1828,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"libc": [
"glibc"
],
@@ -1452,6 +1848,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"libc": [
"musl"
],
@@ -1479,6 +1876,7 @@
"cpu": [
"wasm32"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -1500,6 +1898,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1516,6 +1915,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1529,6 +1929,7 @@
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz",
"integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"@tailwindcss/node": "4.3.3",
@@ -1592,6 +1993,7 @@
"version": "0.10.3",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
"integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==",
+ "dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -1635,7 +2037,7 @@
"version": "24.13.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
"integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.18.0"
@@ -1936,6 +2338,7 @@
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=8"
@@ -1953,6 +2356,7 @@
"version": "5.24.3",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.3.tgz",
"integrity": "sha512-PwKooW9JUzh5chmYfHM3IQl5OkK2u2Nm011MgeZrss3JmFraUx/fqrf78kk8GUMYoibx/14MdwTl/1WKkG7TpQ==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.4",
@@ -2006,6 +2410,7 @@
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">=12.0.0"
@@ -2023,6 +2428,7 @@
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
@@ -2037,6 +2443,7 @@
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
+ "dev": true,
"license": "ISC"
},
"node_modules/html-encoding-sniffer": {
@@ -2063,6 +2470,7 @@
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
"integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
+ "dev": true,
"license": "MIT",
"bin": {
"jiti": "lib/jiti-cli.mjs"
@@ -2121,6 +2529,7 @@
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
"integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
+ "dev": true,
"license": "MPL-2.0",
"dependencies": {
"detect-libc": "^2.0.3"
@@ -2153,6 +2562,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -2173,6 +2583,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -2193,6 +2604,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -2213,6 +2625,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -2233,6 +2646,7 @@
"cpu": [
"arm"
],
+ "dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -2253,6 +2667,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"libc": [
"glibc"
],
@@ -2276,6 +2691,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"libc": [
"musl"
],
@@ -2299,6 +2715,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"libc": [
"glibc"
],
@@ -2322,6 +2739,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"libc": [
"musl"
],
@@ -2345,6 +2763,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -2365,6 +2784,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -2403,6 +2823,7 @@
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
@@ -2419,6 +2840,7 @@
"version": "3.3.16",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
+ "dev": true,
"funding": [
{
"type": "github",
@@ -2447,6 +2869,58 @@
"node": ">=12.20.0"
}
},
+ "node_modules/oxfmt": {
+ "version": "0.61.0",
+ "resolved": "https://registry.npmjs.org/oxfmt/-/oxfmt-0.61.0.tgz",
+ "integrity": "sha512-DxdHBEMYpcEnHoUHjjOigUqV2TYKsvxLwUPXnVYBjgFdqrcQ/91OtwubtZ2PUodCs3sStI8R5Qw3fKNGK4e8wQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinypool": "2.1.0"
+ },
+ "bin": {
+ "oxfmt": "bin/oxfmt"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/Boshen"
+ },
+ "optionalDependencies": {
+ "@oxfmt/binding-android-arm-eabi": "0.61.0",
+ "@oxfmt/binding-android-arm64": "0.61.0",
+ "@oxfmt/binding-darwin-arm64": "0.61.0",
+ "@oxfmt/binding-darwin-x64": "0.61.0",
+ "@oxfmt/binding-freebsd-x64": "0.61.0",
+ "@oxfmt/binding-linux-arm-gnueabihf": "0.61.0",
+ "@oxfmt/binding-linux-arm-musleabihf": "0.61.0",
+ "@oxfmt/binding-linux-arm64-gnu": "0.61.0",
+ "@oxfmt/binding-linux-arm64-musl": "0.61.0",
+ "@oxfmt/binding-linux-ppc64-gnu": "0.61.0",
+ "@oxfmt/binding-linux-riscv64-gnu": "0.61.0",
+ "@oxfmt/binding-linux-riscv64-musl": "0.61.0",
+ "@oxfmt/binding-linux-s390x-gnu": "0.61.0",
+ "@oxfmt/binding-linux-x64-gnu": "0.61.0",
+ "@oxfmt/binding-linux-x64-musl": "0.61.0",
+ "@oxfmt/binding-openharmony-arm64": "0.61.0",
+ "@oxfmt/binding-win32-arm64-msvc": "0.61.0",
+ "@oxfmt/binding-win32-ia32-msvc": "0.61.0",
+ "@oxfmt/binding-win32-x64-msvc": "0.61.0"
+ },
+ "peerDependencies": {
+ "svelte": "^5.0.0",
+ "vite-plus": "*"
+ },
+ "peerDependenciesMeta": {
+ "svelte": {
+ "optional": true
+ },
+ "vite-plus": {
+ "optional": true
+ }
+ }
+ },
"node_modules/oxlint": {
"version": "1.75.0",
"resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.75.0.tgz",
@@ -2520,12 +2994,14 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
"license": "ISC"
},
"node_modules/picomatch": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
@@ -2538,6 +3014,7 @@
"version": "8.5.23",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz",
"integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==",
+ "dev": true,
"funding": [
{
"type": "opencollective",
@@ -2652,6 +3129,7 @@
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz",
"integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"@oxc-project/types": "=0.139.0",
@@ -2711,6 +3189,7 @@
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
@@ -2741,12 +3220,14 @@
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz",
"integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==",
+ "dev": true,
"license": "MIT"
},
"node_modules/tapable": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz",
"integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==",
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -2777,6 +3258,7 @@
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
@@ -2789,6 +3271,16 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
+ "node_modules/tinypool": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-2.1.0.tgz",
+ "integrity": "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^20.0.0 || >=22.0.0"
+ }
+ },
"node_modules/tinyrainbow": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
@@ -2849,6 +3341,7 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "dev": true,
"license": "0BSD",
"optional": true
},
@@ -2880,13 +3373,14 @@
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
- "devOptional": true,
+ "dev": true,
"license": "MIT"
},
"node_modules/vite": {
"version": "8.1.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz",
"integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"lightningcss": "^1.32.0",
diff --git a/frontend/package.json b/frontend/package.json
index 3fb90bb..30b1852 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,31 +1,34 @@
{
"name": "windup-frontend",
- "private": true,
"version": "0.0.0",
+ "private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "oxlint",
+ "format": "oxfmt",
+ "format:check": "oxfmt --check",
"preview": "vite preview",
"test": "vitest run",
"typecheck": "tsc -b"
},
"dependencies": {
- "@tailwindcss/vite": "^4.3.3",
"react": "^19.2.7",
"react-dom": "^19.2.7",
- "react-router": "^8.3.0",
- "tailwindcss": "^4.3.3"
+ "react-router": "^8.3.0"
},
"devDependencies": {
+ "@tailwindcss/vite": "^4.3.3",
"@testing-library/react": "^16.3.2",
"@types/node": "^24.13.2",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.3",
"jsdom": "^29.1.1",
+ "oxfmt": "^0.61.0",
"oxlint": "^1.71.0",
+ "tailwindcss": "^4.3.3",
"typescript": "~6.0.2",
"vite": "^8.1.1",
"vitest": "^4.1.10"
diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg
deleted file mode 100644
index 6893eb1..0000000
--- a/frontend/public/favicon.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/frontend/public/icons.svg b/frontend/public/icons.svg
deleted file mode 100644
index e952219..0000000
--- a/frontend/public/icons.svg
+++ /dev/null
@@ -1,24 +0,0 @@
-
diff --git a/frontend/src/app/asset-library-flow.test.tsx b/frontend/src/app/asset-library-flow.test.tsx
new file mode 100644
index 0000000..be9b056
--- /dev/null
+++ b/frontend/src/app/asset-library-flow.test.tsx
@@ -0,0 +1,23 @@
+// @vitest-environment jsdom
+import { cleanup, render, screen } from '@testing-library/react'
+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+
+import { App } from './index'
+
+describe('AssetLibraryPage', () => {
+ beforeEach(() => {
+ localStorage.clear()
+ window.history.replaceState(null, '', '/projects/project-1/assets')
+ })
+
+ afterEach(() => {
+ cleanup()
+ })
+
+ it('从项目路径读取资产库所属项目', () => {
+ render()
+
+ expect(screen.getByRole('heading', { name: '资产库' })).toBeTruthy()
+ expect(screen.getByText('项目 project-1 内可复用的角色、动作与穿戴')).toBeTruthy()
+ })
+})
diff --git a/frontend/src/app/error-boundary.test.tsx b/frontend/src/app/error-boundary.test.tsx
index a9989b7..a3325fb 100644
--- a/frontend/src/app/error-boundary.test.tsx
+++ b/frontend/src/app/error-boundary.test.tsx
@@ -1,8 +1,9 @@
// @vitest-environment jsdom
-import { cleanup, render, screen } from '@testing-library/react'
+import { cleanup, fireEvent, render, screen } from '@testing-library/react'
+import { Link, MemoryRouter, Route, Routes } from 'react-router'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-import { ErrorBoundary } from './error-boundary'
+import { ErrorBoundary, RouteErrorBoundary } from './error-boundary'
/**
* 错误边界得真的兜得住才算数:这里让子组件抛异常,验证应用没有白屏、
@@ -43,4 +44,42 @@ describe('全局错误边界', () => {
expect(screen.getByText('一切正常')).toBeTruthy()
expect(screen.queryByText('这个页面出错了')).toBeNull()
})
+
+ it('路由位置变化后清除上一页的错误状态', () => {
+ const view = render(
+
+
+ ,
+ )
+ expect(screen.getByText('这个页面出错了')).toBeTruthy()
+
+ view.rerender(
+
+ 新页面正常
+ ,
+ )
+
+ expect(screen.getByText('新页面正常')).toBeTruthy()
+ expect(screen.queryByText('这个页面出错了')).toBeNull()
+ })
+
+ it('页面抛错后可以通过导航进入正常页面', async () => {
+ render(
+
+ 离开错误页
+
+
+ } />
+ 目标页面正常} />
+
+
+ ,
+ )
+
+ expect(screen.getByText('这个页面出错了')).toBeTruthy()
+ fireEvent.click(screen.getByRole('link', { name: '离开错误页' }))
+
+ expect(await screen.findByText('目标页面正常')).toBeTruthy()
+ expect(screen.queryByText('这个页面出错了')).toBeNull()
+ })
})
diff --git a/frontend/src/app/error-boundary.tsx b/frontend/src/app/error-boundary.tsx
index 9499ab5..99ee0f4 100644
--- a/frontend/src/app/error-boundary.tsx
+++ b/frontend/src/app/error-boundary.tsx
@@ -1,5 +1,6 @@
import { Component } from 'react'
import type { ErrorInfo, ReactNode } from 'react'
+import { useLocation } from 'react-router'
/**
* 全局错误边界。任何一页渲染时抛出的异常都在这里兜住,
@@ -10,6 +11,8 @@ import type { ErrorInfo, ReactNode } from 'react'
*/
interface ErrorBoundaryProps {
children: ReactNode
+ /** 路由位置变化时清除上一页的错误,避免 fallback 挡住新页面。 */
+ resetKey?: string
}
interface ErrorBoundaryState {
@@ -28,6 +31,10 @@ export class ErrorBoundary extends Component {
this.setState({ error: null })
}
@@ -51,3 +58,9 @@ export class ErrorBoundary extends Component{children}
+}
diff --git a/frontend/src/app/index.tsx b/frontend/src/app/index.tsx
index cc79a32..e12d84e 100644
--- a/frontend/src/app/index.tsx
+++ b/frontend/src/app/index.tsx
@@ -8,7 +8,7 @@ import { ProjectDetailPage } from '@/pages/project-detail'
import { ProjectsPage } from '@/pages/projects'
import { QuickStartPage } from '@/pages/quick-start'
import { WorkflowEditorPage } from '@/pages/workflow-editor'
-import { ErrorBoundary } from './error-boundary'
+import { RouteErrorBoundary } from './error-boundary'
import { AppShell } from './layout'
/** 路由表与全局外壳。app 层只做启动与装配。 */
@@ -16,21 +16,27 @@ export function App() {
return (
-
-
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
-
-
+
)
}
+
+function AppRoutes() {
+ return (
+
+
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+ )
+}
diff --git a/frontend/src/app/layout/index.tsx b/frontend/src/app/layout/index.tsx
index b9551df..9ba6c95 100644
--- a/frontend/src/app/layout/index.tsx
+++ b/frontend/src/app/layout/index.tsx
@@ -17,8 +17,12 @@ export function AppShell({ children }: AppShellProps) {
{children}
diff --git a/frontend/src/entities/action-template/index.ts b/frontend/src/entities/action-template/index.ts
index a9c76a9..4c1663d 100644
--- a/frontend/src/entities/action-template/index.ts
+++ b/frontend/src/entities/action-template/index.ts
@@ -1,27 +1,26 @@
-import { useAsync } from '@/shared/lib'
-import type { AsyncState } from '@/shared/lib'
+import { useAsync } from '@/shared/hooks'
+import type { AsyncState } from '@/shared/hooks'
-/**
- * 可复用的动作模板,项目下属而非全站总库。
- * 复用能力一期之后做,先占好数据入口。
- */
+/** 可复用的动作模板;查询项目可用模板时合并系统内置与项目自定义数据。 */
-export interface ActionTemplate {
+interface ActionTemplateBase {
id: string
- projectId: string
name: string
/** 通常取第一帧。 */
previewImageUrl: string | null
/** 由后端给出,前端不写死。 */
frameCount: number
- hasDisplacement: boolean
+ fps: number
}
+export type ActionTemplate = ActionTemplateBase &
+ ({ scope: 'system'; projectId: null } | { scope: 'project'; projectId: string })
+
export async function fetchActionTemplates(_projectId: string): Promise {
throw new Error('not implemented:等待后端 GET /projects/{id}/action-templates')
}
-/** 订阅动作模板列表。 */
+/** 订阅系统内置模板与当前项目自定义模板的合集。 */
export function useActionTemplates(projectId: string): AsyncState {
return useAsync(() => fetchActionTemplates(projectId), [projectId])
}
diff --git a/frontend/src/entities/character/index.ts b/frontend/src/entities/character/index.ts
index 7c11d68..eb47e26 100644
--- a/frontend/src/entities/character/index.ts
+++ b/frontend/src/entities/character/index.ts
@@ -1,6 +1,6 @@
-import { useAsync } from '@/shared/lib'
-import type { AsyncState } from '@/shared/lib'
-import type { Action, Character, CreateCharacterInput } from './types'
+import { useAsync } from '@/shared/hooks'
+import type { AsyncState } from '@/shared/hooks'
+import type { Action, Character, CharacterVariant, CreateCharacterInput } from './types'
/** 角色、动作、帧。后端接口未提供,下面的签名即我们提给后端的需求。 */
@@ -9,6 +9,7 @@ export type {
ActionKind,
ActionStatus,
Character,
+ CharacterVariant,
CreateCharacterInput,
Frame,
FrameQcResult,
@@ -24,22 +25,22 @@ export async function fetchCharactersByProject(_projectId: string): Promise {
throw new Error('not implemented:等待后端 POST /characters')
}
-/** 确认母版后才能加动作。 */
-export async function confirmTemplate(_characterId: string): Promise {
- throw new Error('not implemented:等待后端 POST /characters/{id}/template/confirm')
+/** 确认某套造型的母版后才能为该造型添加动作。 */
+export async function confirmBaseImage(_variantId: string): Promise {
+ throw new Error('not implemented:等待后端角色造型母版确认接口')
}
/** 加一个动作,此时还未生成。 */
export async function addAction(
- _characterId: string,
+ _variantId: string,
_input: { name: string; kind: 'preset' | 'custom'; templateId?: string },
): Promise {
- throw new Error('not implemented:等待后端 POST /characters/{id}/actions')
+ throw new Error('not implemented:等待后端角色造型动作接口')
}
/** 候选转正式。新候选不覆盖正式资产。 */
diff --git a/frontend/src/entities/character/types.ts b/frontend/src/entities/character/types.ts
index f18bc20..a238c8e 100644
--- a/frontend/src/entities/character/types.ts
+++ b/frontend/src/entities/character/types.ts
@@ -1,3 +1,5 @@
+import type { WorkflowRun } from '../workflow-run'
+
/** 角色、动作、帧。后端接口尚未提供,形状按界面需要先定,待与后端对齐。 */
/** 预设动作(行走/奔跑/跳跃/待机等 6–10 个)或自定义。 */
@@ -20,8 +22,6 @@ export type ActionStatus =
export type FrameQcResult = 'pending' | 'passed' | 'failed'
export interface Frame {
- /** 动作内序号。一个动作几帧由后端决定,前端不写死。 */
- index: number
imageUrl: string
qc: FrameQcResult
@@ -31,29 +31,37 @@ export interface Frame {
export interface Action {
id: string
- characterId: string
+ variantId: CharacterVariant['id']
name: string
kind: ActionKind
status: ActionStatus
- /** 播放时是否位移:跳跃有,待机、蹲下没有。由后端按动作标签给出。 */
- hasDisplacement: boolean
+ /** 每个动作明确携带帧率;预览和导出不得使用前端全局常量。 */
+ fps: number
frames: Frame[]
/**
- * 生成它的工作流 id,审核台退回单帧后据此跳回编辑器定位。
+ * 生成它的 WorkflowRun id,审核台退回单帧后据此跳回编辑器定位。
* 类型必须与 WorkflowRun.id 一致,否则这条恢复链在类型上就是断的。
*/
- sourceWorkflowId: string | null
+ sourceWorkflowRunId: WorkflowRun['id'] | null
}
-/** 项目下的核心资产,带一整套动作。 */
+/** 同一角色的一套独立造型;MVP UI 只展示第一套,但数据结构不折叠该层。 */
+export interface CharacterVariant {
+ id: string
+ characterId: string
+ name: string
+ /** 母版图,确认后作为该造型后续动作的一致性基准。 */
+ baseImageUrl: string | null
+ actions: Action[]
+}
+
+/** 项目下的角色资产;造型拥有各自的母版和动作帧。 */
export interface Character {
id: string
projectId: string
name: string
- /** 母版图,确认后作为后续动作的一致性基准。 */
- templateImageUrl: string | null
- actions: Action[]
+ variants: CharacterVariant[]
createdAt: string
updatedAt: string
}
diff --git a/frontend/src/entities/index.ts b/frontend/src/entities/index.ts
index 09751ae..09eb500 100644
--- a/frontend/src/entities/index.ts
+++ b/frontend/src/entities/index.ts
@@ -16,13 +16,18 @@ export {
useProject,
useProjects,
} from './project'
-export type { CreateProjectInput, Project } from './project'
+export type {
+ CharacterPerspective,
+ CreateProjectInput,
+ DirectionalMovement,
+ Project,
+} from './project'
/* 角色 / 动作 / 帧 —— 提案,待与后端 review */
export {
addAction,
confirmAction,
- confirmTemplate,
+ confirmBaseImage,
createCharacter,
fetchCharacter,
fetchCharactersByProject,
@@ -34,6 +39,7 @@ export type {
ActionKind,
ActionStatus,
Character,
+ CharacterVariant,
CreateCharacterInput,
Frame,
FrameQcResult,
@@ -67,6 +73,9 @@ export type {
ExportStatus,
GenerationStatus,
PlaytestStatus,
+ Task,
+ TaskEvent,
+ TaskStatus,
WorkflowCommand,
WorkflowCommandKind,
WorkflowDriver,
diff --git a/frontend/src/entities/project/api.test.ts b/frontend/src/entities/project/api.test.ts
index 5133c29..cea752b 100644
--- a/frontend/src/entities/project/api.test.ts
+++ b/frontend/src/entities/project/api.test.ts
@@ -1,7 +1,7 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
-import { uploadImage } from './api'
+import { fetchProject, uploadImage } from './api'
afterEach(() => {
vi.unstubAllGlobals()
@@ -19,3 +19,12 @@ describe('uploadImage', () => {
expect(fetchMock).not.toHaveBeenCalled()
})
})
+
+describe('Project DTO mapper', () => {
+ it('把后端数字枚举转换成前端字符串领域值', async () => {
+ const project = await fetchProject('1')
+
+ expect(project.perspective).toBe('side')
+ expect(project.directionalMovement).toBe('four-way')
+ })
+})
diff --git a/frontend/src/entities/project/api.ts b/frontend/src/entities/project/api.ts
index 9fe023a..931e7f3 100644
--- a/frontend/src/entities/project/api.ts
+++ b/frontend/src/entities/project/api.ts
@@ -1,6 +1,11 @@
import { ApiError, request, requestList, uploadFile } from '@/shared/api'
import type { Paged, PageQuery } from '@/shared/api'
-import type { CreateProjectInput, Project } from './types'
+import type {
+ CharacterPerspective,
+ CreateProjectInput,
+ DirectionalMovement,
+ Project,
+} from './types'
const SUPPORTED_IMAGE_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif'])
const MAX_IMAGE_UPLOAD_BYTES = 10 * 1024 * 1024
@@ -24,15 +29,41 @@ interface ProjectDto {
/** TODO(对后端):user_id 应由后端从 token 推出,不该前端传。暂时写死。 */
const CURRENT_USER_ID = 1
+const PERSPECTIVE_FROM_DTO: Record = {
+ 1: 'side',
+ 2: 'top-down',
+ 3: 'isometric',
+}
+const PERSPECTIVE_TO_DTO: Record = {
+ side: 1,
+ 'top-down': 2,
+ isometric: 3,
+}
+const MOVEMENT_FROM_DTO: Record = {
+ 1: 'single',
+ 2: 'four-way',
+ 3: 'eight-way',
+}
+const MOVEMENT_TO_DTO: Record = {
+ single: 1,
+ 'four-way': 2,
+ 'eight-way': 3,
+}
+
/** 后端形状 → 业务对象。 */
function toProject(dto: ProjectDto): Project {
+ const perspective = PERSPECTIVE_FROM_DTO[dto.character_perspective]
+ const directionalMovement = MOVEMENT_FROM_DTO[dto.directional_movement]
+ if (!perspective || !directionalMovement) {
+ throw new Error(`项目 ${dto.id} 返回了未知的视角或移动方向`)
+ }
return {
id: String(dto.id),
ownerId: String(dto.user_id),
workflowId: dto.workflow_id === null ? null : String(dto.workflow_id),
name: dto.project_name,
- perspective: dto.character_perspective,
- directionalMovement: dto.directional_movement,
+ perspective,
+ directionalMovement,
spriteSize: { width: dto.sprite_width, height: dto.sprite_height },
gameStyle: dto.game_style,
sampleImageUrl: dto.sprite_sample_url,
@@ -68,8 +99,8 @@ export async function createProject(input: CreateProjectInput): Promise
user_id: CURRENT_USER_ID,
workflow_id: null,
project_name: input.name,
- character_perspective: input.perspective,
- directional_movement: input.directionalMovement,
+ character_perspective: PERSPECTIVE_TO_DTO[input.perspective],
+ directional_movement: MOVEMENT_TO_DTO[input.directionalMovement],
sprite_width: input.spriteSize.width,
sprite_height: input.spriteSize.height,
game_style: input.gameStyle ?? null,
diff --git a/frontend/src/entities/project/index.ts b/frontend/src/entities/project/index.ts
index cf8de2c..5a4b052 100644
--- a/frontend/src/entities/project/index.ts
+++ b/frontend/src/entities/project/index.ts
@@ -1,5 +1,5 @@
-import { useAsync } from '@/shared/lib'
-import type { AsyncState } from '@/shared/lib'
+import { useAsync } from '@/shared/hooks'
+import type { AsyncState } from '@/shared/hooks'
import type { Paged, PageQuery } from '@/shared/api'
import { fetchProject, fetchProjects } from './api'
import type { Project } from './types'
@@ -8,7 +8,12 @@ import type { Project } from './types'
export { createProject, deleteProject, fetchProject, fetchProjects, uploadImage } from './api'
export { CHARACTER_PERSPECTIVE, DIRECTIONAL_MOVEMENT, SPRITE_SIZES } from './types'
-export type { CreateProjectInput, Project } from './types'
+export type {
+ CharacterPerspective,
+ CreateProjectInput,
+ DirectionalMovement,
+ Project,
+} from './types'
/** 订阅项目列表。 */
export function useProjects(query: PageQuery = {}): AsyncState> {
diff --git a/frontend/src/entities/project/types.ts b/frontend/src/entities/project/types.ts
index b4764e5..ef630aa 100644
--- a/frontend/src/entities/project/types.ts
+++ b/frontend/src/entities/project/types.ts
@@ -11,14 +11,14 @@ export interface Project {
/** 后端限制 1–20 字符,同一用户下不可重名。 */
name: string
/** 游戏视角,见 CHARACTER_PERSPECTIVE。 */
- perspective: number
+ perspective: CharacterPerspective
/** 移动方向,见 DIRECTIONAL_MOVEMENT。 */
- directionalMovement: number
+ directionalMovement: DirectionalMovement
/** 后端校验 32–2048,实际取 SPRITE_SIZES 里的档位。 */
spriteSize: { width: number; height: number }
- /** 会进生成提示词。 */
+ /** 项目级画风描述,会作为本项目所有角色与动作生成的视觉约束。 */
gameStyle: string | null
- /** 由 POST /upload/image 上传后拿到。 */
+ /** 项目级画风参考图;不是生成结果或角色母版,URL 来自 POST /upload/image。 */
sampleImageUrl: string | null
createdAt: string
updatedAt: string
@@ -27,25 +27,28 @@ export interface Project {
/** 新建项目的入参。 */
export interface CreateProjectInput {
name: string
- perspective: number
- directionalMovement: number
+ perspective: CharacterPerspective
+ directionalMovement: DirectionalMovement
spriteSize: { width: number; height: number }
gameStyle?: string | null
sampleImageUrl?: string | null
}
-/** 游戏视角。取值与含义来自 windup_project 表注释(2026-07-27 后端接口文档)。 */
-export const CHARACTER_PERSPECTIVE: Record = {
- 1: '横版视角',
- 2: '俯视',
- 3: '2.5D',
+export type CharacterPerspective = 'side' | 'top-down' | 'isometric'
+export type DirectionalMovement = 'single' | 'four-way' | 'eight-way'
+
+/** 游戏视角。DTO 数字值只在 api mapper 内存在。 */
+export const CHARACTER_PERSPECTIVE: Record = {
+ side: '横版视角',
+ 'top-down': '俯视',
+ isometric: '2.5D',
}
/** 移动方向,决定一个动作要生成几套朝向的帧。 */
-export const DIRECTIONAL_MOVEMENT: Record = {
- 1: '单向',
- 2: '四向',
- 3: '八向',
+export const DIRECTIONAL_MOVEMENT: Record = {
+ single: '单向',
+ 'four-way': '四向',
+ 'eight-way': '八向',
}
/**
diff --git a/frontend/src/entities/public-contracts.test.ts b/frontend/src/entities/public-contracts.test.ts
new file mode 100644
index 0000000..8b84a33
--- /dev/null
+++ b/frontend/src/entities/public-contracts.test.ts
@@ -0,0 +1,61 @@
+import { describe, expectTypeOf, it } from 'vitest'
+
+import { confirmBaseImage } from './index'
+import type {
+ Action,
+ ActionTemplate,
+ Character,
+ CharacterPerspective,
+ CharacterVariant,
+ DirectionalMovement,
+ Frame,
+ Project,
+ Task,
+ WorkflowRun,
+} from './index'
+
+describe('entities 公开契约', () => {
+ it('动作携带播放帧率,Frame 的顺序由数组表达', () => {
+ expectTypeOf().toHaveProperty('fps').toBeNumber()
+ expectTypeOf().not.toHaveProperty('hasDisplacement')
+ expectTypeOf().not.toHaveProperty('index')
+ })
+
+ it('角色母版与动作模板使用不同概念', () => {
+ expectTypeOf().toHaveProperty('variants').toEqualTypeOf()
+ expectTypeOf().not.toHaveProperty('baseImageUrl')
+ expectTypeOf().toHaveProperty('baseImageUrl').toEqualTypeOf()
+ expectTypeOf(confirmBaseImage).toEqualTypeOf<(variantId: string) => Promise>()
+ expectTypeOf().toHaveProperty('variantId').toBeString()
+ expectTypeOf()
+ .toHaveProperty('sourceWorkflowRunId')
+ .toEqualTypeOf()
+ expectTypeOf().not.toHaveProperty('sourceWorkflowId')
+ })
+
+ it('系统模板与项目模板通过作用域区分归属', () => {
+ type SystemTemplate = Extract
+ type ProjectTemplate = Extract
+
+ expectTypeOf().toHaveProperty('projectId').toEqualTypeOf()
+ expectTypeOf().toHaveProperty('projectId').toBeString()
+ expectTypeOf().not.toHaveProperty('hasDisplacement')
+ })
+
+ it('异步任务提供可查询和恢复的完整快照', () => {
+ expectTypeOf().toHaveProperty('id').toBeString()
+ expectTypeOf().toHaveProperty('runId').toBeString()
+ expectTypeOf().toHaveProperty('revisionId').toBeString()
+ expectTypeOf().toHaveProperty('progress').toEqualTypeOf()
+ expectTypeOf().toHaveProperty('result').toBeUnknown()
+ })
+
+ it('Project 在领域层使用字符串枚举,数字只保留在 DTO', () => {
+ expectTypeOf().toEqualTypeOf<'side' | 'top-down' | 'isometric'>()
+ expectTypeOf().toEqualTypeOf<'single' | 'four-way' | 'eight-way'>()
+ expectTypeOf().toHaveProperty('perspective').toEqualTypeOf()
+ expectTypeOf()
+ .toHaveProperty('directionalMovement')
+ .toEqualTypeOf()
+ })
+})
diff --git a/frontend/src/entities/wearable/index.ts b/frontend/src/entities/wearable/index.ts
index 4766659..7e77d48 100644
--- a/frontend/src/entities/wearable/index.ts
+++ b/frontend/src/entities/wearable/index.ts
@@ -1,5 +1,5 @@
-import { useAsync } from '@/shared/lib'
-import type { AsyncState } from '@/shared/lib'
+import { useAsync } from '@/shared/hooks'
+import type { AsyncState } from '@/shared/hooks'
/** 可复用的穿戴资产,与 action-template 同属项目资产库,先占好入口。 */
diff --git a/frontend/src/entities/workflow-run/api/dto.ts b/frontend/src/entities/workflow-run/api/dto.ts
index c1d926c..8f3b56e 100644
--- a/frontend/src/entities/workflow-run/api/dto.ts
+++ b/frontend/src/entities/workflow-run/api/dto.ts
@@ -1,9 +1,4 @@
-import type {
- WorkflowCommand,
- WorkflowNode,
- WorkflowRevision,
- WorkflowRun,
-} from '../model/types'
+import type { WorkflowCommand, WorkflowNode, WorkflowRevision, WorkflowRun } from '../model/types'
/** 后端原始形状,只在 api/ 内出现;出了这里全项目只认 WorkflowRun。 */
export interface WorkflowNodeDto {
diff --git a/frontend/src/entities/workflow-run/index.ts b/frontend/src/entities/workflow-run/index.ts
index ca8cb80..d63513d 100644
--- a/frontend/src/entities/workflow-run/index.ts
+++ b/frontend/src/entities/workflow-run/index.ts
@@ -1,5 +1,5 @@
-import { useAsync } from '@/shared/lib'
-import type { AsyncState } from '@/shared/lib'
+import { useAsync } from '@/shared/hooks'
+import type { AsyncState } from '@/shared/hooks'
import { fetchWorkflowRun } from './api/get-workflow-run'
import type { TaskEvent } from './model/task'
import type { WorkflowRun } from './model/types'
@@ -24,7 +24,7 @@ export {
nextNodeType,
} from './model/selectors'
export { WORKFLOW_NODE_ORDER } from './model/types'
-export type { TaskEvent, TaskStatus } from './model/task'
+export type { Task, TaskEvent, TaskStatus } from './model/task'
export type {
CreateWorkflowRunInput,
ExportStatus,
@@ -44,10 +44,7 @@ export type {
} from './model/types'
/** 任务流协议尚未冻结,调用会明确失败而不是伪造进度。 */
-export function subscribeTask(
- _taskId: string,
- _onEvent: (event: TaskEvent) => void,
-): () => void {
+export function subscribeTask(_taskId: string, _onEvent: (event: TaskEvent) => void): () => void {
throw new Error('subscribeTask 的 SSE 协议尚未与后端确定,暂不可调用')
}
diff --git a/frontend/src/entities/workflow-run/model/selectors.test.ts b/frontend/src/entities/workflow-run/model/selectors.test.ts
index f02201b..ce4aca5 100644
--- a/frontend/src/entities/workflow-run/model/selectors.test.ts
+++ b/frontend/src/entities/workflow-run/model/selectors.test.ts
@@ -47,7 +47,10 @@ function revision(overrides: Partial = {}): WorkflowRevision {
}
}
-function run(current: WorkflowRevision = revision(), history: WorkflowRevision[] = []): WorkflowRun {
+function run(
+ current: WorkflowRevision = revision(),
+ history: WorkflowRevision[] = [],
+): WorkflowRun {
return {
id: 'run-1',
projectId: 'project-1',
@@ -98,9 +101,7 @@ describe('WorkflowRun selectors', () => {
passed: true,
}),
).toBe(true)
- expect(
- canSubmitCommand(value, { kind: 'complete-node', nodeId: 'candidate-1' }),
- ).toBe(false)
+ expect(canSubmitCommand(value, { kind: 'complete-node', nodeId: 'candidate-1' })).toBe(false)
})
it('只有系统质检通过的版本可以导入核验台', () => {
diff --git a/frontend/src/entities/workflow-run/model/selectors.ts b/frontend/src/entities/workflow-run/model/selectors.ts
index 7f2087c..9250172 100644
--- a/frontend/src/entities/workflow-run/model/selectors.ts
+++ b/frontend/src/entities/workflow-run/model/selectors.ts
@@ -21,11 +21,7 @@ export function listRevisionHistory(run: WorkflowRun): WorkflowRevision[] {
return [...run.revisions].sort((left, right) => right.createdAt.localeCompare(left.createdAt))
}
-export function getNode(
- run: WorkflowRun,
- revisionId: string,
- nodeId: string,
-): WorkflowNode | null {
+export function getNode(run: WorkflowRun, revisionId: string, nodeId: string): WorkflowNode | null {
return getRevision(run, revisionId)?.nodes.find((node) => node.id === nodeId) ?? null
}
@@ -57,11 +53,7 @@ export function canEnterNode(
return node?.status === 'active' || node?.status === 'passed' || node?.status === 'failed'
}
-export function canRestartFromNode(
- run: WorkflowRun,
- revisionId: string,
- nodeId: string,
-): boolean {
+export function canRestartFromNode(run: WorkflowRun, revisionId: string, nodeId: string): boolean {
const revision = getRevision(run, revisionId)
const node = revision?.nodes.find((item) => item.id === nodeId)
return Boolean(revision && node && node.status !== 'locked' && node.status !== 'available')
diff --git a/frontend/src/entities/workflow-run/model/task.ts b/frontend/src/entities/workflow-run/model/task.ts
index 58d787b..9619d6f 100644
--- a/frontend/src/entities/workflow-run/model/task.ts
+++ b/frontend/src/entities/workflow-run/model/task.ts
@@ -1,3 +1,5 @@
+import type { WorkflowRevision, WorkflowRun } from './types'
+
/**
* 异步任务契约,与 WorkflowRun 节点是两回事。
*
@@ -11,8 +13,11 @@
*/
export type TaskStatus = 'queued' | 'running' | 'succeeded' | 'failed'
-export interface TaskEvent {
- taskId: string
+/** 创建、查询和断线恢复都使用的完整任务快照。 */
+export interface Task {
+ id: string
+ runId: WorkflowRun['id']
+ revisionId: WorkflowRevision['id']
status: TaskStatus
/** 0–100。后端给不出就是 null,界面显示不确定进度。 */
progress: number | null
@@ -24,3 +29,8 @@ export interface TaskEvent {
*/
result: unknown
}
+
+/** SSE 每条事件携带完整状态,taskId 对应 Task.id。 */
+export interface TaskEvent extends Omit {
+ taskId: Task['id']
+}
diff --git a/frontend/src/entities/workflow-run/model/types.ts b/frontend/src/entities/workflow-run/model/types.ts
index ed0a487..6db6cbe 100644
--- a/frontend/src/entities/workflow-run/model/types.ts
+++ b/frontend/src/entities/workflow-run/model/types.ts
@@ -1,13 +1,7 @@
/** Quick Start 与手动工作流只改变输入方式,共用同一种运行模型。 */
export type WorkflowDriver = 'ai' | 'manual'
-export const WORKFLOW_NODE_ORDER = [
- 'asset',
- 'generation',
- 'candidate',
- 'review',
- 'export',
-] as const
+export const WORKFLOW_NODE_ORDER = ['asset', 'generation', 'candidate', 'review', 'export'] as const
export type WorkflowNodeType = (typeof WORKFLOW_NODE_ORDER)[number]
export type WorkflowNodeStatus = 'locked' | 'available' | 'active' | 'passed' | 'failed'
diff --git a/frontend/src/features/export/index.tsx b/frontend/src/features/export/index.tsx
index 273cf84..008c912 100644
--- a/frontend/src/features/export/index.tsx
+++ b/frontend/src/features/export/index.tsx
@@ -1,5 +1,5 @@
/**
- * 选择内容、发起导出和下载。下载属于本 feature,不放 shared/lib。
+ * 选择内容、发起导出和下载。下载属于本 feature,不放 shared/hooks。
*/
export interface ExportProps {
characterId: string
diff --git a/frontend/src/features/generation/index.tsx b/frontend/src/features/generation/index.tsx
index ef1d869..aecc6ec 100644
--- a/frontend/src/features/generation/index.tsx
+++ b/frontend/src/features/generation/index.tsx
@@ -1,6 +1,11 @@
import type { ProviderSessionStatus } from './provider-session'
-export type { ProviderCredentialMode, ProviderDescriptor, ProviderSession, ProviderSessionStatus } from './provider-session'
+export type {
+ ProviderCredentialMode,
+ ProviderDescriptor,
+ ProviderSession,
+ ProviderSessionStatus,
+} from './provider-session'
/** Generation 只展示任务和 Provider 状态;真实连接由后端契约接入。 */
export interface GenerationProps {
@@ -21,9 +26,14 @@ export function Generation({ runId, actionId, providerStatus = 'unconfigured' }:
return (
AI 生成
- 工作流 {runId}{actionId ? ` · 动作 ${actionId}` : ''}
+
+ 工作流 {runId}
+ {actionId ? ` · 动作 ${actionId}` : ''}
+
{message}
- 真实 Provider、Job 和增量产物由后端接口接入;当前不会模拟生成成功。
+
+ 真实 Provider、Job 和增量产物由后端接口接入;当前不会模拟生成成功。
+
)
}
diff --git a/frontend/src/pages/asset-library/index.tsx b/frontend/src/pages/asset-library/index.tsx
index 9a2d117..78374a3 100644
--- a/frontend/src/pages/asset-library/index.tsx
+++ b/frontend/src/pages/asset-library/index.tsx
@@ -1,32 +1,20 @@
-import { useSearchParams } from 'react-router'
+import { useParams } from 'react-router'
import { CharacterSetup } from '@/features/character-setup'
import { PageHeader } from '@/shared/ui'
/**
- * 资产库是项目下属的,不是全站总库,所以必须带 projectId 进来。
+ * 资产库以项目为上下文;系统内置动作模板作为当前项目可用资源一并展示。
* 按角色/视角/动作浏览,「继续补充动作」复用 CharacterSetup。
*/
export function AssetLibraryPage() {
- const [search] = useSearchParams()
- const projectId = search.get('projectId') ?? ''
-
- if (!projectId) {
- return (
- <>
-
-
- 资产库属于某个项目,请先从项目页进入(/asset-library?projectId=…)。
-
- >
- )
- }
+ const { projectId = '' } = useParams()
return (
<>
- 待实现:读 Character / ActionTemplate / Wearable 三个列表并筛选。
+ 待实现:读取当前项目的 Character、ActionTemplate、Wearable,以及系统内置 ActionTemplate。
>
diff --git a/frontend/src/pages/playtest/index.tsx b/frontend/src/pages/playtest/index.tsx
index bfe4152..3df50fe 100644
--- a/frontend/src/pages/playtest/index.tsx
+++ b/frontend/src/pages/playtest/index.tsx
@@ -1,11 +1,6 @@
import { useNavigate, useParams, useSearchParams } from 'react-router'
-import {
- canImportToPlaytest,
- getRevision,
- submitWorkflowCommand,
- useWorkflowRun,
-} from '@/entities'
+import { canImportToPlaytest, getRevision, submitWorkflowCommand, useWorkflowRun } from '@/entities'
import { PageHeader } from '@/shared/ui'
import { InspectionPreview } from './inspection-preview'
@@ -18,7 +13,11 @@ export function PlaytestPage() {
return (
<>
- navigate(-1)} />
+ navigate(-1)}
+ />
{!characterId || !runId || !revisionId ? (
缺少角色、工作流或版本参数,无法确定核验来源。
) : (
@@ -53,7 +52,8 @@ function PlaytestSession({
if (query.loading) return 加载核验版本…
if (query.error) return 加载失败:{query.error.message}
- if (!query.data || !revision) return 指定的历史版本不存在。
+ if (!query.data || !revision)
+ return 指定的历史版本不存在。
if (!canImportToPlaytest(query.data, revision.id)) {
return 该版本尚未通过系统质检,不能导入核验台。
}
diff --git a/frontend/src/pages/playtest/inspection-preview/index.tsx b/frontend/src/pages/playtest/inspection-preview/index.tsx
index 7697fbd..6e37049 100644
--- a/frontend/src/pages/playtest/inspection-preview/index.tsx
+++ b/frontend/src/pages/playtest/inspection-preview/index.tsx
@@ -22,7 +22,9 @@ export function InspectionPreview({
角色 {characterId}
- 来源 {runId} / {revisionId}
+
+ 来源 {runId} / {revisionId}
+
播放器、按键绑定和动作状态机等待真实角色资产与渲染实现接入。
diff --git a/frontend/src/pages/project-detail/index.tsx b/frontend/src/pages/project-detail/index.tsx
index b32243a..f57fe19 100644
--- a/frontend/src/pages/project-detail/index.tsx
+++ b/frontend/src/pages/project-detail/index.tsx
@@ -1,6 +1,11 @@
import { useNavigate, useParams } from 'react-router'
-import { CHARACTER_PERSPECTIVE, DIRECTIONAL_MOVEMENT, createWorkflowRun, useProject } from '@/entities'
+import {
+ CHARACTER_PERSPECTIVE,
+ DIRECTIONAL_MOVEMENT,
+ createWorkflowRun,
+ useProject,
+} from '@/entities'
import { PageHeader } from '@/shared/ui'
/**
@@ -56,7 +61,7 @@ export function ProjectDetailPage() {
diff --git a/frontend/src/pages/workflow-editor/editor/index.test.tsx b/frontend/src/pages/workflow-editor/editor/index.test.tsx
index de99802..9aeeda3 100644
--- a/frontend/src/pages/workflow-editor/editor/index.test.tsx
+++ b/frontend/src/pages/workflow-editor/editor/index.test.tsx
@@ -25,8 +25,12 @@ describe('WorkflowEditor', () => {
/>,
)
- expect((screen.getByRole('button', { name: /资产设置/ }) as HTMLButtonElement).disabled).toBe(false)
- expect((screen.getByRole('button', { name: /AI 生成/ }) as HTMLButtonElement).disabled).toBe(true)
+ expect((screen.getByRole('button', { name: /资产设置/ }) as HTMLButtonElement).disabled).toBe(
+ false,
+ )
+ expect((screen.getByRole('button', { name: /AI 生成/ }) as HTMLButtonElement).disabled).toBe(
+ true,
+ )
expect((screen.getByRole('button', { name: /导出/ }) as HTMLButtonElement).disabled).toBe(true)
expect(screen.getByText(/当前版本/)).toBeTruthy()
})
diff --git a/frontend/src/pages/workflow-editor/editor/index.tsx b/frontend/src/pages/workflow-editor/editor/index.tsx
index 354a7db..4c8f823 100644
--- a/frontend/src/pages/workflow-editor/editor/index.tsx
+++ b/frontend/src/pages/workflow-editor/editor/index.tsx
@@ -6,11 +6,7 @@ import {
canRestartFromNode,
getNodeByType,
} from '@/entities'
-import type {
- WorkflowNodeType,
- WorkflowRevision,
- WorkflowRun,
-} from '@/entities'
+import type { WorkflowNodeType, WorkflowRevision, WorkflowRun } from '@/entities'
import { CharacterSetup } from '@/features/character-setup'
import { Export } from '@/features/export'
import { Generation } from '@/features/generation'
@@ -48,9 +44,7 @@ export function WorkflowEditor({
}: WorkflowEditorProps) {
const [restarting, setRestarting] = useState(false)
const activeNode = getNodeByType(revision, activeType)
- const canRestart = activeNode
- ? canRestartFromNode(run, revision.id, activeNode.id)
- : false
+ const canRestart = activeNode ? canRestartFromNode(run, revision.id, activeNode.id) : false
async function restart() {
if (!activeNode || restarting) return
@@ -184,7 +178,11 @@ function StageContent({
return (
- {run.characterId ?
:
}
+ {run.characterId ? (
+
+ ) : (
+
+ )}
{run.characterId && canImportToPlaytest(run, revision.id) ? (