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:
2026-06-01 21:58:49 +08:00
parent 7dc0f77c2b
commit 8eb09db8f0
+508 -313
View File
@@ -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>
);
}
}