feat: rewrite review queue as dense table
This commit is contained in:
86
src/features/reviews/components/review-filters.tsx
Normal file
86
src/features/reviews/components/review-filters.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<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 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>
|
||||
) : null}
|
||||
);
|
||||
}
|
||||
|
||||
{!isLoading && error ? (
|
||||
<div className="rounded-[24px] border border-[#b88472] bg-[#f8ece5] px-5 py-4 text-sm text-[#7f4b3b]">
|
||||
if (error) {
|
||||
return (
|
||||
<div className="rounded-[var(--panel-radius)] border border-[#b88472] bg-[#f8ece5] px-5 py-4 text-sm text-[#7f4b3b]">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
);
|
||||
}
|
||||
|
||||
{!isLoading && !error && queue?.state.kind === "business-empty" ? (
|
||||
if (queue?.state.kind === "business-empty") {
|
||||
return (
|
||||
<EmptyState
|
||||
eyebrow="Queue empty"
|
||||
title={queue.state.title}
|
||||
description={queue.state.description}
|
||||
/>
|
||||
) : null}
|
||||
);
|
||||
}
|
||||
|
||||
{!isLoading && !error && queue?.items.length ? (
|
||||
<div className="space-y-3">
|
||||
if (!queue?.items.length) {
|
||||
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) => (
|
||||
<Link
|
||||
key={item.reviewTaskId}
|
||||
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)]",
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="space-y-2">
|
||||
<CardEyebrow>Order #{item.orderId}</CardEyebrow>
|
||||
<TableRow key={item.reviewTaskId}>
|
||||
<TableCell className="font-medium">#{item.orderId}</TableCell>
|
||||
<TableCell>
|
||||
<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>{item.workflowId}</div>
|
||||
{item.workflowType ? (
|
||||
<div className="text-xs text-[var(--ink-muted)]">{item.workflowType}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge status={item.status} />
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center gap-3 text-xs text-[var(--ink-muted)]">
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-col gap-1">
|
||||
<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}
|
||||
<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.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>
|
||||
) : null}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
{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}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</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.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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user