diff --git a/frontend/src/pages/history/README.md b/frontend/src/pages/history/README.md new file mode 100644 index 0000000..4fe90c0 --- /dev/null +++ b/frontend/src/pages/history/README.md @@ -0,0 +1,57 @@ +# 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 或后端传输。History 在页面入口声明只包含 `listWorkflows` 与 `subscribeAll` 的只读接口;正式 WorkflowController 只要满足这两个方法就能注入。将来持久化方式改变时,History 无需跟着改写。 + +## 页面状态 + +- **进行中**:可以继续;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 提供,但应放在普通文档流中。用户向下浏览较长的历史列表时,导航随页面一起滚走,不固定或吸附在视口顶部。 + +## 模块边界 + +History 可以依赖 `@/entities` 的公开类型,并由外层注入满足只读接口的 WorkflowController,但不得: + +- 直接读取 Store 或 localStorage。 +- 调用生成、候选确认、审核或发布命令。 +- 从 Character 或 Playtest 数据反推 WorkflowRun 状态。 +- 实现资产库、Workflow Editor 或 AppShell。 + +## 验证 + +```bash +npm test -- src/pages/history +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 new file mode 100644 index 0000000..3ff1fb1 --- /dev/null +++ b/frontend/src/pages/history/index.test.tsx @@ -0,0 +1,209 @@ +/** @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', + 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, + } +} + +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 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('只展示当前项目,并按最近 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('区分四种 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 的交互界面', () => { + 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', { + 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 0000000..bb650de --- /dev/null +++ b/frontend/src/pages/history/index.tsx @@ -0,0 +1,357 @@ +import { useEffect, useMemo, useState } from 'react' +import { Link, useParams } from 'react-router' + +import type { + WorkflowRevision, + WorkflowRun, + WorkflowRunStatus, + WorkflowStepStatus, + WorkflowStepType, +} from '@/entities' + +/** + * History 面向 Controller 定义自己的最小只读接口,而不依赖 Controller 的具体实现文件。 + * 正式 WorkflowController 只要提供查询和订阅能力,就可以直接作为这个参数传入。 + * 页面拿不到生成、确认、审核等命令,因此从类型层面守住“历史页面不改业务”的边界。 + */ +export interface HistoryController { + /** 读取指定项目当前保存的全部任务快照。 */ + listWorkflows(projectId: string): readonly WorkflowRun[] + + /** 监听任务集合变化;返回值用于页面卸载时取消监听。 */ + subscribeAll(listener: (runs: readonly WorkflowRun[]) => void): () => void +} + +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': '动作首帧生成', + '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 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 ( +
+
+
+
+ {purposeLabel} + + {RUN_STATUS_LABELS[run.status]} + +
+

{title}

+

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

+
+ + {canContinue ? '继续任务' : '查看记录'} + +
+ + {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) => ( +
+
+

+ 版本 {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(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 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 +}