diff --git a/frontend/src/pages/history/README.md b/frontend/src/pages/history/README.md new file mode 100644 index 00000000..c7888fe1 --- /dev/null +++ b/frontend/src/pages/history/README.md @@ -0,0 +1,33 @@ +# History 页面模块 + +History 只读展示项目下已经保存的 `WorkflowRun` 及其当前节点图。当前阶段只保留模块骨架, +不注册 App 路由,也不在项目导航中提供入口。 + +## 当前模型 + +- `WorkflowRun` 直接保存 `nodes`,节点之间的边由 `dependsOnNodeIds` 表达。 +- 页面不再使用已经删除的 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 装配真实实现,再注册路由和导航入口。 + +## 模块边界 + +History 可以读取 `@/entities` 的公开 WorkflowRun 类型,但不得推进节点、生成资产、修改审核结果, +也不得依赖单条 Run 的 WorkflowController。 + +## 验证 + +```bash +npm test -- src/pages/history +npm run typecheck +npm run lint +npm run build +``` diff --git a/frontend/src/pages/history/index.test.tsx b/frontend/src/pages/history/index.test.tsx new file mode 100644 index 00000000..8523b394 --- /dev/null +++ b/frontend/src/pages/history/index.test.tsx @@ -0,0 +1,118 @@ +/** @vitest-environment jsdom */ +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 { WorkflowRun } from '@/entities' +import type { WorkflowHistoryReader } from './index' +import { HistoryPage } from './index' + +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] +} + +function run(id: string, projectId: string, nodes: WorkflowRun['nodes']): WorkflowRun { + return { id, projectId, version: 3, storageStatus: 'active', nodes } +} + +function reader(items: WorkflowRun[] = []): WorkflowHistoryReader { + return { listByProject: vi.fn(async () => items) } +} + +function renderHistory(source: WorkflowHistoryReader, path = '/projects/project-1/history') { + return render( + + + } /> + + , + ) +} + +afterEach(cleanup) + +describe('HistoryPage', () => { + 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 状态字段', async () => { + renderHistory( + 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(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.getByRole('link', { name: '查看记录' })).toBeTruthy() + }) + + it('读取失败时展示错误,不伪装为空历史', async () => { + const source: WorkflowHistoryReader = { + listByProject: vi.fn(async () => { + throw new Error('历史接口暂不可用') + }), + } + + renderHistory(source) + + expect((await screen.findByRole('alert')).textContent).toContain('历史接口暂不可用') + 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() + 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 new file mode 100644 index 00000000..2b8263a7 --- /dev/null +++ b/frontend/src/pages/history/index.tsx @@ -0,0 +1,214 @@ +import { useEffect, useMemo, useState } from 'react' +import { Link, useParams } from 'react-router' + +import type { WorkflowNode, WorkflowRun } from '@/entities' + +/** + * 当前后端尚未提供 WorkflowRun 列表接口,因此页面只声明读取边界,不伪造实现。 + * 接口就绪后由 App 装配真实 reader;页面不依赖单 Run 的 WorkflowController。 + */ +export interface WorkflowHistoryReader { + listByProject(projectId: string): Promise +} + +export interface HistoryPageProps { + reader: WorkflowHistoryReader +} + +type DerivedRunState = 'active' | 'failed' | 'completed' + +const RUN_SECTIONS: ReadonlyArray<{ + state: DerivedRunState + title: string +}> = [ + { state: 'active', title: '进行中' }, + { state: 'failed', title: '失败' }, + { state: 'completed', title: '已完成' }, +] + +const RUN_STATUS_LABELS: Readonly> = { + active: '进行中', + failed: '失败', + completed: '已完成', +} + +const RUN_STATUS_STYLES: Readonly> = { + active: 'border-sky-200 bg-sky-50 text-sky-800', + failed: 'border-rose-200 bg-rose-50 text-rose-800', + completed: 'border-emerald-200 bg-emerald-50 text-emerald-800', +} + +const NODE_LABELS: Readonly> = { + character: '角色制作', + action: '动作制作', + 'character-setup': '角色设定', + 'character-template': '角色母版', + 'action-first-frame': '动作首帧', + 'action-generation-method': '资产生成方式', + 'action-full-frame': '完整动画', + review: '动作审核', +} + +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 () => { + cancelled = true + } + } + + 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 + } + }, [projectId, reader]) + + const groupedRuns = useMemo( + () => + RUN_SECTIONS.map((section) => ({ + ...section, + runs: runs.filter((run) => deriveRunState(run) === section.state), + })), + [runs], + ) + + return ( +
+
+

HISTORY

+

+ 创作历史 +

+

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

+
+ + {loading ? ( +

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

+ ) : error !== null ? ( +

+ {error} +

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

还没有创作记录

+

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

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

+ {section.title} +

+ {section.runs.length} +
+
+ {section.runs.map((run) => ( + + ))} +
+
+ ) : null, + )} +
+ )} +
+ ) +} + +function RunCard({ run }: { run: WorkflowRun }) { + const state = deriveRunState(run) + const passedCount = run.nodes.filter((node) => node.status === 'passed').length + + return ( +
+
+
+ + {RUN_STATUS_LABELS[state]} + +

工作流 {shortId(run.id)}

+

+ 版本 {run.version} · 节点 {passedCount} / {run.nodes.length} +

+
+ + {state === 'completed' ? '查看记录' : '继续任务'} + +
+ +
    + {run.nodes.map((node) => ( +
  1. + {NODE_LABELS[node.type] ?? node.type} + {NODE_STATUS_LABELS[node.status]} +
  2. + ))} +
+
+ ) +} + +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 +}