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 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 (
|
||||||
|
<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 (
|
return (
|
||||||
<Card className="h-full">
|
<Table>
|
||||||
<CardHeader>
|
<TableHeader>
|
||||||
<SectionTitle
|
<TableRow>
|
||||||
eyebrow="Pending queue"
|
<TableHead>订单号</TableHead>
|
||||||
title="待审核队列"
|
<TableHead>workflowId</TableHead>
|
||||||
description="只展示当前后端真实返回的待审核订单,动作提交后再以非乐观方式刷新队列。"
|
<TableHead>当前状态</TableHead>
|
||||||
/>
|
<TableHead>当前步骤</TableHead>
|
||||||
</CardHeader>
|
<TableHead>修订状态</TableHead>
|
||||||
<CardContent className="space-y-4">
|
<TableHead>失败次数</TableHead>
|
||||||
{isLoading ? (
|
<TableHead>更新时间</TableHead>
|
||||||
<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)]">
|
<TableHead className="text-right">操作</TableHead>
|
||||||
正在加载待审核队列…
|
</TableRow>
|
||||||
</div>
|
</TableHeader>
|
||||||
) : null}
|
<TableBody>
|
||||||
|
{queue.items.map((item) => (
|
||||||
{!isLoading && error ? (
|
<TableRow key={item.reviewTaskId}>
|
||||||
<div className="rounded-[24px] border border-[#b88472] bg-[#f8ece5] px-5 py-4 text-sm text-[#7f4b3b]">
|
<TableCell className="font-medium">#{item.orderId}</TableCell>
|
||||||
{error}
|
<TableCell>
|
||||||
</div>
|
<div className="space-y-1">
|
||||||
) : null}
|
<div>{item.workflowId}</div>
|
||||||
|
{item.workflowType ? (
|
||||||
{!isLoading && !error && queue?.state.kind === "business-empty" ? (
|
<div className="text-xs text-[var(--ink-muted)]">{item.workflowType}</div>
|
||||||
<EmptyState
|
) : null}
|
||||||
eyebrow="Queue empty"
|
</div>
|
||||||
title={queue.state.title}
|
</TableCell>
|
||||||
description={queue.state.description}
|
<TableCell>
|
||||||
/>
|
<StatusBadge status={item.status} />
|
||||||
) : null}
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
{!isLoading && !error && queue?.items.length ? (
|
<div className="flex flex-col gap-1">
|
||||||
<div className="space-y-3">
|
<StatusBadge variant="workflowStep" status={item.currentStep} />
|
||||||
{queue.items.map((item) => (
|
<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
|
<Link
|
||||||
key={item.reviewTaskId}
|
key={item.orderId}
|
||||||
href={`/reviews/workbench/${item.orderId}`}
|
href={`/reviews/workbench/${item.orderId}`}
|
||||||
className={joinClasses(
|
className={joinClasses(
|
||||||
"block w-full rounded-[24px] border px-4 py-4 text-left transition duration-150",
|
"inline-flex h-9 items-center rounded-md border border-[var(--border-strong)] px-3 text-sm text-[var(--ink-strong)] transition",
|
||||||
"border-[var(--border-soft)] bg-[var(--surface-muted)] hover:border-[var(--border-strong)] hover:bg-[var(--surface)]",
|
"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>
|
</Link>
|
||||||
))}
|
</TableCell>
|
||||||
</div>
|
</TableRow>
|
||||||
) : null}
|
))}
|
||||||
</CardContent>
|
</TableBody>
|
||||||
</Card>
|
</Table>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user