feat: redesign reviews page with left-tree right-detail layout
- Left sidebar: tree menu showing all reviews grouped by contract type, review status, and risk level (each review is a leaf node) - Right panel: selected review's detail including status, risk level, report download, and action suggestions - "New Review" button links to /review page - Modern card-based styling consistent with other pages Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+508
-313
@@ -1,11 +1,33 @@
|
||||
"use client";
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Button, Card, Empty, Input, Select, Space, Table, Tag, Typography, message } from "antd";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
|
||||
import { SearchOutlined } from "@ant-design/icons";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Drawer,
|
||||
Empty,
|
||||
Input,
|
||||
message,
|
||||
Select,
|
||||
Space,
|
||||
Spin,
|
||||
Tag,
|
||||
Tree,
|
||||
Typography,
|
||||
} from "antd";
|
||||
import type { DataNode } from "antd/es/tree";
|
||||
import {
|
||||
CaretDownOutlined,
|
||||
CaretRightOutlined,
|
||||
DownloadOutlined,
|
||||
FileTextOutlined,
|
||||
PlusOutlined,
|
||||
RedoOutlined,
|
||||
SearchOutlined,
|
||||
WarningOutlined,
|
||||
} from "@ant-design/icons";
|
||||
|
||||
import {
|
||||
formatContractType,
|
||||
@@ -19,13 +41,10 @@ import {
|
||||
import { downloadReviewReport, getReviewReport, getReviews, retryReview, type ReviewListItem } from "@/services/review";
|
||||
import { useAuthStore } from "@/stores/auth.store";
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
const { Title, Text, Paragraph } = Typography;
|
||||
|
||||
function formatStageLabel(value: string | undefined) {
|
||||
if (!value) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
if (!value) return "-";
|
||||
const stageLabelMap: Record<string, string> = {
|
||||
parse: "文本解析",
|
||||
review: "规则审查",
|
||||
@@ -34,145 +53,193 @@ function formatStageLabel(value: string | undefined) {
|
||||
failed: "处理失败",
|
||||
processing: "处理中",
|
||||
};
|
||||
|
||||
return stageLabelMap[value] ?? value;
|
||||
}
|
||||
|
||||
function getStageTextType(item: ReviewListItem) {
|
||||
if (item.failure_reason) {
|
||||
return "danger" as const;
|
||||
}
|
||||
if (item.status === "partial") {
|
||||
return "warning" as const;
|
||||
}
|
||||
if (item.failure_reason) return "danger" as const;
|
||||
if (item.status === "partial") return "warning" as const;
|
||||
return "secondary" as const;
|
||||
}
|
||||
|
||||
function getStageDisplayText(item: ReviewListItem) {
|
||||
if (item.status === "partial" && item.current_stage_label) {
|
||||
return `${item.current_stage_label} · 需复核`;
|
||||
}
|
||||
if (item.status === "partial" && item.current_stage_label) return `${item.current_stage_label} · 需复核`;
|
||||
return item.current_stage_label ?? formatStageLabel(item.current_stage);
|
||||
}
|
||||
|
||||
const ALL_KEY = "__all__";
|
||||
|
||||
const statusLabelMap: Record<string, string> = {
|
||||
pending: "待处理",
|
||||
processing: "处理中",
|
||||
completed: "已完成",
|
||||
partial: "部分完成",
|
||||
failed: "失败",
|
||||
};
|
||||
|
||||
const riskLabelMap: Record<string, string> = {
|
||||
high: "高风险",
|
||||
medium: "中风险",
|
||||
low: "低风险",
|
||||
};
|
||||
|
||||
export default function ReviewsPage() {
|
||||
const [messageApi, contextHolder] = message.useMessage();
|
||||
const router = useRouter();
|
||||
const currentUser = useAuthStore((state) => state.currentUser);
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [items, setItems] = useState<ReviewListItem[]>([]);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [expandedKeys, setExpandedKeys] = useState<React.Key[]>([ALL_KEY]);
|
||||
|
||||
const [reportLoadingId, setReportLoadingId] = useState<string | null>(null);
|
||||
const [retryingReviewId, setRetryingReviewId] = useState<string | null>(null);
|
||||
const [items, setItems] = useState<ReviewListItem[]>([]);
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState<string>("all");
|
||||
const [riskFilter, setRiskFilter] = useState<string>("all");
|
||||
const [viewMode, setViewMode] = useState<string>("all");
|
||||
const [reportReadyMap, setReportReadyMap] = useState<Record<string, boolean>>({});
|
||||
const [reportFileMap, setReportFileMap] = useState<Record<string, string | null>>({});
|
||||
|
||||
const completedCount = items.filter((item) => item.status === "completed").length;
|
||||
const highRiskCount = items.filter((item) => item.risk_level === "high").length;
|
||||
const retryableCount = items.filter((item) => item.can_retry).length;
|
||||
const latestCreatedAt = items[0]?.created_at;
|
||||
const selectedReview = useMemo(() => items.find((item) => item.id === selectedId) ?? null, [items, selectedId]);
|
||||
|
||||
const filteredItems = useMemo(() => {
|
||||
const normalizedKeyword = keyword.trim().toLowerCase();
|
||||
if (!searchQuery.trim()) return items;
|
||||
const q = searchQuery.toLowerCase();
|
||||
return items.filter(
|
||||
(item) =>
|
||||
item.title.toLowerCase().includes(q) ||
|
||||
(item.contract_type ?? "").toLowerCase().includes(q) ||
|
||||
(item.review_as ?? "").toLowerCase().includes(q) ||
|
||||
(item.risk_level ?? "").toLowerCase().includes(q),
|
||||
);
|
||||
}, [items, searchQuery]);
|
||||
|
||||
return items.filter((item) => {
|
||||
const matchesStatus = statusFilter === "all" || item.status === statusFilter;
|
||||
const matchesRisk = riskFilter === "all" || item.risk_level === riskFilter;
|
||||
const matchesViewMode =
|
||||
viewMode === "all" ||
|
||||
(viewMode === "can_retry" && item.can_retry) ||
|
||||
(viewMode === "report_ready" && reportReadyMap[item.id]) ||
|
||||
(viewMode === "high_priority" && (item.risk_level === "high" || item.status === "partial" || item.status === "failed"));
|
||||
const matchesKeyword =
|
||||
!normalizedKeyword ||
|
||||
item.title.toLowerCase().includes(normalizedKeyword) ||
|
||||
formatContractType(item.contract_type).toLowerCase().includes(normalizedKeyword) ||
|
||||
formatReviewAs(item.review_as).toLowerCase().includes(normalizedKeyword) ||
|
||||
formatStatus(item.status).toLowerCase().includes(normalizedKeyword) ||
|
||||
formatRisk(item.risk_level).toLowerCase().includes(normalizedKeyword);
|
||||
const treeData = useMemo((): DataNode[] => {
|
||||
const contractTypes = Array.from(new Set(items.map((i) => i.contract_type).filter(Boolean)));
|
||||
const statuses = Array.from(new Set(items.map((i) => i.status).filter(Boolean)));
|
||||
const risks = Array.from(new Set(items.map((i) => i.risk_level).filter((v): v is string => Boolean(v))));
|
||||
|
||||
return matchesStatus && matchesRisk && matchesViewMode && matchesKeyword;
|
||||
const typeChildren: DataNode[] = contractTypes.map((type) => {
|
||||
const reviewsInType = filteredItems.filter((i) => i.contract_type === type);
|
||||
return {
|
||||
key: `type:${type}`,
|
||||
title: (
|
||||
<Space>
|
||||
<span>{formatContractType(type)}</span>
|
||||
<Tag color="default" style={{ margin: 0, fontSize: 11 }}>{reviewsInType.length}</Tag>
|
||||
</Space>
|
||||
),
|
||||
selectable: false,
|
||||
children: reviewsInType.map((r) => ({
|
||||
key: r.id,
|
||||
title: (
|
||||
<div className="review-tree-item">
|
||||
<span className="review-tree-name">{r.title}</span>
|
||||
<Tag color={getStatusColor(r.status)} style={{ fontSize: 10, marginLeft: 4 }}>
|
||||
{formatStatus(r.status).slice(0, 2)}
|
||||
</Tag>
|
||||
</div>
|
||||
),
|
||||
isLeaf: true,
|
||||
})),
|
||||
};
|
||||
});
|
||||
}, [items, keyword, reportReadyMap, riskFilter, statusFilter, viewMode]);
|
||||
|
||||
const latestFilteredCreatedAt = filteredItems[0]?.created_at;
|
||||
const filteredTaskLabel = latestFilteredCreatedAt ? formatDateTime(latestFilteredCreatedAt) : "暂无匹配记录";
|
||||
const filterSummary = `${filteredItems.length} / ${items.length}`;
|
||||
const statusChildren: DataNode[] = statuses.map((status) => {
|
||||
const reviewsInStatus = filteredItems.filter((i) => i.status === status);
|
||||
return {
|
||||
key: `status:${status}`,
|
||||
title: (
|
||||
<Space>
|
||||
<span>{statusLabelMap[status] || status}</span>
|
||||
<Tag color={getStatusColor(status)} style={{ margin: 0, fontSize: 11 }}>{reviewsInStatus.length}</Tag>
|
||||
</Space>
|
||||
),
|
||||
selectable: false,
|
||||
children: reviewsInStatus.map((r) => ({
|
||||
key: r.id,
|
||||
title: (
|
||||
<div className="review-tree-item">
|
||||
<span className="review-tree-name">{r.title}</span>
|
||||
{r.risk_level && (
|
||||
<Tag color={getRiskColor(r.risk_level)} style={{ fontSize: 10, marginLeft: 4 }}>
|
||||
{riskLabelMap[r.risk_level]?.slice(0, 2) || r.risk_level}
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
isLeaf: true,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
const statusOptions = [
|
||||
{ value: "all", label: "全部状态" },
|
||||
{ value: "pending", label: formatStatus("pending") },
|
||||
{ value: "processing", label: formatStatus("processing") },
|
||||
{ value: "completed", label: formatStatus("completed") },
|
||||
{ value: "partial", label: formatStatus("partial") },
|
||||
{ value: "failed", label: formatStatus("failed") },
|
||||
];
|
||||
const riskChildren: DataNode[] = risks.map((risk) => {
|
||||
const reviewsInRisk = filteredItems.filter((i) => i.risk_level === risk);
|
||||
return {
|
||||
key: `risk:${risk}`,
|
||||
title: (
|
||||
<Space>
|
||||
<span>{riskLabelMap[risk] || risk}</span>
|
||||
<Tag color={getRiskColor(risk)} style={{ margin: 0, fontSize: 11 }}>{reviewsInRisk.length}</Tag>
|
||||
</Space>
|
||||
),
|
||||
selectable: false,
|
||||
children: reviewsInRisk.map((r) => ({
|
||||
key: r.id,
|
||||
title: (
|
||||
<div className="review-tree-item">
|
||||
<span className="review-tree-name">{r.title}</span>
|
||||
<Tag color={getStatusColor(r.status)} style={{ fontSize: 10, marginLeft: 4 }}>
|
||||
{formatStatus(r.status).slice(0, 2)}
|
||||
</Tag>
|
||||
</div>
|
||||
),
|
||||
isLeaf: true,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
const riskOptions = [
|
||||
{ value: "all", label: "全部风险" },
|
||||
{ value: "high", label: formatRisk("high") },
|
||||
{ value: "medium", label: formatRisk("medium") },
|
||||
{ value: "low", label: formatRisk("low") },
|
||||
];
|
||||
return [
|
||||
{
|
||||
key: ALL_KEY,
|
||||
title: (
|
||||
<Space>
|
||||
<span>全部审查</span>
|
||||
<Tag color="blue" style={{ margin: 0, fontSize: 11 }}>{filteredItems.length}</Tag>
|
||||
</Space>
|
||||
),
|
||||
selectable: false,
|
||||
children: [
|
||||
{ key: "group-type", title: <span className="tree-group-title">按合同类型</span>, selectable: false, children: typeChildren },
|
||||
{ key: "group-status", title: <span className="tree-group-title">按审查状态</span>, selectable: false, children: statusChildren },
|
||||
{ key: "group-risk", title: <span className="tree-group-title">按风险等级</span>, selectable: false, children: riskChildren },
|
||||
],
|
||||
},
|
||||
];
|
||||
}, [filteredItems]);
|
||||
|
||||
const viewModeOptions = [
|
||||
{ value: "all", label: "全部任务" },
|
||||
{ value: "high_priority", label: "高优先级" },
|
||||
{ value: "report_ready", label: "报告可下载" },
|
||||
{ value: "can_retry", label: "可重试" },
|
||||
];
|
||||
|
||||
const hasActiveFilter = keyword.trim().length > 0 || statusFilter !== "all" || riskFilter !== "all" || viewMode !== "all";
|
||||
|
||||
const resetFilters = () => {
|
||||
setKeyword("");
|
||||
setStatusFilter("all");
|
||||
setRiskFilter("all");
|
||||
setViewMode("all");
|
||||
const handleTreeSelect = (_selectedKeys: React.Key[], info: { node: DataNode }) => {
|
||||
const key = info.node.key as string;
|
||||
if (key.startsWith("group-") || key === ALL_KEY) return;
|
||||
setSelectedId(key);
|
||||
};
|
||||
|
||||
const historyStats = [
|
||||
{ label: "累计任务", value: items.length, tone: "neutral" },
|
||||
{ label: "已完成", value: completedCount, tone: "info" },
|
||||
{ label: "高风险", value: highRiskCount, tone: "danger" },
|
||||
{ label: "可重试", value: retryableCount, tone: "warning" },
|
||||
];
|
||||
|
||||
const historyTips = [
|
||||
"优先关注高风险、失败和部分完成任务,快速进入详情页继续处理。",
|
||||
"已完成任务可直接从列表下载报告、查看文件资产或进入详情页继续问答。",
|
||||
"列表页现在承担筛选、进入详情和报告下载,不必每次都先点进详情页。",
|
||||
];
|
||||
|
||||
const latestTaskLabel = latestCreatedAt ? formatDateTime(latestCreatedAt) : "暂无记录";
|
||||
|
||||
const primeReportState = useCallback(async (reviewItems: ReviewListItem[]) => {
|
||||
const readyItems = reviewItems.filter((item) => item.status === "completed" || item.status === "partial");
|
||||
const results = await Promise.all(
|
||||
readyItems.map(async (item) => {
|
||||
try {
|
||||
const response = await getReviewReport(item.id);
|
||||
if (response.code !== 0) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id: item.id,
|
||||
fileId: response.data.file_id ?? null,
|
||||
};
|
||||
if (response.code !== 0) return null;
|
||||
return { id: item.id, fileId: response.data.file_id ?? null };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const nextReadyMap: Record<string, boolean> = {};
|
||||
const nextFileMap: Record<string, string | null> = {};
|
||||
results.forEach((item) => {
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
if (!item) return;
|
||||
nextReadyMap[item.id] = true;
|
||||
nextFileMap[item.id] = item.fileId;
|
||||
});
|
||||
@@ -184,20 +251,25 @@ export default function ReviewsPage() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await getReviews();
|
||||
|
||||
if (result.code !== 0) {
|
||||
messageApi.error(result.message || "获取审查记录失败");
|
||||
return;
|
||||
}
|
||||
|
||||
setItems(result.data.items);
|
||||
if (!selectedId && result.data.items.length > 0) {
|
||||
setSelectedId(result.data.items[0].id);
|
||||
}
|
||||
await primeReportState(result.data.items);
|
||||
} catch {
|
||||
messageApi.error("审查记录加载失败,请稍后重试");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [messageApi, primeReportState]);
|
||||
}, [messageApi, primeReportState, selectedId]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadReviews();
|
||||
}, [loadReviews]);
|
||||
|
||||
const handleDownloadReport = async (reviewId: string) => {
|
||||
setReportLoadingId(reviewId);
|
||||
@@ -235,219 +307,342 @@ export default function ReviewsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ColumnsType<ReviewListItem> = [
|
||||
{
|
||||
title: "文件名",
|
||||
dataIndex: "title",
|
||||
key: "title",
|
||||
render: (_value: string, record: ReviewListItem) => <Link href={`/reviews/${record.id}`}>{record.title}</Link>,
|
||||
},
|
||||
{
|
||||
title: "合同类型",
|
||||
dataIndex: "contract_type",
|
||||
key: "contract_type",
|
||||
render: (value: string) => formatContractType(value),
|
||||
},
|
||||
{
|
||||
title: "审查视角",
|
||||
dataIndex: "review_as",
|
||||
key: "review_as",
|
||||
render: (value: string) => formatReviewAs(value),
|
||||
},
|
||||
{
|
||||
title: "状态 / 阶段",
|
||||
dataIndex: "status",
|
||||
key: "status",
|
||||
render: (_value: string, record: ReviewListItem) => (
|
||||
<Space orientation="vertical" size={4}>
|
||||
<Tag color={getStatusColor(record.status)}>{formatStatus(record.status)}</Tag>
|
||||
<Text type={getStageTextType(record)}>{getStageDisplayText(record)}</Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "风险等级",
|
||||
dataIndex: "risk_level",
|
||||
key: "risk_level",
|
||||
render: (value: string | null) => <Tag color={getRiskColor(value)}>{formatRisk(value)}</Tag>,
|
||||
},
|
||||
{
|
||||
title: "当前说明 / 下一步",
|
||||
dataIndex: "status_detail",
|
||||
key: "status_detail",
|
||||
render: (_value: string | undefined, record: ReviewListItem) => (
|
||||
<Space orientation="vertical" size={2}>
|
||||
<Text type="secondary">{record.status_detail || "-"}</Text>
|
||||
{record.failure_reason ? <Text type="danger">失败原因:{record.failure_reason}</Text> : null}
|
||||
{record.next_action ? <Text>下一步:{record.next_action}</Text> : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "列表侧动作",
|
||||
key: "actions",
|
||||
width: 280,
|
||||
render: (_value: unknown, record: ReviewListItem) => {
|
||||
const fileId = reportFileMap[record.id];
|
||||
const canDownload = record.status === "completed" || record.status === "partial";
|
||||
const canChat = record.status === "completed" || record.status === "partial" || record.status === "failed";
|
||||
return (
|
||||
<Space orientation="vertical" size={6}>
|
||||
<Space wrap>
|
||||
<Link href={`/reviews/${record.id}`}>
|
||||
<Button size="small" type={record.status === "completed" ? "primary" : "default"}>
|
||||
<span className="ui-nowrap-label">{record.status === "completed" ? "查看详情" : record.can_retry ? "查看并复核" : "跟踪进度"}</span>
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
size="small"
|
||||
loading={reportLoadingId === record.id}
|
||||
disabled={!canDownload}
|
||||
onClick={() => void handleDownloadReport(record.id)}
|
||||
>
|
||||
<span className="ui-nowrap-label">{reportReadyMap[record.id] ? "下载报告" : canDownload ? "准备报告" : "报告未就绪"}</span>
|
||||
</Button>
|
||||
{currentUser?.role === "admin" && fileId ? (
|
||||
<Link href={`/files?highlight=${fileId}`}>
|
||||
<Button size="small"><span className="ui-nowrap-label">文件资产</span></Button>
|
||||
</Link>
|
||||
) : null}
|
||||
{canChat ? (
|
||||
<Link href={`/reviews/${record.id}`}>
|
||||
<Button size="small"><span className="ui-nowrap-label">继续处理</span></Button>
|
||||
</Link>
|
||||
) : null}
|
||||
{record.can_retry ? (
|
||||
<Button size="small" loading={retryingReviewId === record.id} onClick={() => void handleRetryReview(record.id)}>
|
||||
<span className="ui-nowrap-label">重新发起</span>
|
||||
</Button>
|
||||
) : null}
|
||||
</Space>
|
||||
<Text type="secondary">
|
||||
{record.status === "completed"
|
||||
? "可直接查看详情、下载报告并继续进入处理工作台。"
|
||||
: record.status === "partial"
|
||||
? "结果部分可用,建议先复核详情再决定是否重试。"
|
||||
: record.status === "failed"
|
||||
? "优先查看失败原因与下一步建议。"
|
||||
: "任务仍在处理中,可先进入详情页跟踪状态。"}
|
||||
</Text>
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "创建时间",
|
||||
dataIndex: "created_at",
|
||||
key: "created_at",
|
||||
render: (value: string) => formatDateTime(value),
|
||||
},
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
void loadReviews();
|
||||
}, 0);
|
||||
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [loadReviews]);
|
||||
if (currentUser?.role !== "admin") {
|
||||
return (
|
||||
<main className="page-shell reviews-page">
|
||||
{contextHolder}
|
||||
<div style={{ padding: 48, textAlign: "center" }}>
|
||||
<Text type="secondary">仅管理员可查看审查记录。</Text>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="page-shell review-history-page">
|
||||
<main className="page-shell reviews-page">
|
||||
{contextHolder}
|
||||
<Space orientation="vertical" size="large" style={{ width: "100%" }}>
|
||||
<section className="review-history-hero">
|
||||
<div className="review-page-hero-copy">
|
||||
<span className="landing-badge">Review History</span>
|
||||
<Title level={2}>审查记录不再只是列表,而是任务工作台。</Title>
|
||||
<Text type="secondary">从这里筛选任务、进入详情、下载报告,或继续处理未完成合同。</Text>
|
||||
<div className="review-history-latest">最近一次任务创建时间:{latestTaskLabel}</div>
|
||||
<div className="review-history-filter-summary">当前列表展示:{filterSummary}</div>
|
||||
<div className="review-layout">
|
||||
{/* 左侧树形菜单 */}
|
||||
<aside className="review-sidebar">
|
||||
<div className="review-sidebar-header">
|
||||
<Input
|
||||
allowClear
|
||||
prefix={<SearchOutlined style={{ color: "#8c8c8c" }} />}
|
||||
placeholder="搜索审查记录..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="review-search"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Card className="review-side-card" variant="borderless">
|
||||
<span className="landing-panel-eyebrow">History Insight</span>
|
||||
<Title level={4} style={{ margin: 0, color: "inherit" }}>
|
||||
处理优先级
|
||||
</Title>
|
||||
<ul className="review-page-tip-list">
|
||||
{historyTips.map((item) => (
|
||||
<li key={item}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<div className="review-history-stats">
|
||||
{historyStats.map((item) => (
|
||||
<div className={`review-metric-card review-metric-card-${item.tone}`} key={item.label}>
|
||||
<span>{item.label}</span>
|
||||
<strong>{item.value}</strong>
|
||||
</div>
|
||||
))}
|
||||
<Link className="review-history-cta" href="/review">
|
||||
<span className="landing-card-eyebrow">Quick Action</span>
|
||||
<strong>继续发起新审查</strong>
|
||||
<span>上传新合同或补充文本,直接进入下一条审查任务。</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Card className="review-list-card">
|
||||
<div className="review-toolbar">
|
||||
<div className="review-toolbar-copy">
|
||||
<span className="landing-card-eyebrow">Filter Workspace</span>
|
||||
<strong>快速定位目标审查记录</strong>
|
||||
<span>支持按关键词、状态、风险和处理视角筛选。</span>
|
||||
</div>
|
||||
<div className="review-toolbar-controls">
|
||||
<Input
|
||||
allowClear
|
||||
className="review-toolbar-search"
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
placeholder="搜索文件名、合同类型、审查视角或风险等级"
|
||||
prefix={<SearchOutlined />}
|
||||
value={keyword}
|
||||
<div className="review-sidebar-tree">
|
||||
{loading ? (
|
||||
<div className="review-loading"><Spin /></div>
|
||||
) : (
|
||||
<Tree
|
||||
showIcon={false}
|
||||
switcherIcon={({ expanded }) => expanded ? <CaretDownOutlined style={{ fontSize: 12 }} /> : <CaretRightOutlined style={{ fontSize: 12 }} />}
|
||||
treeData={treeData}
|
||||
expandedKeys={expandedKeys}
|
||||
selectedKeys={selectedId ? [selectedId] : []}
|
||||
onExpand={setExpandedKeys}
|
||||
onSelect={handleTreeSelect}
|
||||
blockNode
|
||||
/>
|
||||
<Select className="review-toolbar-select" onChange={setStatusFilter} options={statusOptions} value={statusFilter} />
|
||||
<Select className="review-toolbar-select" onChange={setRiskFilter} options={riskOptions} value={riskFilter} />
|
||||
<Select className="review-toolbar-select" onChange={setViewMode} options={viewModeOptions} value={viewMode} />
|
||||
<Button disabled={!hasActiveFilter} onClick={resetFilters}>
|
||||
<span className="ui-nowrap-label">重置</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="review-sidebar-actions">
|
||||
<Button type="primary" icon={<PlusOutlined />} block onClick={() => router.push("/review")}>
|
||||
发起新审查
|
||||
</Button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* 右侧审查详情 */}
|
||||
<section className="review-detail">
|
||||
{loading ? (
|
||||
<div className="review-loading-panel">
|
||||
<Spin size="large" />
|
||||
<Text type="secondary">加载中...</Text>
|
||||
</div>
|
||||
</div>
|
||||
) : selectedReview ? (
|
||||
<div className="review-detail-content">
|
||||
{/* 头部 */}
|
||||
<header className="review-detail-header">
|
||||
<div className="review-header-row">
|
||||
<div className="review-header-title">
|
||||
<Title level={3} style={{ margin: 0 }}>{selectedReview.title}</Title>
|
||||
<Space size={[8, 8]} wrap style={{ marginTop: 8 }}>
|
||||
<Tag>{formatContractType(selectedReview.contract_type)}</Tag>
|
||||
<Tag color="geekblue">{formatReviewAs(selectedReview.review_as)}</Tag>
|
||||
</Space>
|
||||
</div>
|
||||
<Space size={8}>
|
||||
<Button onClick={() => router.push(`/reviews/${selectedReview.id}`)}>
|
||||
查看详情
|
||||
</Button>
|
||||
{selectedReview.can_retry && (
|
||||
<Button icon={<RedoOutlined />} loading={retryingReviewId === selectedReview.id} onClick={() => void handleRetryReview(selectedReview.id)}>
|
||||
重试
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
<div className="review-header-meta">
|
||||
<Text type="secondary">创建时间:{formatDateTime(selectedReview.created_at)}</Text>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="review-filter-meta">
|
||||
<span>筛选后最近任务:{filteredTaskLabel}</span>
|
||||
{hasActiveFilter ? <span>当前已启用筛选条件</span> : <span>当前显示全部记录</span>}
|
||||
</div>
|
||||
{/* 状态与风险 */}
|
||||
<div className="review-section review-status-section">
|
||||
<div className="review-section-header">
|
||||
<Text strong>审查状态</Text>
|
||||
<Tag color={getStatusColor(selectedReview.status)}>{formatStatus(selectedReview.status)}</Tag>
|
||||
</div>
|
||||
<div className="review-status-content">
|
||||
<Space direction="vertical" size={8}>
|
||||
<Text type={getStageTextType(selectedReview)}>{getStageDisplayText(selectedReview)}</Text>
|
||||
{selectedReview.status_detail && <Text type="secondary">{selectedReview.status_detail}</Text>}
|
||||
{selectedReview.failure_reason && <Text type="danger">失败原因:{selectedReview.failure_reason}</Text>}
|
||||
{selectedReview.next_action && <Text>下一步:{selectedReview.next_action}</Text>}
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
rowKey="id"
|
||||
dataSource={filteredItems}
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
locale={{
|
||||
emptyText: hasActiveFilter ? (
|
||||
<Empty description="没有匹配的审查记录,试试调整筛选条件。" image={Empty.PRESENTED_IMAGE_SIMPLE}>
|
||||
<Button onClick={resetFilters}>重置筛选条件</Button>
|
||||
</Empty>
|
||||
) : (
|
||||
<Empty
|
||||
description="还没有审查记录,先发起第一份合同审查。"
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
>
|
||||
<Link href="/review">
|
||||
<Button type="primary">发起首个审查任务</Button>
|
||||
</Link>
|
||||
</Empty>
|
||||
),
|
||||
}}
|
||||
columns={columns}
|
||||
scroll={{ x: 1600 }}
|
||||
/>
|
||||
</Card>
|
||||
</Space>
|
||||
{/* 风险等级 */}
|
||||
{selectedReview.risk_level && (
|
||||
<div className="review-section review-risk-section">
|
||||
<div className="review-section-header">
|
||||
<Space>
|
||||
<WarningOutlined style={{ color: getRiskColor(selectedReview.risk_level) }} />
|
||||
<Text strong>风险等级</Text>
|
||||
</Space>
|
||||
<Tag color={getRiskColor(selectedReview.risk_level)} style={{ fontSize: 14 }}>
|
||||
{formatRisk(selectedReview.risk_level)}
|
||||
</Tag>
|
||||
</div>
|
||||
{(selectedReview.status === "completed" || selectedReview.status === "partial") && (
|
||||
<Paragraph type="secondary">
|
||||
{selectedReview.risk_level === "high"
|
||||
? "合同存在重大风险条款,建议重点复核后再签署。"
|
||||
: selectedReview.risk_level === "medium"
|
||||
? "合同存在部分风险点,建议关注后确认处理方案。"
|
||||
: "合同整体风险可控,可继续推进后续流程。"}
|
||||
</Paragraph>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 报告下载 */}
|
||||
{(selectedReview.status === "completed" || selectedReview.status === "partial") && (
|
||||
<div className="review-section review-report-section">
|
||||
<div className="review-section-header">
|
||||
<Space>
|
||||
<FileTextOutlined />
|
||||
<Text strong>审查报告</Text>
|
||||
</Space>
|
||||
{reportReadyMap[selectedReview.id] && <Tag color="green">可下载</Tag>}
|
||||
</div>
|
||||
<div className="review-report-content">
|
||||
<Space direction="vertical" size={8}>
|
||||
<Text type="secondary">审查完成后可下载完整报告,包含风险分析、条款建议和修改意见。</Text>
|
||||
<Space>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<DownloadOutlined />}
|
||||
loading={reportLoadingId === selectedReview.id}
|
||||
onClick={() => void handleDownloadReport(selectedReview.id)}
|
||||
>
|
||||
{reportReadyMap[selectedReview.id] ? "下载报告" : "准备报告"}
|
||||
</Button>
|
||||
{reportFileMap[selectedReview.id] && (
|
||||
<Button onClick={() => router.push(`/files?highlight=${reportFileMap[selectedReview.id]}`)}>
|
||||
查看文件资产
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 处理建议 */}
|
||||
<div className="review-section review-action-section">
|
||||
<div className="review-section-header">
|
||||
<Text strong>处理建议</Text>
|
||||
</div>
|
||||
<div className="review-action-content">
|
||||
<Text type="secondary">
|
||||
{selectedReview.status === "completed"
|
||||
? "审查已完成。可直接进入详情页查看完整分析,继续问答或下载报告。"
|
||||
: selectedReview.status === "partial"
|
||||
? "结果部分可用,建议进入详情页复核,确认是否需要重试。"
|
||||
: selectedReview.status === "failed"
|
||||
? "任务处理失败。建议查看失败原因,按下一步建议处理后重试。"
|
||||
: "任务仍在处理中,可进入详情页跟踪进度。"}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="review-empty">
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="请从左侧选择审查记录查看详情">
|
||||
<Button type="primary" onClick={() => router.push("/review")}>
|
||||
发起首个审查
|
||||
</Button>
|
||||
</Empty>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<style jsx global>{`
|
||||
.review-layout {
|
||||
display: flex;
|
||||
height: calc(100vh - 64px);
|
||||
background: #f5f7fa;
|
||||
}
|
||||
|
||||
.review-sidebar {
|
||||
width: 280px;
|
||||
background: #fff;
|
||||
border-right: 1px solid #e8e8e8;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.review-sidebar-header {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.review-search {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.review-search .ant-input {
|
||||
background: #f5f7fa;
|
||||
}
|
||||
|
||||
.review-sidebar-tree {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.review-sidebar-tree .ant-tree {
|
||||
background: transparent;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.review-sidebar-tree .ant-tree-node-content-wrapper {
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.review-sidebar-tree .ant-tree-node-selected {
|
||||
background: #e6f4ff !important;
|
||||
}
|
||||
|
||||
.review-tree-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.review-tree-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 160px;
|
||||
}
|
||||
|
||||
.tree-group-title {
|
||||
font-weight: 600;
|
||||
color: #1677ff;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.review-sidebar-actions {
|
||||
padding: 16px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.review-loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
.review-detail {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.review-loading-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.review-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.review-detail-content {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.review-detail-header {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
margin-bottom: 16px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.review-header-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.review-header-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.review-section {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
margin-bottom: 16px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.review-section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
`}</style>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user