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 Link from "next/link";
import { Card, CardContent, CardEyebrow, CardHeader } from "@/components/ui/card";
import { EmptyState } from "@/components/ui/empty-state"; import { EmptyState } from "@/components/ui/empty-state";
import { SectionTitle } from "@/components/ui/section-title";
import { StatusBadge } from "@/components/ui/status-badge"; 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"; import type { ReviewQueueVM } from "@/lib/types/view-models";
type ReviewQueueProps = { type ReviewQueueProps = {
@@ -25,88 +31,119 @@ export function ReviewQueue({
isLoading, isLoading,
queue, queue,
}: ReviewQueueProps) { }: ReviewQueueProps) {
if (isLoading) {
return ( return (
<Card className="h-full"> <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)]">
<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> </div>
) : null} );
}
{!isLoading && error ? ( if (error) {
<div className="rounded-[24px] border border-[#b88472] bg-[#f8ece5] px-5 py-4 text-sm text-[#7f4b3b]"> return (
<div className="rounded-[var(--panel-radius)] border border-[#b88472] bg-[#f8ece5] px-5 py-4 text-sm text-[#7f4b3b]">
{error} {error}
</div> </div>
) : null} );
}
{!isLoading && !error && queue?.state.kind === "business-empty" ? ( if (queue?.state.kind === "business-empty") {
return (
<EmptyState <EmptyState
eyebrow="Queue empty" eyebrow="Queue empty"
title={queue.state.title} title={queue.state.title}
description={queue.state.description} description={queue.state.description}
/> />
) : null} );
}
{!isLoading && !error && queue?.items.length ? ( if (!queue?.items.length) {
<div className="space-y-3"> return null;
}
return (
<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) => ( {queue.items.map((item) => (
<Link <TableRow key={item.reviewTaskId}>
key={item.reviewTaskId} <TableCell className="font-medium">#{item.orderId}</TableCell>
href={`/reviews/workbench/${item.orderId}`} <TableCell>
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)]",
)}
>
<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"> <div className="space-y-1">
<h3 className="text-base font-semibold tracking-[-0.02em] text-[var(--ink-strong)]"> <div>{item.workflowId}</div>
#{item.orderId} {item.workflowType ? (
</h3> <div className="text-xs text-[var(--ink-muted)]">{item.workflowType}</div>
<p className="text-sm text-[var(--ink-muted)]"> ) : null}
{item.workflowId}
{item.workflowType ? ` / ${item.workflowType}` : ""}
</p>
</div>
</div> </div>
</TableCell>
<TableCell>
<StatusBadge status={item.status} /> <StatusBadge status={item.status} />
</div> </TableCell>
<TableCell>
<div className="mt-4 flex flex-wrap items-center gap-3 text-xs text-[var(--ink-muted)]"> <div className="flex flex-col gap-1">
<StatusBadge variant="workflowStep" status={item.currentStep} /> <StatusBadge variant="workflowStep" status={item.currentStep} />
<span>{item.currentStepLabel}</span> <span className="text-xs text-[var(--ink-muted)]">{item.currentStepLabel}</span>
<span>{formatTimestamp(item.createdAt)}</span> </div>
{item.hasMockAssets ? ( </TableCell>
<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]"> <TableCell>
Mock <div className="flex flex-wrap gap-2">
</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 ? ( {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 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> </span>
) : null} ) : null}
</div> {item.latestRevisionAssetId ? (
</Link> <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 ?? "?"}
</div> </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} ) : null}
</CardContent> </div>
</Card> </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.orderId}
href={`/reviews/workbench/${item.orderId}`}
className={joinClasses(
"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)]",
)}
>
</Link>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
); );
} }

View File

@@ -1,8 +1,13 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { PageHeader } from "@/components/ui/page-header"; 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 { ReviewQueue } from "@/features/reviews/components/review-queue";
import type { ReviewQueueVM } from "@/lib/types/view-models"; import type { ReviewQueueVM } from "@/lib/types/view-models";
@@ -19,6 +24,11 @@ export function ReviewWorkbenchListScreen() {
const [queue, setQueue] = useState<ReviewQueueVM | null>(null); const [queue, setQueue] = useState<ReviewQueueVM | null>(null);
const [queueError, setQueueError] = useState<string | null>(null); const [queueError, setQueueError] = useState<string | null>(null);
const [isLoadingQueue, setIsLoadingQueue] = useState(true); 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(() => { useEffect(() => {
let active = true; let active = true;
@@ -59,21 +69,72 @@ export function ReviewWorkbenchListScreen() {
return () => { return () => {
active = false; 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 ( return (
<section className="space-y-8"> <section className="space-y-6">
<PageHeader <PageHeader
eyebrow="Human review queue" eyebrow="Human review queue"
title="审核工作台" title="审核工作台"
description="列表页只负责展示待审核队列,点击具体订单后再进入独立详情页处理资产、动作和流程摘要。" description="先在队列里筛选任务,再进入详情页执行审核或人工修订,列表本身不承载动作面板。"
meta="先看列表,再进详情" meta="队列 -> 决策详情"
/>
<ReviewFilters
query={query}
revisionFilter={revisionFilter}
statusFilter={statusFilter}
onQueryChange={setQuery}
onRevisionFilterChange={setRevisionFilter}
onStatusFilterChange={setStatusFilter}
onRefresh={() => setReloadKey((value) => value + 1)}
/> />
<ReviewQueue <ReviewQueue
error={queueError} error={queueError}
isLoading={isLoadingQueue} isLoading={isLoadingQueue}
queue={queue} queue={filteredQueue}
/> />
</section> </section>
); );

View File

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