From d045e51da15cb888564ad940db67bbc5620270f8 Mon Sep 17 00:00:00 2001 From: xyh202131 <246811510+xyh202131@users.noreply.github.com> Date: Tue, 4 Aug 2026 12:13:00 +0800 Subject: [PATCH 1/6] feat(history): add project workflow history --- frontend/src/pages/history/README.md | 53 ++++ frontend/src/pages/history/index.test.tsx | 193 +++++++++++++ frontend/src/pages/history/index.tsx | 323 ++++++++++++++++++++++ 3 files changed, 569 insertions(+) create mode 100644 frontend/src/pages/history/README.md create mode 100644 frontend/src/pages/history/index.test.tsx create mode 100644 frontend/src/pages/history/index.tsx diff --git a/frontend/src/pages/history/README.md b/frontend/src/pages/history/README.md new file mode 100644 index 00000000..59aa639a --- /dev/null +++ b/frontend/src/pages/history/README.md @@ -0,0 +1,53 @@ +# History 页面模块 + +History 展示项目下的 WorkflowRun 和其内部 Revision。它回答“这次任务做到了哪一步、重做过几次、当前该继续还是只读查看”,不展示正式角色资产,也不记录 Playtest 核验结论。 + +## 数据层级 + +```text +Project +└── WorkflowRun(一次创建角色或生成动作任务) + └── WorkflowRevision(同一任务的一次执行版本) + └── WorkflowStep(该版本中的有序步骤) +``` + +页面不能把 Revision 拍平成新的 Run。用户主动重做时 Run ID 不变,旧 Revision 仍用于解释新结果从哪里产生。 + +## 数据怎么进入页面 + +1. 路由提供 `projectId`。 +2. 页面调用 `controller.listWorkflows(projectId)` 读取初始快照。 +3. 页面通过 `controller.subscribeAll()` 接收全局变化,并再次按 `projectId` 过滤。 +4. 页面卸载时调用 Controller 返回的取消订阅函数。 + +页面不接触 `WorkflowRunStore`、localStorage 或后端传输。将来持久化方式改变,只需保持 WorkflowController 接口不变,History 无需改写。 + +## 页面状态 + +- **进行中**:可继续进入 Workflow Editor。 +- **已中断**:任务未失败,可以进入编辑器恢复。 +- **失败**:保留错误任务,供用户查看问题。 +- **已完成**:只读查看任务和全部 Revision。 +- **无效记录**:`currentRevisionId` 找不到对应 Revision 时明确报错,不让整个历史页崩溃。 + +每张 Run 卡片展示任务目的、更新时间、当前版本、步骤进度和版本数量。展开后显示每个 Revision 的来源、重开步骤和步骤状态。 + +## 模块边界 + +History 可以依赖 `@/entities` 和 `@/features/workflow-controller` 的公开入口,但不得: + +- 直接读取 Store 或 localStorage。 +- 调用生成、候选确认、审核或发布命令。 +- 从 Character 或 Playtest 数据反推 WorkflowRun 状态。 +- 实现资产库、Workflow Editor 或 AppShell。 + +## 验证 + +```bash +npm test -- src/pages/history +npm run typecheck +npm run lint +npm run build +``` + +测试覆盖项目隔离、更新时间排序、四种 Run 状态、Revision 来源、步骤明细、订阅更新、取消订阅、空状态、错误状态和坏记录。 diff --git a/frontend/src/pages/history/index.test.tsx b/frontend/src/pages/history/index.test.tsx new file mode 100644 index 00000000..66498f86 --- /dev/null +++ b/frontend/src/pages/history/index.test.tsx @@ -0,0 +1,193 @@ +/** @vitest-environment jsdom */ +import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { MemoryRouter, Route, Routes } from 'react-router' + +import type { WorkflowRevision, WorkflowRun } from '@/entities' +import type { HistoryController } from './index' +import { HistoryPage } from './index' + +const NOW = '2026-08-04T10:00:00.000Z' + +function revision(id: string, options: Partial = {}): WorkflowRevision { + return { + id, + basedOnRevisionId: null, + restartStepId: null, + status: 'active', + steps: [ + { + id: `${id}-setup`, + type: 'character-setup', + status: 'passed', + taskId: null, + candidateTaskIds: [], + submissionId: null, + error: null, + referenceStepIds: [], + }, + { + id: `${id}-template`, + type: 'character-template', + status: 'active', + taskId: 'generation-1', + candidateTaskIds: [], + submissionId: null, + error: null, + referenceStepIds: [], + }, + ], + generationStatus: 'in_progress', + exportStatus: 'not_exported', + createdAt: NOW, + ...options, + } +} + +type PendingCharacterRun = Extract + +function run(id: string, options: Partial = {}): WorkflowRun { + const current = revision(`${id}-revision`) + const base: PendingCharacterRun = { + id, + projectId: 'project-1', + purpose: 'create_character', + driver: 'manual', + status: 'active', + currentRevisionId: current.id, + revisions: [current], + prompt: `任务 ${id}`, + createdAt: NOW, + updatedAt: NOW, + characterId: null, + outfitId: null, + selectedAt: null, + } + return { ...base, ...options } +} + +function controller(initial: WorkflowRun[] = []): HistoryController & { + emit(items: WorkflowRun[]): void + unsubscribe: ReturnType +} { + let listener: ((runs: WorkflowRun[]) => void) | null = null + const unsubscribe = vi.fn() + return { + listWorkflows: vi.fn(() => initial), + subscribeAll: vi.fn((nextListener) => { + listener = nextListener + return unsubscribe + }), + emit(items) { + listener?.(items) + }, + unsubscribe, + } +} + +function renderHistory(testController: HistoryController, path = '/projects/project-1/history') { + return render( + + + } + /> + + , + ) +} + +afterEach(cleanup) + +describe('HistoryPage', () => { + it('只展示当前项目,并按更新时间从新到旧排列', () => { + const older = run('older-run', { updatedAt: '2026-08-03T10:00:00.000Z' }) + const newer = run('newer-run', { updatedAt: '2026-08-04T11:00:00.000Z' }) + const anotherProject = run('foreign-run', { projectId: 'project-2' }) + const testController = controller([older, anotherProject, newer]) + + renderHistory(testController) + + const cards = screen.getAllByTestId('history-run') + expect(cards).toHaveLength(2) + expect(within(cards[0]!).getByText('任务 newer-run')).toBeTruthy() + expect(within(cards[1]!).getByText('任务 older-run')).toBeTruthy() + expect(screen.queryByText('任务 foreign-run')).toBeNull() + expect(testController.listWorkflows).toHaveBeenCalledWith('project-1') + }) + + it('区分四种 Run 状态,并为活动与终态提供不同操作文案', () => { + renderHistory( + controller([ + run('active-run'), + run('paused-run', { status: 'interrupted' }), + run('failed-run', { status: 'failed' }), + run('done-run', { status: 'completed' }), + ]), + ) + + expect(screen.getByRole('heading', { name: '进行中' })).toBeTruthy() + expect(screen.getByRole('heading', { name: '已中断' })).toBeTruthy() + expect(screen.getByRole('heading', { name: '失败' })).toBeTruthy() + expect(screen.getByRole('heading', { name: '已完成' })).toBeTruthy() + expect(screen.getAllByRole('link', { name: '继续任务' })).toHaveLength(2) + expect(screen.getAllByRole('link', { name: '查看记录' })).toHaveLength(2) + }) + + it('展开 Run 后展示 Revision 来源与步骤状态', () => { + const first = revision('revision-1', { status: 'abandoned' }) + const second = revision('revision-2', { + basedOnRevisionId: first.id, + restartStepId: 'character-template', + status: 'active', + }) + const item = run('restarted-run', { + currentRevisionId: second.id, + revisions: [first, second], + }) + + renderHistory(controller([item])) + fireEvent.click(screen.getByText('查看 2 个版本')) + + expect(screen.getByText('首次执行')).toBeTruthy() + expect(screen.getByText('基于版本 revision,从 角色候选生成 重开')).toBeTruthy() + expect(screen.getAllByText('角色设定')).toHaveLength(2) + expect(screen.getAllByText('已通过')).toHaveLength(2) + }) + + it('响应全局订阅但继续按项目过滤,并在卸载时取消订阅', () => { + const testController = controller([]) + const view = renderHistory(testController) + expect(screen.getByText('还没有创作记录')).toBeTruthy() + + act(() => { + testController.emit([run('arrived-run'), run('foreign-run', { projectId: 'project-2' })]) + }) + expect(screen.getByText('任务 arrived-run')).toBeTruthy() + expect(screen.queryByText('任务 foreign-run')).toBeNull() + + view.unmount() + expect(testController.unsubscribe).toHaveBeenCalledTimes(1) + }) + + it('无效 currentRevisionId 不会让页面崩溃,而是标出待修复记录', () => { + renderHistory(controller([run('broken-run', { currentRevisionId: 'missing-revision' })])) + + expect( + screen.getByText('当前版本 missing-revision 不存在,这条记录需要修复后才能继续。'), + ).toBeTruthy() + }) + + it('读取失败时展示原始错误,不伪造空历史', () => { + const testController = controller([]) + vi.mocked(testController.listWorkflows).mockImplementation(() => { + throw new Error('历史服务暂不可用') + }) + + renderHistory(testController) + + expect(screen.getByRole('alert').textContent).toContain('历史服务暂不可用') + expect(screen.queryByText('还没有创作记录')).toBeNull() + }) +}) diff --git a/frontend/src/pages/history/index.tsx b/frontend/src/pages/history/index.tsx new file mode 100644 index 00000000..16c9c4b7 --- /dev/null +++ b/frontend/src/pages/history/index.tsx @@ -0,0 +1,323 @@ +import { useEffect, useMemo, useState } from 'react' +import { Link, useParams } from 'react-router' + +import type { + WorkflowRevision, + WorkflowRun, + WorkflowRunStatus, + WorkflowStepStatus, + WorkflowStepType, +} from '@/entities' +import type { WorkflowController } from '@/features/workflow-controller' + +/** + * History 只需要 Controller 的两个只读能力。 + * 使用 Pick 可以防止页面顺手调用生成、确认或审核命令,守住“历史页面不改业务”的边界。 + */ +export type HistoryController = Pick + +export interface HistoryPageProps { + controller: HistoryController +} + +const RUN_SECTIONS: ReadonlyArray<{ + status: WorkflowRunStatus + title: string + emptyLabel: string +}> = [ + { status: 'active', title: '进行中', emptyLabel: '没有正在进行的任务' }, + { status: 'interrupted', title: '已中断', emptyLabel: '没有已中断的任务' }, + { status: 'failed', title: '失败', emptyLabel: '没有失败任务' }, + { status: 'completed', title: '已完成', emptyLabel: '没有已完成任务' }, +] + +const RUN_STATUS_LABELS: Readonly> = { + active: '进行中', + interrupted: '已中断', + failed: '失败', + completed: '已完成', +} + +const RUN_STATUS_STYLES: Readonly> = { + active: 'border-sky-200 bg-sky-50 text-sky-800', + interrupted: 'border-amber-200 bg-amber-50 text-amber-900', + failed: 'border-rose-200 bg-rose-50 text-rose-800', + completed: 'border-emerald-200 bg-emerald-50 text-emerald-800', +} + +const STEP_STATUS_LABELS: Readonly> = { + locked: '未解锁', + available: '可开始', + active: '进行中', + passed: '已通过', + failed: '失败', +} + +const STEP_LABELS: Readonly> = { + 'character-setup': '角色设定', + 'character-template': '角色候选生成', + 'template-candidate': '确认角色候选', + 'action-setup': '动作设定', + 'first-frame': '动作首帧生成', + 'first-frame-candidate': '确认动作首帧', + 'complete-animation': '完整动画生成', + review: '动作审核', + export: '写入角色资产', +} + +/** + * 项目历史页展示 WorkflowRun 与其 Revision,不展示 Character 资产或 Playtest 结论。 + * Run 是一次用户任务;Revision 是该任务内部的重做版本,两者不能拍平成同一级列表。 + */ +export function HistoryPage({ controller }: HistoryPageProps) { + const { projectId = '' } = useParams() + const [runs, setRuns] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + useEffect(() => { + if (!projectId) { + setRuns([]) + setError('路由缺少项目 ID,无法读取历史记录') + setLoading(false) + return + } + + /** + * listWorkflows 可以直接按项目读取;subscribeAll 返回全局变化,回调里必须再次过滤。 + * 这样即使其他项目同时产生任务,也不会把记录混进当前页面。 + */ + const applyProjectRuns = (items: readonly WorkflowRun[]) => { + setRuns(sortRuns(items.filter((run) => run.projectId === projectId))) + setError(null) + setLoading(false) + } + + try { + applyProjectRuns(controller.listWorkflows(projectId)) + return controller.subscribeAll(applyProjectRuns) + } catch (cause) { + setRuns([]) + setError(cause instanceof Error ? cause.message : '历史记录加载失败') + setLoading(false) + } + }, [controller, projectId]) + + const groupedRuns = useMemo( + () => + RUN_SECTIONS.map((section) => ({ + ...section, + runs: runs.filter((run) => run.status === section.status), + })), + [runs], + ) + + return ( +
+
+

HISTORY

+
+
+

+ 创作历史 +

+

查看任务进度、重做版本与每一步结果。

+
+ + 新建创作任务 + +
+
+ + {loading ? ( +

+ 正在读取历史记录... +

+ ) : error !== null ? ( +

+ {error} +

+ ) : runs.length === 0 ? ( +
+

还没有创作记录

+

+ 创建角色或生成动作后,任务会按项目出现在这里。 +

+
+ ) : ( +
+ {groupedRuns.map((section) => + section.runs.length > 0 ? ( +
+
+

+ {section.title} +

+ {section.runs.length} +
+
+ {section.runs.map((run) => ( + + ))} +
+
+ ) : null, + )} +
+ )} +
+ ) +} + +function RunCard({ run }: { run: WorkflowRun }) { + const revision = run.revisions.find((item) => item.id === run.currentRevisionId) + const purposeLabel = run.purpose === 'create_character' ? '创建角色' : '生成动作' + const title = run.prompt?.trim() || `${purposeLabel}任务 ${shortId(run.id)}` + const passedCount = revision?.steps.filter((step) => step.status === 'passed').length ?? 0 + const totalCount = revision?.steps.length ?? 0 + + return ( +
+
+
+
+ {purposeLabel} + + {RUN_STATUS_LABELS[run.status]} + +
+

{title}

+

+ Run {shortId(run.id)} · 更新于{' '} + +

+
+ + {run.status === 'active' || run.status === 'interrupted' ? '继续任务' : '查看记录'} + +
+ + {revision === undefined ? ( +

+ 当前版本 {run.currentRevisionId} 不存在,这条记录需要修复后才能继续。 +

+ ) : ( + <> +
+

+ 当前版本 + {shortId(revision.id)} +

+

+ 步骤进度 + + {passedCount} / {totalCount} + +

+

+ 重做版本 + {run.revisions.length} +

+
+ + + )} +
+ ) +} + +function RevisionHistory({ + revisions, + currentRevisionId, +}: { + revisions: readonly WorkflowRevision[] + currentRevisionId: string +}) { + return ( +
+ + 查看 {revisions.length} 个版本 + +
+ {revisions.map((revision, revisionIndex) => ( +
+
+

+ 版本 {revisionIndex + 1} · {shortId(revision.id)} + {revision.id === currentRevisionId ? '(当前)' : ''} +

+ {revision.status} +
+

+ {revision.basedOnRevisionId === null + ? '首次执行' + : `基于版本 ${shortId(revision.basedOnRevisionId)},从 ${revision.restartStepId ? stepLabel(revision.restartStepId) : '未记录步骤'} 重开`} +

+
    + {revision.steps.map((step) => ( +
  1. + {STEP_LABELS[step.type]} + {STEP_STATUS_LABELS[step.status]} +
  2. + ))} +
+
+ ))} +
+
+ ) +} + +function sortRuns(runs: readonly WorkflowRun[]): WorkflowRun[] { + return [...runs].sort((left, right) => timestamp(right.updatedAt) - timestamp(left.updatedAt)) +} + +function timestamp(value: string): number { + const parsed = Date.parse(value) + return Number.isFinite(parsed) ? parsed : 0 +} + +function formatTime(value: string): string { + const parsed = new Date(value) + if (!Number.isFinite(parsed.getTime())) return '时间未知' + return new Intl.DateTimeFormat('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }).format(parsed) +} + +function shortId(value: string): string { + return value.length > 8 ? value.slice(0, 8) : value +} + +/** 水合失败的旧记录可能带未知步骤名;历史页应原样展示,而不是因此崩溃。 */ +function stepLabel(value: string): string { + return Object.hasOwn(STEP_LABELS, value) ? STEP_LABELS[value as WorkflowStepType] : value +} From dad2b8c99de92c2475f153fb5c4f1d4d8b82bd8a Mon Sep 17 00:00:00 2001 From: xyh202131 <246811510+xyh202131@users.noreply.github.com> Date: Tue, 4 Aug 2026 14:05:25 +0800 Subject: [PATCH 2/6] fix(history): align standalone workflow history --- frontend/src/pages/history/README.md | 16 ++++--- frontend/src/pages/history/index.test.tsx | 50 ++++++++++++++------- frontend/src/pages/history/index.tsx | 54 ++++++++++++++++++----- 3 files changed, 87 insertions(+), 33 deletions(-) diff --git a/frontend/src/pages/history/README.md b/frontend/src/pages/history/README.md index 59aa639a..4fe90c07 100644 --- a/frontend/src/pages/history/README.md +++ b/frontend/src/pages/history/README.md @@ -20,21 +20,25 @@ Project 3. 页面通过 `controller.subscribeAll()` 接收全局变化,并再次按 `projectId` 过滤。 4. 页面卸载时调用 Controller 返回的取消订阅函数。 -页面不接触 `WorkflowRunStore`、localStorage 或后端传输。将来持久化方式改变,只需保持 WorkflowController 接口不变,History 无需改写。 +页面不接触 `WorkflowRunStore`、localStorage 或后端传输。History 在页面入口声明只包含 `listWorkflows` 与 `subscribeAll` 的只读接口;正式 WorkflowController 只要满足这两个方法就能注入。将来持久化方式改变时,History 无需跟着改写。 ## 页面状态 -- **进行中**:可继续进入 Workflow Editor。 -- **已中断**:任务未失败,可以进入编辑器恢复。 +- **进行中**:可以继续;AI 驱动的 Run 返回 Quick Start,手动 Run 返回 Workflow Editor。 +- **已中断**:任务未失败,仍按原来的交互界面恢复。 - **失败**:保留错误任务,供用户查看问题。 - **已完成**:只读查看任务和全部 Revision。 - **无效记录**:`currentRevisionId` 找不到对应 Revision 时明确报错,不让整个历史页崩溃。 -每张 Run 卡片展示任务目的、更新时间、当前版本、步骤进度和版本数量。展开后显示每个 Revision 的来源、重开步骤和步骤状态。 +每张 Run 卡片展示任务目的、最近 Revision 时间、当前版本、步骤进度和版本数量。展开后显示每个 Revision 的来源、重开步骤和步骤状态。当前 WorkflowRun 没有独立的 `updatedAt` 字段,因此页面以最新 Revision 的 `createdAt` 作为最近活动时间,不伪造 Entity 数据。 + +History 只选择恢复目标并传递 `runId`。真正的状态恢复由 Quick Start 或 Workflow Editor 调用 `WorkflowController.resume(runId)` 完成,History 不复制恢复逻辑。 + +正式应用接入 `/projects/:projectId/history` 时,顶部产品导航仍由 AppShell 提供,但应放在普通文档流中。用户向下浏览较长的历史列表时,导航随页面一起滚走,不固定或吸附在视口顶部。 ## 模块边界 -History 可以依赖 `@/entities` 和 `@/features/workflow-controller` 的公开入口,但不得: +History 可以依赖 `@/entities` 的公开类型,并由外层注入满足只读接口的 WorkflowController,但不得: - 直接读取 Store 或 localStorage。 - 调用生成、候选确认、审核或发布命令。 @@ -50,4 +54,4 @@ npm run lint npm run build ``` -测试覆盖项目隔离、更新时间排序、四种 Run 状态、Revision 来源、步骤明细、订阅更新、取消订阅、空状态、错误状态和坏记录。 +测试覆盖项目隔离、最近 Revision 时间排序、四种 Run 状态、Revision 来源、步骤明细、订阅更新、取消订阅、空状态、错误状态和坏记录。 diff --git a/frontend/src/pages/history/index.test.tsx b/frontend/src/pages/history/index.test.tsx index 66498f86..3ff1fb12 100644 --- a/frontend/src/pages/history/index.test.tsx +++ b/frontend/src/pages/history/index.test.tsx @@ -20,20 +20,18 @@ function revision(id: string, options: Partial = {}): Workflow id: `${id}-setup`, type: 'character-setup', status: 'passed', + input: null, + output: null, taskId: null, - candidateTaskIds: [], - submissionId: null, - error: null, referenceStepIds: [], }, { id: `${id}-template`, type: 'character-template', status: 'active', + input: null, + output: null, taskId: 'generation-1', - candidateTaskIds: [], - submissionId: null, - error: null, referenceStepIds: [], }, ], @@ -44,11 +42,12 @@ function revision(id: string, options: Partial = {}): Workflow } } -type PendingCharacterRun = Extract +type RunOptions = Partial & { revisionCreatedAt?: string } -function run(id: string, options: Partial = {}): WorkflowRun { - const current = revision(`${id}-revision`) - const base: PendingCharacterRun = { +function run(id: string, options: RunOptions = {}): WorkflowRun { + const { revisionCreatedAt = NOW, ...overrides } = options + const current = revision(`${id}-revision`, { createdAt: revisionCreatedAt }) + const base: WorkflowRun = { id, projectId: 'project-1', purpose: 'create_character', @@ -57,13 +56,10 @@ function run(id: string, options: Partial = {}): WorkflowRu currentRevisionId: current.id, revisions: [current], prompt: `任务 ${id}`, - createdAt: NOW, - updatedAt: NOW, characterId: null, outfitId: null, - selectedAt: null, } - return { ...base, ...options } + return { ...base, ...overrides } } function controller(initial: WorkflowRun[] = []): HistoryController & { @@ -101,9 +97,9 @@ function renderHistory(testController: HistoryController, path = '/projects/proj afterEach(cleanup) describe('HistoryPage', () => { - it('只展示当前项目,并按更新时间从新到旧排列', () => { - const older = run('older-run', { updatedAt: '2026-08-03T10:00:00.000Z' }) - const newer = run('newer-run', { updatedAt: '2026-08-04T11:00:00.000Z' }) + it('只展示当前项目,并按最近 Revision 时间从新到旧排列', () => { + const older = run('older-run', { revisionCreatedAt: '2026-08-03T10:00:00.000Z' }) + const newer = run('newer-run', { revisionCreatedAt: '2026-08-04T11:00:00.000Z' }) const anotherProject = run('foreign-run', { projectId: 'project-2' }) const testController = controller([older, anotherProject, newer]) @@ -135,6 +131,26 @@ describe('HistoryPage', () => { expect(screen.getAllByRole('link', { name: '查看记录' })).toHaveLength(2) }) + it('继续任务时回到创建该 Run 的交互界面', () => { + renderHistory( + controller([ + run('ai-run', { driver: 'ai' }), + run('manual-run', { driver: 'manual', status: 'interrupted' }), + ]), + ) + + const aiCard = screen.getByText('任务 ai-run').closest('article') + const manualCard = screen.getByText('任务 manual-run').closest('article') + expect(aiCard).not.toBeNull() + expect(manualCard).not.toBeNull() + expect(within(aiCard!).getByRole('link', { name: '继续任务' }).getAttribute('href')).toBe( + '/quick-start/ai-run', + ) + expect(within(manualCard!).getByRole('link', { name: '继续任务' }).getAttribute('href')).toBe( + '/workflow-editor/manual-run', + ) + }) + it('展开 Run 后展示 Revision 来源与步骤状态', () => { const first = revision('revision-1', { status: 'abandoned' }) const second = revision('revision-2', { diff --git a/frontend/src/pages/history/index.tsx b/frontend/src/pages/history/index.tsx index 16c9c4b7..bb650def 100644 --- a/frontend/src/pages/history/index.tsx +++ b/frontend/src/pages/history/index.tsx @@ -8,13 +8,19 @@ import type { WorkflowStepStatus, WorkflowStepType, } from '@/entities' -import type { WorkflowController } from '@/features/workflow-controller' /** - * History 只需要 Controller 的两个只读能力。 - * 使用 Pick 可以防止页面顺手调用生成、确认或审核命令,守住“历史页面不改业务”的边界。 + * History 面向 Controller 定义自己的最小只读接口,而不依赖 Controller 的具体实现文件。 + * 正式 WorkflowController 只要提供查询和订阅能力,就可以直接作为这个参数传入。 + * 页面拿不到生成、确认、审核等命令,因此从类型层面守住“历史页面不改业务”的边界。 */ -export type HistoryController = Pick +export interface HistoryController { + /** 读取指定项目当前保存的全部任务快照。 */ + listWorkflows(projectId: string): readonly WorkflowRun[] + + /** 监听任务集合变化;返回值用于页面卸载时取消监听。 */ + subscribeAll(listener: (runs: readonly WorkflowRun[]) => void): () => void +} export interface HistoryPageProps { controller: HistoryController @@ -59,7 +65,6 @@ const STEP_LABELS: Readonly> = { 'template-candidate': '确认角色候选', 'action-setup': '动作设定', 'first-frame': '动作首帧生成', - 'first-frame-candidate': '确认动作首帧', 'complete-animation': '完整动画生成', review: '动作审核', export: '写入角色资产', @@ -180,10 +185,15 @@ export function HistoryPage({ controller }: HistoryPageProps) { function RunCard({ run }: { run: WorkflowRun }) { const revision = run.revisions.find((item) => item.id === run.currentRevisionId) + const latestRevisionAt = latestRevisionTime(run) const purposeLabel = run.purpose === 'create_character' ? '创建角色' : '生成动作' const title = run.prompt?.trim() || `${purposeLabel}任务 ${shortId(run.id)}` const passedCount = revision?.steps.filter((step) => step.status === 'passed').length ?? 0 const totalCount = revision?.steps.length ?? 0 + const canContinue = run.status === 'active' || run.status === 'interrupted' + const target = canContinue + ? continuationPath(run) + : `/workflow-editor/${encodeURIComponent(run.id)}` return (
@@ -199,15 +209,15 @@ function RunCard({ run }: { run: WorkflowRun }) {

{title}

- Run {shortId(run.id)} · 更新于{' '} - + Run {shortId(run.id)} · 最近版本于{' '} +

- {run.status === 'active' || run.status === 'interrupted' ? '继续任务' : '查看记录'} + {canContinue ? '继续任务' : '查看记录'} @@ -243,6 +253,16 @@ function RunCard({ run }: { run: WorkflowRun }) { ) } +/** + * 自动创作与手动画布只是同一 WorkflowRun 的两种操作界面。 + * History 不负责恢复流程,但必须把用户送回创建该 Run 的界面,否则 Quick Start + * 创建的任务会丢失原来的简化交互语境。 + */ +function continuationPath(run: WorkflowRun): string { + const runId = encodeURIComponent(run.id) + return run.driver === 'ai' ? `/quick-start/${runId}` : `/workflow-editor/${runId}` +} + function RevisionHistory({ revisions, currentRevisionId, @@ -293,7 +313,21 @@ function RevisionHistory({ } function sortRuns(runs: readonly WorkflowRun[]): WorkflowRun[] { - return [...runs].sort((left, right) => timestamp(right.updatedAt) - timestamp(left.updatedAt)) + return [...runs].sort( + (left, right) => timestamp(latestRevisionTime(right)) - timestamp(latestRevisionTime(left)), + ) +} + +/** + * main 中的 WorkflowRun 本身没有更新时间,Revision 的创建时间才是可靠的活动时间。 + * 取最新 Revision 可以正确反映首次执行和重做,同时不在页面层伪造 Entity 字段。 + */ +function latestRevisionTime(run: WorkflowRun): string { + return run.revisions.reduce( + (latest, revision) => + timestamp(revision.createdAt) > timestamp(latest) ? revision.createdAt : latest, + '', + ) } function timestamp(value: string): number { From 882d37db4c9c2ee29a43f28d6cf35c6e0237f9bc Mon Sep 17 00:00:00 2001 From: xyh202131 <246811510+xyh202131@users.noreply.github.com> Date: Sat, 8 Aug 2026 16:04:56 +0800 Subject: [PATCH 3/6] refactor(history): read current workflow node graph --- frontend/src/pages/history/README.md | 51 +--- frontend/src/pages/history/index.test.tsx | 227 ++++------------ frontend/src/pages/history/index.tsx | 306 ++++++---------------- 3 files changed, 155 insertions(+), 429 deletions(-) diff --git a/frontend/src/pages/history/README.md b/frontend/src/pages/history/README.md index 4fe90c07..e4c49b66 100644 --- a/frontend/src/pages/history/README.md +++ b/frontend/src/pages/history/README.md @@ -1,49 +1,24 @@ # History 页面模块 -History 展示项目下的 WorkflowRun 和其内部 Revision。它回答“这次任务做到了哪一步、重做过几次、当前该继续还是只读查看”,不展示正式角色资产,也不记录 Playtest 核验结论。 +History 只读展示项目下已经保存的 `WorkflowRun` 及其当前节点图。 -## 数据层级 +## 当前模型 -```text -Project -└── WorkflowRun(一次创建角色或生成动作任务) - └── WorkflowRevision(同一任务的一次执行版本) - └── WorkflowStep(该版本中的有序步骤) -``` - -页面不能把 Revision 拍平成新的 Run。用户主动重做时 Run ID 不变,旧 Revision 仍用于解释新结果从哪里产生。 - -## 数据怎么进入页面 - -1. 路由提供 `projectId`。 -2. 页面调用 `controller.listWorkflows(projectId)` 读取初始快照。 -3. 页面通过 `controller.subscribeAll()` 接收全局变化,并再次按 `projectId` 过滤。 -4. 页面卸载时调用 Controller 返回的取消订阅函数。 - -页面不接触 `WorkflowRunStore`、localStorage 或后端传输。History 在页面入口声明只包含 `listWorkflows` 与 `subscribeAll` 的只读接口;正式 WorkflowController 只要满足这两个方法就能注入。将来持久化方式改变时,History 无需跟着改写。 +- `WorkflowRun` 直接保存 `nodes`,节点之间的边由 `dependsOnNodeIds` 表达。 +- 页面不再使用已经删除的 Revision、Step、driver、purpose 或本地 Store。 +- 进行中、失败、完成由节点状态派生,不向 `WorkflowRun` 增加重复状态字段。 +- Quick Start 与 Workflow Editor 共用同一份 Run;当前模型不保存入口来源,因此历史页统一进入 Workflow Editor。 -## 页面状态 +## 后端缺口 -- **进行中**:可以继续;AI 驱动的 Run 返回 Quick Start,手动 Run 返回 Workflow Editor。 -- **已中断**:任务未失败,仍按原来的交互界面恢复。 -- **失败**:保留错误任务,供用户查看问题。 -- **已完成**:只读查看任务和全部 Revision。 -- **无效记录**:`currentRevisionId` 找不到对应 Revision 时明确报错,不让整个历史页崩溃。 - -每张 Run 卡片展示任务目的、最近 Revision 时间、当前版本、步骤进度和版本数量。展开后显示每个 Revision 的来源、重开步骤和步骤状态。当前 WorkflowRun 没有独立的 `updatedAt` 字段,因此页面以最新 Revision 的 `createdAt` 作为最近活动时间,不伪造 Entity 数据。 - -History 只选择恢复目标并传递 `runId`。真正的状态恢复由 Quick Start 或 Workflow Editor 调用 `WorkflowController.resume(runId)` 完成,History 不复制恢复逻辑。 - -正式应用接入 `/projects/:projectId/history` 时,顶部产品导航仍由 AppShell 提供,但应放在普通文档流中。用户向下浏览较长的历史列表时,导航随页面一起滚走,不固定或吸附在视口顶部。 +后端当前只有单条 WorkflowRun 的创建、读取、更新和删除接口,没有按 Project 列表查询。 +因此本页面只声明异步 `WorkflowHistoryReader.listByProject(projectId)` 边界,不提供假数据、 +localStorage 降级或伪造 HTTP 路径。正式列表接口落地后由 App 装配真实实现。 ## 模块边界 -History 可以依赖 `@/entities` 的公开类型,并由外层注入满足只读接口的 WorkflowController,但不得: - -- 直接读取 Store 或 localStorage。 -- 调用生成、候选确认、审核或发布命令。 -- 从 Character 或 Playtest 数据反推 WorkflowRun 状态。 -- 实现资产库、Workflow Editor 或 AppShell。 +History 可以读取 `@/entities` 的公开 WorkflowRun 类型,但不得推进节点、生成资产、修改审核结果, +也不得依赖单条 Run 的 WorkflowController。 ## 验证 @@ -53,5 +28,3 @@ npm run typecheck npm run lint npm run build ``` - -测试覆盖项目隔离、最近 Revision 时间排序、四种 Run 状态、Revision 来源、步骤明细、订阅更新、取消订阅、空状态、错误状态和坏记录。 diff --git a/frontend/src/pages/history/index.test.tsx b/frontend/src/pages/history/index.test.tsx index 3ff1fb12..536b2d63 100644 --- a/frontend/src/pages/history/index.test.tsx +++ b/frontend/src/pages/history/index.test.tsx @@ -1,94 +1,44 @@ /** @vitest-environment jsdom */ -import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react' +import { cleanup, render, screen, within } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { MemoryRouter, Route, Routes } from 'react-router' -import type { WorkflowRevision, WorkflowRun } from '@/entities' -import type { HistoryController } from './index' +import type { WorkflowRun } from '@/entities' +import type { WorkflowHistoryReader } from './index' import { HistoryPage } from './index' -const NOW = '2026-08-04T10:00:00.000Z' - -function revision(id: string, options: Partial = {}): WorkflowRevision { - return { - id, - basedOnRevisionId: null, - restartStepId: null, - status: 'active', - steps: [ - { - id: `${id}-setup`, - type: 'character-setup', - status: 'passed', - input: null, - output: null, - taskId: null, - referenceStepIds: [], - }, - { - id: `${id}-template`, - type: 'character-template', - status: 'active', - input: null, - output: null, - taskId: 'generation-1', - referenceStepIds: [], - }, - ], - generationStatus: 'in_progress', - exportStatus: 'not_exported', - createdAt: NOW, - ...options, - } +function node( + id: string, + status: 'locked' | 'active' | 'passed' | 'failed', +): WorkflowRun['nodes'][number] { + // #107 合并前 main 仍是两节点联合类型;JSON 水合模拟后端即将返回的五节点契约。 + return JSON.parse( + JSON.stringify({ + id, + type: 'character-setup', + status, + phase: status === 'passed' ? 'completed' : 'configuring', + dependsOnNodeIds: [], + generations: [], + error: status === 'failed' ? '生成失败' : null, + input: { prompt: '像素骑士', referenceMedia: [] }, + }), + ) as WorkflowRun['nodes'][number] } -type RunOptions = Partial & { revisionCreatedAt?: string } - -function run(id: string, options: RunOptions = {}): WorkflowRun { - const { revisionCreatedAt = NOW, ...overrides } = options - const current = revision(`${id}-revision`, { createdAt: revisionCreatedAt }) - const base: WorkflowRun = { - id, - projectId: 'project-1', - purpose: 'create_character', - driver: 'manual', - status: 'active', - currentRevisionId: current.id, - revisions: [current], - prompt: `任务 ${id}`, - characterId: null, - outfitId: null, - } - return { ...base, ...overrides } +function run(id: string, projectId: string, nodes: WorkflowRun['nodes']): WorkflowRun { + return { id, projectId, version: 3, storageStatus: 'active', nodes } } -function controller(initial: WorkflowRun[] = []): HistoryController & { - emit(items: WorkflowRun[]): void - unsubscribe: ReturnType -} { - let listener: ((runs: WorkflowRun[]) => void) | null = null - const unsubscribe = vi.fn() - return { - listWorkflows: vi.fn(() => initial), - subscribeAll: vi.fn((nextListener) => { - listener = nextListener - return unsubscribe - }), - emit(items) { - listener?.(items) - }, - unsubscribe, - } +function reader(items: WorkflowRun[] = []): WorkflowHistoryReader { + return { listByProject: vi.fn(async () => items) } } -function renderHistory(testController: HistoryController, path = '/projects/project-1/history') { +function renderHistory(source: WorkflowHistoryReader, path = '/projects/project-1/history') { return render( - } - /> + } /> , ) @@ -97,113 +47,52 @@ function renderHistory(testController: HistoryController, path = '/projects/proj afterEach(cleanup) describe('HistoryPage', () => { - it('只展示当前项目,并按最近 Revision 时间从新到旧排列', () => { - const older = run('older-run', { revisionCreatedAt: '2026-08-03T10:00:00.000Z' }) - const newer = run('newer-run', { revisionCreatedAt: '2026-08-04T11:00:00.000Z' }) - const anotherProject = run('foreign-run', { projectId: 'project-2' }) - const testController = controller([older, anotherProject, newer]) - - renderHistory(testController) - - const cards = screen.getAllByTestId('history-run') - expect(cards).toHaveLength(2) - expect(within(cards[0]!).getByText('任务 newer-run')).toBeTruthy() - expect(within(cards[1]!).getByText('任务 older-run')).toBeTruthy() - expect(screen.queryByText('任务 foreign-run')).toBeNull() - expect(testController.listWorkflows).toHaveBeenCalledWith('project-1') + it('只展示当前项目,并直接读取 WorkflowRun 节点图', async () => { + const source = reader([ + run('active-run', 'project-1', [node('setup', 'active')]), + run('foreign-run', 'project-2', [node('setup', 'passed')]), + ]) + + renderHistory(source) + + const card = await screen.findByTestId('history-run') + expect(within(card).getByText('工作流 active-r')).toBeTruthy() + expect(screen.queryByText('工作流 foreign-')).toBeNull() + expect(source.listByProject).toHaveBeenCalledWith('project-1') }) - it('区分四种 Run 状态,并为活动与终态提供不同操作文案', () => { + it('从节点状态派生进行中、失败和完成,不引入 Run 状态字段', async () => { renderHistory( - controller([ - run('active-run'), - run('paused-run', { status: 'interrupted' }), - run('failed-run', { status: 'failed' }), - run('done-run', { status: 'completed' }), + reader([ + run('active-run', 'project-1', [node('setup', 'active')]), + run('failed-run', 'project-1', [node('setup', 'failed')]), + run('done-run', 'project-1', [node('setup', 'passed')]), ]), ) - expect(screen.getByRole('heading', { name: '进行中' })).toBeTruthy() - expect(screen.getByRole('heading', { name: '已中断' })).toBeTruthy() + expect(await screen.findByRole('heading', { name: '进行中' })).toBeTruthy() expect(screen.getByRole('heading', { name: '失败' })).toBeTruthy() expect(screen.getByRole('heading', { name: '已完成' })).toBeTruthy() expect(screen.getAllByRole('link', { name: '继续任务' })).toHaveLength(2) - expect(screen.getAllByRole('link', { name: '查看记录' })).toHaveLength(2) + expect(screen.getByRole('link', { name: '查看记录' })).toBeTruthy() }) - it('继续任务时回到创建该 Run 的交互界面', () => { - renderHistory( - controller([ - run('ai-run', { driver: 'ai' }), - run('manual-run', { driver: 'manual', status: 'interrupted' }), - ]), - ) - - const aiCard = screen.getByText('任务 ai-run').closest('article') - const manualCard = screen.getByText('任务 manual-run').closest('article') - expect(aiCard).not.toBeNull() - expect(manualCard).not.toBeNull() - expect(within(aiCard!).getByRole('link', { name: '继续任务' }).getAttribute('href')).toBe( - '/quick-start/ai-run', - ) - expect(within(manualCard!).getByRole('link', { name: '继续任务' }).getAttribute('href')).toBe( - '/workflow-editor/manual-run', - ) - }) + it('读取失败时展示错误,不伪装为空历史', async () => { + const source: WorkflowHistoryReader = { + listByProject: vi.fn(async () => { + throw new Error('历史接口暂不可用') + }), + } - it('展开 Run 后展示 Revision 来源与步骤状态', () => { - const first = revision('revision-1', { status: 'abandoned' }) - const second = revision('revision-2', { - basedOnRevisionId: first.id, - restartStepId: 'character-template', - status: 'active', - }) - const item = run('restarted-run', { - currentRevisionId: second.id, - revisions: [first, second], - }) + renderHistory(source) - renderHistory(controller([item])) - fireEvent.click(screen.getByText('查看 2 个版本')) - - expect(screen.getByText('首次执行')).toBeTruthy() - expect(screen.getByText('基于版本 revision,从 角色候选生成 重开')).toBeTruthy() - expect(screen.getAllByText('角色设定')).toHaveLength(2) - expect(screen.getAllByText('已通过')).toHaveLength(2) - }) - - it('响应全局订阅但继续按项目过滤,并在卸载时取消订阅', () => { - const testController = controller([]) - const view = renderHistory(testController) - expect(screen.getByText('还没有创作记录')).toBeTruthy() - - act(() => { - testController.emit([run('arrived-run'), run('foreign-run', { projectId: 'project-2' })]) - }) - expect(screen.getByText('任务 arrived-run')).toBeTruthy() - expect(screen.queryByText('任务 foreign-run')).toBeNull() - - view.unmount() - expect(testController.unsubscribe).toHaveBeenCalledTimes(1) - }) - - it('无效 currentRevisionId 不会让页面崩溃,而是标出待修复记录', () => { - renderHistory(controller([run('broken-run', { currentRevisionId: 'missing-revision' })])) - - expect( - screen.getByText('当前版本 missing-revision 不存在,这条记录需要修复后才能继续。'), - ).toBeTruthy() + expect((await screen.findByRole('alert')).textContent).toContain('历史接口暂不可用') + expect(screen.queryByText('还没有创作记录')).toBeNull() }) - it('读取失败时展示原始错误,不伪造空历史', () => { - const testController = controller([]) - vi.mocked(testController.listWorkflows).mockImplementation(() => { - throw new Error('历史服务暂不可用') - }) - - renderHistory(testController) - - expect(screen.getByRole('alert').textContent).toContain('历史服务暂不可用') - expect(screen.queryByText('还没有创作记录')).toBeNull() + it('空列表说明仍在等待后端列表接口', async () => { + renderHistory(reader()) + expect(await screen.findByText('还没有创作记录')).toBeTruthy() + expect(screen.getByText('后端提供列表接口后,项目记录会显示在这里。')).toBeTruthy() }) }) diff --git a/frontend/src/pages/history/index.tsx b/frontend/src/pages/history/index.tsx index bb650def..92b50299 100644 --- a/frontend/src/pages/history/index.tsx +++ b/frontend/src/pages/history/index.tsx @@ -1,118 +1,107 @@ import { useEffect, useMemo, useState } from 'react' import { Link, useParams } from 'react-router' -import type { - WorkflowRevision, - WorkflowRun, - WorkflowRunStatus, - WorkflowStepStatus, - WorkflowStepType, -} from '@/entities' +import type { WorkflowNode, WorkflowRun } from '@/entities' /** - * History 面向 Controller 定义自己的最小只读接口,而不依赖 Controller 的具体实现文件。 - * 正式 WorkflowController 只要提供查询和订阅能力,就可以直接作为这个参数传入。 - * 页面拿不到生成、确认、审核等命令,因此从类型层面守住“历史页面不改业务”的边界。 + * 当前后端尚未提供 WorkflowRun 列表接口,因此页面只声明读取边界,不伪造实现。 + * 接口就绪后由 App 装配真实 reader;页面不依赖单 Run 的 WorkflowController。 */ -export interface HistoryController { - /** 读取指定项目当前保存的全部任务快照。 */ - listWorkflows(projectId: string): readonly WorkflowRun[] - - /** 监听任务集合变化;返回值用于页面卸载时取消监听。 */ - subscribeAll(listener: (runs: readonly WorkflowRun[]) => void): () => void +export interface WorkflowHistoryReader { + listByProject(projectId: string): Promise } export interface HistoryPageProps { - controller: HistoryController + reader: WorkflowHistoryReader } +type DerivedRunState = 'active' | 'failed' | 'completed' + const RUN_SECTIONS: ReadonlyArray<{ - status: WorkflowRunStatus + state: DerivedRunState title: string - emptyLabel: string }> = [ - { status: 'active', title: '进行中', emptyLabel: '没有正在进行的任务' }, - { status: 'interrupted', title: '已中断', emptyLabel: '没有已中断的任务' }, - { status: 'failed', title: '失败', emptyLabel: '没有失败任务' }, - { status: 'completed', title: '已完成', emptyLabel: '没有已完成任务' }, + { state: 'active', title: '进行中' }, + { state: 'failed', title: '失败' }, + { state: 'completed', title: '已完成' }, ] -const RUN_STATUS_LABELS: Readonly> = { +const RUN_STATUS_LABELS: Readonly> = { active: '进行中', - interrupted: '已中断', failed: '失败', completed: '已完成', } -const RUN_STATUS_STYLES: Readonly> = { +const RUN_STATUS_STYLES: Readonly> = { active: 'border-sky-200 bg-sky-50 text-sky-800', - interrupted: 'border-amber-200 bg-amber-50 text-amber-900', failed: 'border-rose-200 bg-rose-50 text-rose-800', completed: 'border-emerald-200 bg-emerald-50 text-emerald-800', } -const STEP_STATUS_LABELS: Readonly> = { - locked: '未解锁', - available: '可开始', - active: '进行中', - passed: '已通过', - failed: '失败', -} - -const STEP_LABELS: Readonly> = { +const NODE_LABELS: Readonly> = { + character: '角色制作', + action: '动作制作', 'character-setup': '角色设定', - 'character-template': '角色候选生成', - 'template-candidate': '确认角色候选', - 'action-setup': '动作设定', - 'first-frame': '动作首帧生成', - 'complete-animation': '完整动画生成', + 'character-template': '角色母版', + 'action-first-frame': '动作首帧', + 'action-full-frame': '完整动画', review: '动作审核', - export: '写入角色资产', } -/** - * 项目历史页展示 WorkflowRun 与其 Revision,不展示 Character 资产或 Playtest 结论。 - * Run 是一次用户任务;Revision 是该任务内部的重做版本,两者不能拍平成同一级列表。 - */ -export function HistoryPage({ controller }: HistoryPageProps) { +const NODE_STATUS_LABELS: Readonly> = { + locked: '等待上游', + active: '进行中', + passed: '已完成', + failed: '失败', +} + +/** 只读展示 WorkflowRun 当前节点图;不恢复旧 Revision、Step 或 driver 概念。 */ +export function HistoryPage({ reader }: HistoryPageProps) { const { projectId = '' } = useParams() const [runs, setRuns] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) useEffect(() => { + let cancelled = false + setLoading(true) + setError(null) + if (!projectId) { setRuns([]) setError('路由缺少项目 ID,无法读取历史记录') setLoading(false) - return + return () => { + cancelled = true + } } - /** - * listWorkflows 可以直接按项目读取;subscribeAll 返回全局变化,回调里必须再次过滤。 - * 这样即使其他项目同时产生任务,也不会把记录混进当前页面。 - */ - const applyProjectRuns = (items: readonly WorkflowRun[]) => { - setRuns(sortRuns(items.filter((run) => run.projectId === projectId))) - setError(null) - setLoading(false) - } - - try { - applyProjectRuns(controller.listWorkflows(projectId)) - return controller.subscribeAll(applyProjectRuns) - } catch (cause) { - setRuns([]) - setError(cause instanceof Error ? cause.message : '历史记录加载失败') - setLoading(false) + void reader.listByProject(projectId).then( + (items) => { + if (cancelled) return + setRuns( + items.filter((run) => run.projectId === projectId).map((run) => structuredClone(run)), + ) + setLoading(false) + }, + (cause: unknown) => { + if (cancelled) return + setRuns([]) + setError(cause instanceof Error ? cause.message : '历史记录加载失败') + setLoading(false) + }, + ) + + return () => { + cancelled = true } - }, [controller, projectId]) + }, [projectId, reader]) const groupedRuns = useMemo( () => RUN_SECTIONS.map((section) => ({ ...section, - runs: runs.filter((run) => run.status === section.status), + runs: runs.filter((run) => deriveRunState(run) === section.state), })), [runs], ) @@ -126,7 +115,7 @@ export function HistoryPage({ controller }: HistoryPageProps) {

创作历史

-

查看任务进度、重做版本与每一步结果。

+

查看每条工作流当前保存的节点进度。

还没有创作记录

-

- 创建角色或生成动作后,任务会按项目出现在这里。 -

+

后端提供列表接口后,项目记录会显示在这里。

) : (
{groupedRuns.map((section) => section.runs.length > 0 ? ( -
+

{section.title} @@ -184,174 +171,51 @@ export function HistoryPage({ controller }: HistoryPageProps) { } function RunCard({ run }: { run: WorkflowRun }) { - const revision = run.revisions.find((item) => item.id === run.currentRevisionId) - const latestRevisionAt = latestRevisionTime(run) - const purposeLabel = run.purpose === 'create_character' ? '创建角色' : '生成动作' - const title = run.prompt?.trim() || `${purposeLabel}任务 ${shortId(run.id)}` - const passedCount = revision?.steps.filter((step) => step.status === 'passed').length ?? 0 - const totalCount = revision?.steps.length ?? 0 - const canContinue = run.status === 'active' || run.status === 'interrupted' - const target = canContinue - ? continuationPath(run) - : `/workflow-editor/${encodeURIComponent(run.id)}` + const state = deriveRunState(run) + const passedCount = run.nodes.filter((node) => node.status === 'passed').length return (
-
-
- {purposeLabel} - - {RUN_STATUS_LABELS[run.status]} - -
-

{title}

+
+ + {RUN_STATUS_LABELS[state]} + +

工作流 {shortId(run.id)}

- Run {shortId(run.id)} · 最近版本于{' '} - + 版本 {run.version} · 节点 {passedCount} / {run.nodes.length}

- {canContinue ? '继续任务' : '查看记录'} + {state === 'completed' ? '查看记录' : '继续任务'}
- {revision === undefined ? ( -

- 当前版本 {run.currentRevisionId} 不存在,这条记录需要修复后才能继续。 -

- ) : ( - <> -
-

- 当前版本 - {shortId(revision.id)} -

-

- 步骤进度 - - {passedCount} / {totalCount} - -

-

- 重做版本 - {run.revisions.length} -

-
- - - )} -
- ) -} - -/** - * 自动创作与手动画布只是同一 WorkflowRun 的两种操作界面。 - * History 不负责恢复流程,但必须把用户送回创建该 Run 的界面,否则 Quick Start - * 创建的任务会丢失原来的简化交互语境。 - */ -function continuationPath(run: WorkflowRun): string { - const runId = encodeURIComponent(run.id) - return run.driver === 'ai' ? `/quick-start/${runId}` : `/workflow-editor/${runId}` -} - -function RevisionHistory({ - revisions, - currentRevisionId, -}: { - revisions: readonly WorkflowRevision[] - currentRevisionId: string -}) { - return ( -
- - 查看 {revisions.length} 个版本 - -
- {revisions.map((revision, revisionIndex) => ( -
+ {run.nodes.map((node) => ( +
  • -
    -

    - 版本 {revisionIndex + 1} · {shortId(revision.id)} - {revision.id === currentRevisionId ? '(当前)' : ''} -

    - {revision.status} -
    -

    - {revision.basedOnRevisionId === null - ? '首次执行' - : `基于版本 ${shortId(revision.basedOnRevisionId)},从 ${revision.restartStepId ? stepLabel(revision.restartStepId) : '未记录步骤'} 重开`} -

    -
      - {revision.steps.map((step) => ( -
    1. - {STEP_LABELS[step.type]} - {STEP_STATUS_LABELS[step.status]} -
    2. - ))} -
    -
  • + {NODE_LABELS[node.type] ?? node.type} + {NODE_STATUS_LABELS[node.status]} + ))} -
    -
    - ) -} - -function sortRuns(runs: readonly WorkflowRun[]): WorkflowRun[] { - return [...runs].sort( - (left, right) => timestamp(latestRevisionTime(right)) - timestamp(latestRevisionTime(left)), - ) -} - -/** - * main 中的 WorkflowRun 本身没有更新时间,Revision 的创建时间才是可靠的活动时间。 - * 取最新 Revision 可以正确反映首次执行和重做,同时不在页面层伪造 Entity 字段。 - */ -function latestRevisionTime(run: WorkflowRun): string { - return run.revisions.reduce( - (latest, revision) => - timestamp(revision.createdAt) > timestamp(latest) ? revision.createdAt : latest, - '', + +

    ) } -function timestamp(value: string): number { - const parsed = Date.parse(value) - return Number.isFinite(parsed) ? parsed : 0 -} - -function formatTime(value: string): string { - const parsed = new Date(value) - if (!Number.isFinite(parsed.getTime())) return '时间未知' - return new Intl.DateTimeFormat('zh-CN', { - year: 'numeric', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - }).format(parsed) +function deriveRunState(run: WorkflowRun): DerivedRunState { + if (run.nodes.some((node) => node.status === 'failed')) return 'failed' + if (run.nodes.length > 0 && run.nodes.every((node) => node.status === 'passed')) + return 'completed' + return 'active' } function shortId(value: string): string { return value.length > 8 ? value.slice(0, 8) : value } - -/** 水合失败的旧记录可能带未知步骤名;历史页应原样展示,而不是因此崩溃。 */ -function stepLabel(value: string): string { - return Object.hasOwn(STEP_LABELS, value) ? STEP_LABELS[value as WorkflowStepType] : value -} From 4f592d1c7f06b10f6faad5f11468a3c8458fcd0d Mon Sep 17 00:00:00 2001 From: xyh202131 <246811510+xyh202131@users.noreply.github.com> Date: Sat, 8 Aug 2026 16:31:59 +0800 Subject: [PATCH 4/6] feat(history): label asset generation method nodes --- frontend/src/pages/history/index.test.tsx | 21 ++++++++++++++++++++- frontend/src/pages/history/index.tsx | 1 + 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/frontend/src/pages/history/index.test.tsx b/frontend/src/pages/history/index.test.tsx index 536b2d63..4fa091c6 100644 --- a/frontend/src/pages/history/index.test.tsx +++ b/frontend/src/pages/history/index.test.tsx @@ -11,7 +11,7 @@ function node( id: string, status: 'locked' | 'active' | 'passed' | 'failed', ): WorkflowRun['nodes'][number] { - // #107 合并前 main 仍是两节点联合类型;JSON 水合模拟后端即将返回的五节点契约。 + // #107 合并前 main 仍是两节点联合类型;JSON 水合模拟后端即将返回的六节点契约。 return JSON.parse( JSON.stringify({ id, @@ -90,6 +90,25 @@ describe('HistoryPage', () => { expect(screen.queryByText('还没有创作记录')).toBeNull() }) + it('展示动作资产生成方式节点的人类可读名称', async () => { + const methodNode = JSON.parse( + JSON.stringify({ + id: 'method-1', + type: 'action-generation-method', + status: 'active', + phase: 'selecting', + dependsOnNodeIds: [], + generations: [], + error: null, + method: null, + }), + ) as WorkflowRun['nodes'][number] + + renderHistory(reader([run('route-run', 'project-1', [methodNode])])) + + expect(await screen.findByText('资产生成方式')).toBeTruthy() + }) + it('空列表说明仍在等待后端列表接口', async () => { renderHistory(reader()) expect(await screen.findByText('还没有创作记录')).toBeTruthy() diff --git a/frontend/src/pages/history/index.tsx b/frontend/src/pages/history/index.tsx index 92b50299..a5ba4132 100644 --- a/frontend/src/pages/history/index.tsx +++ b/frontend/src/pages/history/index.tsx @@ -44,6 +44,7 @@ const NODE_LABELS: Readonly> = { 'character-setup': '角色设定', 'character-template': '角色母版', 'action-first-frame': '动作首帧', + 'action-generation-method': '资产生成方式', 'action-full-frame': '完整动画', review: '动作审核', } From 805447a1d50bd1e0f41009c26258faaca21be32e Mon Sep 17 00:00:00 2001 From: xyh202131 <246811510+xyh202131@users.noreply.github.com> Date: Sat, 8 Aug 2026 17:08:23 +0800 Subject: [PATCH 5/6] fix(history): keep deferred skeleton unreachable --- frontend/src/pages/history/README.md | 7 +++++-- frontend/src/pages/history/index.test.tsx | 3 ++- frontend/src/pages/history/index.tsx | 22 +++++++--------------- 3 files changed, 14 insertions(+), 18 deletions(-) diff --git a/frontend/src/pages/history/README.md b/frontend/src/pages/history/README.md index e4c49b66..c7888fe1 100644 --- a/frontend/src/pages/history/README.md +++ b/frontend/src/pages/history/README.md @@ -1,6 +1,7 @@ # History 页面模块 -History 只读展示项目下已经保存的 `WorkflowRun` 及其当前节点图。 +History 只读展示项目下已经保存的 `WorkflowRun` 及其当前节点图。当前阶段只保留模块骨架, +不注册 App 路由,也不在项目导航中提供入口。 ## 当前模型 @@ -8,12 +9,14 @@ History 只读展示项目下已经保存的 `WorkflowRun` 及其当前节点图 - 页面不再使用已经删除的 Revision、Step、driver、purpose 或本地 Store。 - 进行中、失败、完成由节点状态派生,不向 `WorkflowRun` 增加重复状态字段。 - Quick Start 与 Workflow Editor 共用同一份 Run;当前模型不保存入口来源,因此历史页统一进入 Workflow Editor。 +- 页面不提供“新建创作任务”入口;创建工作流必须先由正式用例取得真实 `runId`,再进入 + `/workflow-editor/:runId`。 ## 后端缺口 后端当前只有单条 WorkflowRun 的创建、读取、更新和删除接口,没有按 Project 列表查询。 因此本页面只声明异步 `WorkflowHistoryReader.listByProject(projectId)` 边界,不提供假数据、 -localStorage 降级或伪造 HTTP 路径。正式列表接口落地后由 App 装配真实实现。 +localStorage 降级或伪造 HTTP 路径。正式列表接口落地后由 App 装配真实实现,再注册路由和导航入口。 ## 模块边界 diff --git a/frontend/src/pages/history/index.test.tsx b/frontend/src/pages/history/index.test.tsx index 4fa091c6..8523b394 100644 --- a/frontend/src/pages/history/index.test.tsx +++ b/frontend/src/pages/history/index.test.tsx @@ -112,6 +112,7 @@ describe('HistoryPage', () => { it('空列表说明仍在等待后端列表接口', async () => { renderHistory(reader()) expect(await screen.findByText('还没有创作记录')).toBeTruthy() - expect(screen.getByText('后端提供列表接口后,项目记录会显示在这里。')).toBeTruthy() + expect(screen.getByText('History 暂未接入产品入口;后端列表接口确定后再启用。')).toBeTruthy() + expect(screen.queryByRole('link', { name: '新建创作任务' })).toBeNull() }) }) diff --git a/frontend/src/pages/history/index.tsx b/frontend/src/pages/history/index.tsx index a5ba4132..2b8263a7 100644 --- a/frontend/src/pages/history/index.tsx +++ b/frontend/src/pages/history/index.tsx @@ -111,20 +111,10 @@ export function HistoryPage({ reader }: HistoryPageProps) {

    HISTORY

    -
    -
    -

    - 创作历史 -

    -

    查看每条工作流当前保存的节点进度。

    -
    - - 新建创作任务 - -
    +

    + 创作历史 +

    +

    查看每条工作流当前保存的节点进度。

    {loading ? ( @@ -141,7 +131,9 @@ export function HistoryPage({ reader }: HistoryPageProps) { ) : runs.length === 0 ? (

    还没有创作记录

    -

    后端提供列表接口后,项目记录会显示在这里。

    +

    + History 暂未接入产品入口;后端列表接口确定后再启用。 +

    ) : (
    From 9c39869dea82edc87113250444063a3aadaa73b3 Mon Sep 17 00:00:00 2001 From: xyh202131 <246811510+xyh202131@users.noreply.github.com> Date: Mon, 10 Aug 2026 13:57:18 +0800 Subject: [PATCH 6/6] docs(history): record workflow run list availability --- frontend/src/pages/history/README.md | 8 ++++---- frontend/src/pages/history/index.tsx | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/frontend/src/pages/history/README.md b/frontend/src/pages/history/README.md index c7888fe1..d90abb2c 100644 --- a/frontend/src/pages/history/README.md +++ b/frontend/src/pages/history/README.md @@ -12,11 +12,11 @@ History 只读展示项目下已经保存的 `WorkflowRun` 及其当前节点图 - 页面不提供“新建创作任务”入口;创建工作流必须先由正式用例取得真实 `runId`,再进入 `/workflow-editor/:runId`。 -## 后端缺口 +## 接入状态 -后端当前只有单条 WorkflowRun 的创建、读取、更新和删除接口,没有按 Project 列表查询。 -因此本页面只声明异步 `WorkflowHistoryReader.listByProject(projectId)` 边界,不提供假数据、 -localStorage 降级或伪造 HTTP 路径。正式列表接口落地后由 App 装配真实实现,再注册路由和导航入口。 +后端 PR #176 已提供按 Project 分页查询 WorkflowRun 的接口。本页面继续只声明异步 +`WorkflowHistoryReader.listByProject(projectId)` 边界,不提供假数据或 localStorage 降级。 +待 WorkflowRunStore 的真实适配器合并并由 App 装配后,再注册路由和导航入口。 ## 模块边界 diff --git a/frontend/src/pages/history/index.tsx b/frontend/src/pages/history/index.tsx index 2b8263a7..1b7d8783 100644 --- a/frontend/src/pages/history/index.tsx +++ b/frontend/src/pages/history/index.tsx @@ -4,8 +4,8 @@ import { Link, useParams } from 'react-router' import type { WorkflowNode, WorkflowRun } from '@/entities' /** - * 当前后端尚未提供 WorkflowRun 列表接口,因此页面只声明读取边界,不伪造实现。 - * 接口就绪后由 App 装配真实 reader;页面不依赖单 Run 的 WorkflowController。 + * 后端 PR #176 已提供按项目分页查询 WorkflowRun 的接口;本模块仍只依赖读取边界。 + * 待 WorkflowRunStore 合并并由 App 装配后再开放入口,页面不依赖单 Run 的 Controller。 */ export interface WorkflowHistoryReader { listByProject(projectId: string): Promise