feat: rewrite review queue as dense table

This commit is contained in:
afei A
2026-03-28 00:18:50 +08:00
parent 025ae31f9f
commit f2deb54f3a
4 changed files with 276 additions and 91 deletions

View File

@@ -0,0 +1,86 @@
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { PageToolbar } from "@/components/ui/page-toolbar";
import { Select } from "@/components/ui/select";
import type { ReviewTaskStatus } from "@/lib/types/backend";
export type ReviewStatusFilter = "all" | "waiting_review";
export type ReviewRevisionFilter =
| "all"
| "none"
| "revision_uploaded"
| "pending_manual_confirm";
type ReviewFiltersProps = {
query: string;
revisionFilter: ReviewRevisionFilter;
statusFilter: ReviewStatusFilter;
onQueryChange: (value: string) => void;
onRevisionFilterChange: (value: ReviewRevisionFilter) => void;
onStatusFilterChange: (value: ReviewStatusFilter) => void;
onRefresh: () => void;
};
const REVISION_LABELS: Record<ReviewTaskStatus, string> = {
pending: "无修订稿",
revision_uploaded: "已上传修订稿",
submitted: "已提交",
};
export function getRevisionFilterLabel(
reviewTaskStatus: ReviewTaskStatus,
pendingManualConfirm: boolean,
) {
if (pendingManualConfirm) {
return "修订待确认";
}
return REVISION_LABELS[reviewTaskStatus];
}
export function ReviewFilters({
query,
revisionFilter,
statusFilter,
onQueryChange,
onRevisionFilterChange,
onStatusFilterChange,
onRefresh,
}: ReviewFiltersProps) {
return (
<PageToolbar>
<Input
aria-label="审核关键词搜索"
className="min-w-[220px] flex-1 md:max-w-[320px]"
placeholder="搜索订单号或 workflowId"
value={query}
onChange={(event) => onQueryChange(event.target.value)}
/>
<Select
aria-label="审核状态筛选"
value={statusFilter}
onChange={(event) =>
onStatusFilterChange(event.target.value as ReviewStatusFilter)
}
>
<option value="all"></option>
<option value="waiting_review"></option>
</Select>
<Select
aria-label="修订状态筛选"
value={revisionFilter}
onChange={(event) =>
onRevisionFilterChange(event.target.value as ReviewRevisionFilter)
}
>
<option value="all"></option>
<option value="none">稿</option>
<option value="revision_uploaded">稿</option>
<option value="pending_manual_confirm"></option>
</Select>
<Button variant="secondary" onClick={onRefresh}>
</Button>
</PageToolbar>
);
}

View File

@@ -1,9 +1,15 @@
import Link from "next/link";
import { Card, CardContent, CardEyebrow, CardHeader } from "@/components/ui/card";
import { EmptyState } from "@/components/ui/empty-state";
import { SectionTitle } from "@/components/ui/section-title";
import { StatusBadge } from "@/components/ui/status-badge";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import type { ReviewQueueVM } from "@/lib/types/view-models";
type ReviewQueueProps = {
@@ -25,88 +31,119 @@ export function ReviewQueue({
isLoading,
queue,
}: ReviewQueueProps) {
if (isLoading) {
return (
<div className="rounded-[var(--panel-radius)] border border-dashed border-[var(--border-strong)] bg-[var(--surface-muted)] px-5 py-6 text-sm text-[var(--ink-muted)]">
</div>
);
}
if (error) {
return (
<div className="rounded-[var(--panel-radius)] border border-[#b88472] bg-[#f8ece5] px-5 py-4 text-sm text-[#7f4b3b]">
{error}
</div>
);
}
if (queue?.state.kind === "business-empty") {
return (
<EmptyState
eyebrow="Queue empty"
title={queue.state.title}
description={queue.state.description}
/>
);
}
if (!queue?.items.length) {
return null;
}
return (
<Card className="h-full">
<CardHeader>
<SectionTitle
eyebrow="Pending queue"
title="待审核队列"
description="只展示当前后端真实返回的待审核订单,动作提交后再以非乐观方式刷新队列。"
/>
</CardHeader>
<CardContent className="space-y-4">
{isLoading ? (
<div className="rounded-[24px] border border-dashed border-[var(--border-strong)] bg-[var(--surface-muted)] px-5 py-6 text-sm text-[var(--ink-muted)]">
</div>
) : null}
{!isLoading && error ? (
<div className="rounded-[24px] border border-[#b88472] bg-[#f8ece5] px-5 py-4 text-sm text-[#7f4b3b]">
{error}
</div>
) : null}
{!isLoading && !error && queue?.state.kind === "business-empty" ? (
<EmptyState
eyebrow="Queue empty"
title={queue.state.title}
description={queue.state.description}
/>
) : null}
{!isLoading && !error && queue?.items.length ? (
<div className="space-y-3">
{queue.items.map((item) => (
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead>workflowId</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{queue.items.map((item) => (
<TableRow key={item.reviewTaskId}>
<TableCell className="font-medium">#{item.orderId}</TableCell>
<TableCell>
<div className="space-y-1">
<div>{item.workflowId}</div>
{item.workflowType ? (
<div className="text-xs text-[var(--ink-muted)]">{item.workflowType}</div>
) : null}
</div>
</TableCell>
<TableCell>
<StatusBadge status={item.status} />
</TableCell>
<TableCell>
<div className="flex flex-col gap-1">
<StatusBadge variant="workflowStep" status={item.currentStep} />
<span className="text-xs text-[var(--ink-muted)]">{item.currentStepLabel}</span>
</div>
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-2">
{item.pendingManualConfirm ? (
<span className="rounded-full bg-[rgba(88,106,56,0.12)] px-2 py-0.5 font-[var(--font-mono)] text-[11px] uppercase tracking-[0.14em] text-[#50633b]">
</span>
) : null}
{item.latestRevisionAssetId ? (
<span className="rounded-full bg-[rgba(57,86,95,0.1)] px-2 py-0.5 font-[var(--font-mono)] text-[11px] uppercase tracking-[0.14em] text-[#2e4d56]">
v{item.latestRevisionVersion ?? "?"}
</span>
) : (
<span className="text-xs text-[var(--ink-muted)]">稿</span>
)}
{item.hasMockAssets ? (
<span className="rounded-full bg-[rgba(145,104,46,0.12)] px-2 py-0.5 font-[var(--font-mono)] text-[11px] uppercase tracking-[0.14em] text-[#7a5323]">
Mock
</span>
) : null}
</div>
</TableCell>
<TableCell>
{item.failureCount > 0 ? (
<span className="rounded-full bg-[rgba(140,74,67,0.12)] px-2 py-0.5 font-[var(--font-mono)] text-[11px] uppercase tracking-[0.14em] text-[#7f3f38]">
{item.failureCount}
</span>
) : (
<span className="text-xs text-[var(--ink-muted)]">0</span>
)}
</TableCell>
<TableCell className="text-xs text-[var(--ink-muted)]">
{formatTimestamp(item.createdAt)}
</TableCell>
<TableCell className="text-right">
<Link
key={item.reviewTaskId}
key={item.orderId}
href={`/reviews/workbench/${item.orderId}`}
className={joinClasses(
"block w-full rounded-[24px] border px-4 py-4 text-left transition duration-150",
"border-[var(--border-soft)] bg-[var(--surface-muted)] hover:border-[var(--border-strong)] hover:bg-[var(--surface)]",
"inline-flex h-9 items-center rounded-md border border-[var(--border-strong)] px-3 text-sm text-[var(--ink-strong)] transition",
"hover:bg-[var(--surface-muted)]",
)}
>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-2">
<CardEyebrow>Order #{item.orderId}</CardEyebrow>
<div className="space-y-1">
<h3 className="text-base font-semibold tracking-[-0.02em] text-[var(--ink-strong)]">
#{item.orderId}
</h3>
<p className="text-sm text-[var(--ink-muted)]">
{item.workflowId}
{item.workflowType ? ` / ${item.workflowType}` : ""}
</p>
</div>
</div>
<StatusBadge status={item.status} />
</div>
<div className="mt-4 flex flex-wrap items-center gap-3 text-xs text-[var(--ink-muted)]">
<StatusBadge variant="workflowStep" status={item.currentStep} />
<span>{item.currentStepLabel}</span>
<span>{formatTimestamp(item.createdAt)}</span>
{item.hasMockAssets ? (
<span className="rounded-full bg-[rgba(145,104,46,0.12)] px-2.5 py-1 font-[var(--font-mono)] uppercase tracking-[0.18em] text-[#7a5323]">
Mock
</span>
) : null}
{item.failureCount > 0 ? (
<span className="rounded-full bg-[rgba(140,74,67,0.12)] px-2.5 py-1 font-[var(--font-mono)] uppercase tracking-[0.18em] text-[#7f3f38]">
{item.failureCount}
</span>
) : null}
{item.pendingManualConfirm ? (
<span className="rounded-full bg-[rgba(88,106,56,0.12)] px-2.5 py-1 font-[var(--font-mono)] uppercase tracking-[0.18em] text-[#50633b]">
</span>
) : null}
</div>
</Link>
))}
</div>
) : null}
</CardContent>
</Card>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
}

View File

@@ -1,8 +1,13 @@
"use client";
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { PageHeader } from "@/components/ui/page-header";
import {
ReviewFilters,
type ReviewRevisionFilter,
type ReviewStatusFilter,
} from "@/features/reviews/components/review-filters";
import { ReviewQueue } from "@/features/reviews/components/review-queue";
import type { ReviewQueueVM } from "@/lib/types/view-models";
@@ -19,6 +24,11 @@ export function ReviewWorkbenchListScreen() {
const [queue, setQueue] = useState<ReviewQueueVM | null>(null);
const [queueError, setQueueError] = useState<string | null>(null);
const [isLoadingQueue, setIsLoadingQueue] = useState(true);
const [query, setQuery] = useState("");
const [statusFilter, setStatusFilter] = useState<ReviewStatusFilter>("all");
const [revisionFilter, setRevisionFilter] =
useState<ReviewRevisionFilter>("all");
const [reloadKey, setReloadKey] = useState(0);
useEffect(() => {
let active = true;
@@ -59,21 +69,72 @@ export function ReviewWorkbenchListScreen() {
return () => {
active = false;
};
}, []);
}, [reloadKey]);
const filteredQueue = useMemo(() => {
if (!queue) {
return null;
}
const normalizedQuery = query.trim().toLowerCase();
const nextItems = queue.items.filter((item) => {
const matchesQuery =
!normalizedQuery ||
String(item.orderId).includes(normalizedQuery) ||
item.workflowId.toLowerCase().includes(normalizedQuery);
const matchesStatus =
statusFilter === "all" ? true : item.status === statusFilter;
const matchesRevision =
revisionFilter === "all"
? true
: revisionFilter === "none"
? !item.latestRevisionAssetId
: revisionFilter === "revision_uploaded"
? item.reviewTaskStatus === "revision_uploaded"
: item.pendingManualConfirm;
return matchesQuery && matchesStatus && matchesRevision;
});
return {
...queue,
items: nextItems,
state:
nextItems.length > 0
? queue.state
: {
kind: "business-empty" as const,
title: "当前筛选下没有待处理任务",
description: "调整关键词或修订状态后再试,或者刷新队列同步后端最新状态。",
},
};
}, [query, queue, revisionFilter, statusFilter]);
return (
<section className="space-y-8">
<section className="space-y-6">
<PageHeader
eyebrow="Human review queue"
title="审核工作台"
description="列表页只负责展示待审核队列,点击具体订单后再进入独立详情页处理资产、动作和流程摘要。"
meta="先看列表,再进详情"
description="先在队列里筛选任务,再进入详情页执行审核或人工修订,列表本身不承载动作面板。"
meta="队列 -> 决策详情"
/>
<ReviewFilters
query={query}
revisionFilter={revisionFilter}
statusFilter={statusFilter}
onQueryChange={setQuery}
onRevisionFilterChange={setRevisionFilter}
onStatusFilterChange={setStatusFilter}
onRefresh={() => setReloadKey((value) => value + 1)}
/>
<ReviewQueue
error={queueError}
isLoading={isLoadingQueue}
queue={queue}
queue={filteredQueue}
/>
</section>
);

View File

@@ -36,7 +36,7 @@ afterEach(() => {
vi.unstubAllGlobals();
});
test("renders medium-density list rows that link into independent review detail pages", async () => {
test("renders a compact review queue table with triage columns", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify(createPendingPayload()), {
status: 200,
@@ -50,13 +50,14 @@ test("renders medium-density list rows that link into independent review detail
render(<ReviewWorkbenchListScreen />);
expect(await screen.findByText("审核目标 #101")).toBeInTheDocument();
expect(screen.getByText(/工作流 wf-101/)).toBeInTheDocument();
expect(screen.getByText("Mock 资产")).toBeInTheDocument();
expect(screen.getByText("失败 2")).toBeInTheDocument();
expect(screen.queryByText("审核动作面板")).not.toBeInTheDocument();
expect(screen.getByRole("link", { name: /审核目标 #101/ })).toHaveAttribute(
expect(await screen.findByRole("columnheader", { name: "订单号" })).toBeInTheDocument();
expect(screen.getByRole("columnheader", { name: "修订状态" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "刷新队列" })).toBeInTheDocument();
expect(screen.getByRole("link", { name: "进入详情" })).toHaveAttribute(
"href",
"/reviews/workbench/101",
);
expect(screen.getByText("Mock 资产")).toBeInTheDocument();
expect(screen.getByText("失败 2")).toBeInTheDocument();
expect(screen.queryByText("审核动作面板")).not.toBeInTheDocument();
});