Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions frontend/src/pages/history/README.md
Original file line number Diff line number Diff line change
@@ -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`。

## 接入状态

后端 PR #176 已提供按 Project 分页查询 WorkflowRun 的接口。本页面继续只声明异步
`WorkflowHistoryReader.listByProject(projectId)` 边界,不提供假数据或 localStorage 降级。
待 WorkflowRunStore 的真实适配器合并并由 App 装配后,再注册路由和导航入口。

## 模块边界

History 可以读取 `@/entities` 的公开 WorkflowRun 类型,但不得推进节点、生成资产、修改审核结果,
也不得依赖单条 Run 的 WorkflowController。

## 验证

```bash
npm test -- src/pages/history
npm run typecheck
npm run lint
npm run build
```
118 changes: 118 additions & 0 deletions frontend/src/pages/history/index.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<MemoryRouter initialEntries={[path]}>
<Routes>
<Route path="/projects/:projectId/history" element={<HistoryPage reader={source} />} />
</Routes>
</MemoryRouter>,
)
}

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()
})
})
214 changes: 214 additions & 0 deletions frontend/src/pages/history/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
import { useEffect, useMemo, useState } from 'react'
import { Link, useParams } from 'react-router'

import type { WorkflowNode, WorkflowRun } from '@/entities'

/**
* 后端 PR #176 已提供按项目分页查询 WorkflowRun 的接口;本模块仍只依赖读取边界。
* 待 WorkflowRunStore 合并并由 App 装配后再开放入口,页面不依赖单 Run 的 Controller。
*/
export interface WorkflowHistoryReader {
listByProject(projectId: string): Promise<readonly WorkflowRun[]>
}

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<Record<DerivedRunState, string>> = {
active: '进行中',
failed: '失败',
completed: '已完成',
}

const RUN_STATUS_STYLES: Readonly<Record<DerivedRunState, string>> = {
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<Record<string, string>> = {
character: '角色制作',
action: '动作制作',
'character-setup': '角色设定',
'character-template': '角色母版',
'action-first-frame': '动作首帧',
'action-generation-method': '资产生成方式',
'action-full-frame': '完整动画',
review: '动作审核',
}

const NODE_STATUS_LABELS: Readonly<Record<WorkflowNode['status'], string>> = {
locked: '等待上游',
active: '进行中',
passed: '已完成',
failed: '失败',
}

/** 只读展示 WorkflowRun 当前节点图;不恢复旧 Revision、Step 或 driver 概念。 */
export function HistoryPage({ reader }: HistoryPageProps) {
const { projectId = '' } = useParams()
const [runs, setRuns] = useState<WorkflowRun[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(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 (
<section className="mx-auto w-full max-w-5xl px-6 py-8" aria-labelledby="history-title">
<header className="border-b border-slate-200 pb-6">
<p className="text-xs font-semibold text-slate-500">HISTORY</p>
<h1 id="history-title" className="mt-2 text-3xl font-semibold text-slate-950">
创作历史
</h1>
<p className="mt-2 text-sm text-slate-600">查看每条工作流当前保存的节点进度。</p>
</header>

{loading ? (
<p role="status" className="py-10 text-sm text-slate-600">
正在读取历史记录...
</p>
) : error !== null ? (
<p
role="alert"
className="mt-6 border border-rose-200 bg-rose-50 p-4 text-sm text-rose-800"
>
{error}
</p>
) : runs.length === 0 ? (
<div className="mt-8 border border-dashed border-slate-300 p-10 text-center">
<h2 className="text-base font-semibold text-slate-900">还没有创作记录</h2>
<p className="mt-2 text-sm text-slate-600">
History 暂未接入产品入口;后端列表接口确定后再启用。
</p>
</div>
) : (
<div className="mt-8 space-y-10">
{groupedRuns.map((section) =>
section.runs.length > 0 ? (
<section key={section.state} aria-labelledby={`history-${section.state}`}>
<div className="mb-3 flex items-center gap-2">
<h2
id={`history-${section.state}`}
className="text-sm font-semibold text-slate-900"
>
{section.title}
</h2>
<span className="text-xs text-slate-500">{section.runs.length}</span>
</div>
<div className="space-y-3">
{section.runs.map((run) => (
<RunCard key={run.id} run={run} />
))}
</div>
</section>
) : null,
)}
</div>
)}
</section>
)
}

function RunCard({ run }: { run: WorkflowRun }) {
const state = deriveRunState(run)
const passedCount = run.nodes.filter((node) => node.status === 'passed').length

return (
<article data-testid="history-run" className="border border-slate-200 bg-white p-5">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<span className={`border px-2 py-0.5 text-xs font-medium ${RUN_STATUS_STYLES[state]}`}>
{RUN_STATUS_LABELS[state]}
</span>
<h3 className="mt-2 text-base font-semibold text-slate-950">工作流 {shortId(run.id)}</h3>
<p className="mt-1 text-xs text-slate-500">
版本 {run.version} · 节点 {passedCount} / {run.nodes.length}
</p>
</div>
<Link
to={`/workflow-editor/${encodeURIComponent(run.id)}`}
className="border border-slate-300 px-3 py-2 text-xs font-semibold text-slate-800 hover:border-slate-500"
>
{state === 'completed' ? '查看记录' : '继续任务'}
</Link>
</div>

<ol className="mt-4 grid gap-2 sm:grid-cols-2">
{run.nodes.map((node) => (
<li
key={node.id}
className="flex items-center justify-between gap-3 bg-slate-50 px-3 py-2 text-xs"
>
<span className="text-slate-800">{NODE_LABELS[node.type] ?? node.type}</span>
<span className="text-slate-500">{NODE_STATUS_LABELS[node.status]}</span>
</li>
))}
</ol>
</article>
)
}

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
}