diff --git a/frontend/src/app/reviews/page.tsx b/frontend/src/app/reviews/page.tsx index 1633ba5b..8e826364 100644 --- a/frontend/src/app/reviews/page.tsx +++ b/frontend/src/app/reviews/page.tsx @@ -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 = { 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 = { + pending: "待处理", + processing: "处理中", + completed: "已完成", + partial: "部分完成", + failed: "失败", +}; + +const riskLabelMap: Record = { + 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([]); + const [selectedId, setSelectedId] = useState(null); + const [searchQuery, setSearchQuery] = useState(""); + const [expandedKeys, setExpandedKeys] = useState([ALL_KEY]); + const [reportLoadingId, setReportLoadingId] = useState(null); const [retryingReviewId, setRetryingReviewId] = useState(null); - const [items, setItems] = useState([]); - const [keyword, setKeyword] = useState(""); - const [statusFilter, setStatusFilter] = useState("all"); - const [riskFilter, setRiskFilter] = useState("all"); - const [viewMode, setViewMode] = useState("all"); const [reportReadyMap, setReportReadyMap] = useState>({}); const [reportFileMap, setReportFileMap] = useState>({}); - 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: ( + + {formatContractType(type)} + {reviewsInType.length} + + ), + selectable: false, + children: reviewsInType.map((r) => ({ + key: r.id, + title: ( +
+ {r.title} + + {formatStatus(r.status).slice(0, 2)} + +
+ ), + 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: ( + + {statusLabelMap[status] || status} + {reviewsInStatus.length} + + ), + selectable: false, + children: reviewsInStatus.map((r) => ({ + key: r.id, + title: ( +
+ {r.title} + {r.risk_level && ( + + {riskLabelMap[r.risk_level]?.slice(0, 2) || r.risk_level} + + )} +
+ ), + 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: ( + + {riskLabelMap[risk] || risk} + {reviewsInRisk.length} + + ), + selectable: false, + children: reviewsInRisk.map((r) => ({ + key: r.id, + title: ( +
+ {r.title} + + {formatStatus(r.status).slice(0, 2)} + +
+ ), + 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: ( + + 全部审查 + {filteredItems.length} + + ), + selectable: false, + children: [ + { key: "group-type", title: 按合同类型, selectable: false, children: typeChildren }, + { key: "group-status", title: 按审查状态, selectable: false, children: statusChildren }, + { key: "group-risk", title: 按风险等级, 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 = {}; const nextFileMap: Record = {}; 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 = [ - { - title: "文件名", - dataIndex: "title", - key: "title", - render: (_value: string, record: ReviewListItem) => {record.title}, - }, - { - 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) => ( - - {formatStatus(record.status)} - {getStageDisplayText(record)} - - ), - }, - { - title: "风险等级", - dataIndex: "risk_level", - key: "risk_level", - render: (value: string | null) => {formatRisk(value)}, - }, - { - title: "当前说明 / 下一步", - dataIndex: "status_detail", - key: "status_detail", - render: (_value: string | undefined, record: ReviewListItem) => ( - - {record.status_detail || "-"} - {record.failure_reason ? 失败原因:{record.failure_reason} : null} - {record.next_action ? 下一步:{record.next_action} : null} - - ), - }, - { - 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 ( - - - - - - - {currentUser?.role === "admin" && fileId ? ( - - - - ) : null} - {canChat ? ( - - - - ) : null} - {record.can_retry ? ( - - ) : null} - - - {record.status === "completed" - ? "可直接查看详情、下载报告并继续进入处理工作台。" - : record.status === "partial" - ? "结果部分可用,建议先复核详情再决定是否重试。" - : record.status === "failed" - ? "优先查看失败原因与下一步建议。" - : "任务仍在处理中,可先进入详情页跟踪状态。"} - - - ); - }, - }, - { - 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 ( +
+ {contextHolder} +
+ 仅管理员可查看审查记录。 +
+
+ ); + } return ( -
+
{contextHolder} - -
-
- Review History - 审查记录不再只是列表,而是任务工作台。 - 从这里筛选任务、进入详情、下载报告,或继续处理未完成合同。 -
最近一次任务创建时间:{latestTaskLabel}
-
当前列表展示:{filterSummary}
+
+ {/* 左侧树形菜单 */} +
- -
- {historyStats.map((item) => ( -
- {item.label} - {item.value} -
- ))} - - Quick Action - 继续发起新审查 - 上传新合同或补充文本,直接进入下一条审查任务。 - -
- - -
-
- Filter Workspace - 快速定位目标审查记录 - 支持按关键词、状态、风险和处理视角筛选。 -
-
- setKeyword(event.target.value)} - placeholder="搜索文件名、合同类型、审查视角或风险等级" - prefix={} - value={keyword} +
+ {loading ? ( +
+ ) : ( + expanded ? : } + treeData={treeData} + expandedKeys={expandedKeys} + selectedKeys={selectedId ? [selectedId] : []} + onExpand={setExpandedKeys} + onSelect={handleTreeSelect} + blockNode /> - -