From a3fd2c7fed4b6b1418b954dcc16e7adbac73fc5e Mon Sep 17 00:00:00 2001 From: yorn <1057707203@qq.com> Date: Tue, 21 Apr 2026 15:08:14 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=88=9D=E6=AD=A5=E5=AE=8C=E6=88=90?= =?UTF-8?q?=E8=AF=84=E6=9F=A5=E8=AF=A6=E6=83=85=E9=A1=B5=E9=9D=A2=E7=9A=84?= =?UTF-8?q?work=E6=96=87=E6=A1=A3=E5=92=8Cpdf=E6=96=87=E6=A1=A3=E7=9A=84?= =?UTF-8?q?=E5=8A=A0=E8=BD=BD=E7=9A=84=E9=A1=B5=E9=9D=A2=E4=B8=89=E6=A0=8F?= =?UTF-8?q?=E8=AE=BE=E8=AE=A1=E7=9A=84=E9=87=8D=E6=9E=84=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/evaluation_points/reviews.ts | 15 +- app/components/layout/Layout.tsx | 28 +- app/components/reviews/ReviewPointsList.tsx | 5 + app/components/reviews/index.ts | 8 +- .../reviews/leftColumn/RulesDirectory.tsx | 302 +++++++++ .../previewComponents/DocxPreviewTest.tsx | 224 +++++++ .../previewComponents/PdfPreviewTest.tsx | 32 +- .../reviews/rightColumn/DetailPanel.tsx | 222 +++++++ .../reviews/rightColumn/FileInfoPanel.tsx | 71 ++ .../rightColumn/ReviewPointDetailCard.tsx | 577 +++++++++++++++++ app/root.tsx | 15 +- app/routes/documents.list.tsx | 1 + app/routes/reviewsTest.tsx | 613 +++++++++--------- 13 files changed, 1781 insertions(+), 332 deletions(-) create mode 100644 app/components/reviews/leftColumn/RulesDirectory.tsx create mode 100644 app/components/reviews/previewComponents/DocxPreviewTest.tsx create mode 100644 app/components/reviews/rightColumn/DetailPanel.tsx create mode 100644 app/components/reviews/rightColumn/FileInfoPanel.tsx create mode 100644 app/components/reviews/rightColumn/ReviewPointDetailCard.tsx diff --git a/app/api/evaluation_points/reviews.ts b/app/api/evaluation_points/reviews.ts index f791517..8935a8e 100644 --- a/app/api/evaluation_points/reviews.ts +++ b/app/api/evaluation_points/reviews.ts @@ -1149,13 +1149,16 @@ export async function getReviewPoints_fromApi(fileId: string, request: Request) // 成功响应 if (response.data) { + // console.log('✅ [getReviewPoints_fromApi] API调用成功,返回数据结构:', JSON.stringify(response.data, null, 2)) + // console.log('✅ [getReviewPoints_fromApi] API调用成功,返回数据结构:', JSON.stringify(response.data)) // console.log('✅ [getReviewPoints_fromApi] API调用成功,返回数据结构:', JSON.stringify({ - // 评查点数量: response.data.data?.length || 0, - // // 统计信息: response.data.stats, - // // 评查信息: response.data.reviewInfo, - // 有文档数据: response.data.document, - // // 有比对数据: !!response.data.comparison_document, - // // 评分提案数量: response.data.scoring_proposals?.length || 0 + // 评查点数量: response.data.data?.length || 0, + // 评查点数量: response.data.data + // 统计信息: response.data.stats, + // 评查信息: response.data.reviewInfo, + // 有文档数据: response.data.document, + // 有比对数据: !!response.data.comparison_document, + // 评分提案数量: response.data.scoring_proposals?.length || 0 // })); // 返回数据格式与原方法完全一致 diff --git a/app/components/layout/Layout.tsx b/app/components/layout/Layout.tsx index 250ee89..20ab35b 100644 --- a/app/components/layout/Layout.tsx +++ b/app/components/layout/Layout.tsx @@ -15,6 +15,8 @@ interface LayoutProps { // 添加一个接口表示路由handle可能包含的属性 interface RouteHandle { hideBreadcrumb?: boolean; + collapseSidebar?: boolean; + noPadding?: boolean; [key: string]: unknown; } @@ -42,6 +44,11 @@ export function Layout({ children, userRole = 'developer' as UserRole, frontendJ match.handle && match.handle.hideBreadcrumb === true ); + // 检查当前路由是否要求无 padding + const shouldNoPadding = matches.some(match => + match.handle && match.handle.noPadding === true + ); + // 从 localStorage 读取用户信息和 JWT 作为备用方案 useEffect(() => { if (typeof window === 'undefined') return; @@ -80,16 +87,23 @@ export function Layout({ children, userRole = 'developer' as UserRole, frontendJ // 检查是否为移动端 const isMobile = window.innerWidth <= 768; - // 从localStorage获取侧边栏状态 - const savedState = localStorage.getItem('sidebarCollapsed'); + // 检查当前路由是否要求收缩侧边栏 + const shouldCollapse = matches.some(match => + match.handle && match.handle.collapseSidebar === true + ); - // 移动端默认收起,桌面端使用保存的状态 if (isMobile) { setSidebarCollapsed(true); - } else if (savedState) { - setSidebarCollapsed(savedState === 'true'); + } else if (shouldCollapse) { + setSidebarCollapsed(true); + } else { + // 从localStorage获取侧边栏状态 + const savedState = localStorage.getItem('sidebarCollapsed'); + if (savedState) { + setSidebarCollapsed(savedState === 'true'); + } } - }, []); + }, [location.pathname]); const toggleSidebar = () => { const newState = !sidebarCollapsed; @@ -135,7 +149,7 @@ export function Layout({ children, userRole = 'developer' as UserRole, frontendJ ))} */} -
+
{!shouldHideBreadcrumb && } {children}
diff --git a/app/components/reviews/ReviewPointsList.tsx b/app/components/reviews/ReviewPointsList.tsx index 7d053a8..e245044 100644 --- a/app/components/reviews/ReviewPointsList.tsx +++ b/app/components/reviews/ReviewPointsList.tsx @@ -118,6 +118,11 @@ export interface ReviewPoint { }; postAction?: string; actionContent?: string; + score?: number; + machineScore?: number; + finalScore?: number | null; + failMessage?: string; + passMessage?: string; evaluationConfig?: { rules?: Array<{ type: string; diff --git a/app/components/reviews/index.ts b/app/components/reviews/index.ts index af679ec..1f741e4 100644 --- a/app/components/reviews/index.ts +++ b/app/components/reviews/index.ts @@ -9,4 +9,10 @@ export { ReviewPointsList } from './ReviewPointsList'; export type { ReviewPoint } from './ReviewPointsList'; export { AIAnalysis } from './AIAnalysis'; export { FileDetails } from './FileDetails'; -export { Comparison } from './Comparison'; \ No newline at end of file +export { Comparison } from './Comparison'; + +// 新三栏组件 +export { RulesDirectory } from './leftColumn/RulesDirectory'; +export { DetailPanel } from './rightColumn/DetailPanel'; +export { ReviewPointDetailCard } from './rightColumn/ReviewPointDetailCard'; +export { FileInfoPanel } from './rightColumn/FileInfoPanel'; \ No newline at end of file diff --git a/app/components/reviews/leftColumn/RulesDirectory.tsx b/app/components/reviews/leftColumn/RulesDirectory.tsx new file mode 100644 index 0000000..4f4c47b --- /dev/null +++ b/app/components/reviews/leftColumn/RulesDirectory.tsx @@ -0,0 +1,302 @@ +/** + * 左栏 · 规则目录 + * 显示文件名、分数进度条、搜索、评查点分组列表 + */ +import { useState, useMemo } from 'react'; +import type { ReviewPoint } from '../ReviewPointsList'; + +interface Statistics { + total: number; + success: number; + warning: number; + error: number; + notApplicable: number; + score: number; +} + +interface RulesDirectoryProps { + reviewPoints: ReviewPoint[]; + statistics: Statistics; + activeReviewPointResultId: string | null; + fileName: string; + onRuleSelect: (id: string) => void; + onBack: () => void; +} + +type PointStatus = 'pass' | 'warn' | 'fail' | 'skipped'; + +function classifyPoint(p: ReviewPoint): PointStatus { + if (p.status === 'notApplicable' || p.status === 'not_applicable') return 'skipped'; + if (p.result === true || (p.result === undefined && p.status === 'success')) return 'pass'; + if (p.result === false) { + if (p.status === 'error') return 'fail'; + if (p.status === 'warning' || p.status === 'info') return 'warn'; + } + return 'pass'; +} + +const STATUS_ICON: Record = { + pass: { icon: 'ri-checkbox-circle-fill', color: 'text-emerald-500' }, + warn: { icon: 'ri-lightbulb-flash-fill', color: 'text-amber-500' }, + fail: { icon: 'ri-close-circle-fill', color: 'text-red-500' }, + skipped: { icon: 'ri-forbid-2-line', color: 'text-slate-400' }, +}; + +function RuleListItem({ + point, + isActive, + showCategory, + onClick, +}: { + point: ReviewPoint; + isActive: boolean; + showCategory?: boolean; + onClick: () => void; +}) { + const cls = classifyPoint(point); + const s = STATUS_ICON[cls]; + + return ( + + ); +} + +export function RulesDirectory({ + reviewPoints, + statistics, + activeReviewPointResultId, + fileName, + onRuleSelect, + onBack, +}: RulesDirectoryProps) { + const [searchText, setSearchText] = useState(''); + const [passOpen, setPassOpen] = useState(true); + const [openCategories, setOpenCategories] = useState>(new Set()); + + const q = searchText.toLowerCase(); + + const matchSearch = (p: ReviewPoint) => + !q || + (p.pointName?.toLowerCase().includes(q)) || + (p.pointCode?.toLowerCase().includes(q)) || + (p.groupName?.toLowerCase().includes(q)); + + const { needAttention, passed, passedByGroup } = useMemo(() => { + const filtered = reviewPoints.filter(matchSearch); + const needAttention = filtered.filter( + (p) => classifyPoint(p) !== 'pass' && classifyPoint(p) !== 'skipped' + ); + const passed = filtered.filter((p) => classifyPoint(p) === 'pass'); + + const passedByGroup: Record = {}; + passed.forEach((p) => { + const key = p.groupName || '未分组'; + (passedByGroup[key] = passedByGroup[key] || []).push(p); + }); + + return { needAttention, passed, passedByGroup }; + }, [reviewPoints, searchText]); + + // 计算进度条百分比 + const total = statistics.total || reviewPoints.length; + const passPct = total > 0 ? (statistics.success / total) * 100 : 0; + const warnPct = total > 0 ? (statistics.warning / total) * 100 : 0; + const errPct = total > 0 ? (statistics.error / total) * 100 : 0; + const naPct = + total > 0 + ? ((statistics.notApplicable || 0) / total) * 100 + : 0; + + const attentionCount = needAttention.length; + const passedCount = passed.length; + + const toggleCategory = (cat: string) => { + setOpenCategories((prev) => { + const next = new Set(prev); + if (next.has(cat)) next.delete(cat); + else next.add(cat); + return next; + }); + }; + + return ( + + ); +} diff --git a/app/components/reviews/previewComponents/DocxPreviewTest.tsx b/app/components/reviews/previewComponents/DocxPreviewTest.tsx new file mode 100644 index 0000000..8550d72 --- /dev/null +++ b/app/components/reviews/previewComponents/DocxPreviewTest.tsx @@ -0,0 +1,224 @@ +/** + * DOCX 预览组件(设计稿 7c-简化C-极简 · 中栏实现) + * + * 使用 CollaboraViewer 渲染 DOCX 文件。 + * 工具栏与 PdfPreviewTest 一致,额外增加清除高亮、返回顶部按钮。 + */ +import { useState, useEffect, useMemo, useRef } from 'react'; +import { CollaboraViewer } from '~/components/collabora/CollaboraViewer'; +import type { CollaboraViewerHandle } from '~/components/collabora/types'; +import { customGotoPage } from '~/components/collabora/lib'; +import { toastService } from '~/components/ui/Toast'; +import type { ReviewPoint } from '../ReviewPointsList'; + +interface DocxPreviewTestProps { + filePath: string; + targetPage?: number; + charPositions?: Array<{ box: number[][]; char: string; score: number }>; + activeReviewPointResultId?: string | null; + reviewPoints?: ReviewPoint[]; + highlightValue?: string; + aiSuggestionReplace?: { + searchText: string; + replaceText: string; + pageNumber: number; + silentReplace?: boolean; + }; + userInfo?: { sub: string; nick_name: string }; +} + +export function DocxPreviewTest({ + filePath, + targetPage, + activeReviewPointResultId, + reviewPoints, + highlightValue, + aiSuggestionReplace, + userInfo, +}: DocxPreviewTestProps) { + const collaboraRef = useRef(null); + + const [pageInputValue, setPageInputValue] = useState(''); + const [isClearingHighlights, setIsClearingHighlights] = useState(false); + const [isScrollingToTop, setIsScrollingToTop] = useState(false); + + // 当前激活的评查点 + const activePoint = useMemo( + () => reviewPoints?.find(p => p.id === activeReviewPointResultId), + [reviewPoints, activeReviewPointResultId], + ); + + // 当前高亮标签 + const highlightLabel = useMemo(() => { + if (!activePoint) return null; + const code = activePoint.pointCode || activePoint.id; + const name = activePoint.pointName || activePoint.title || ''; + return `${code}${name ? ' · ' + name : ''}`; + }, [activePoint]); + + // ── 页码跳转 ── + const handlePageInputChange = (e: React.ChangeEvent) => { + setPageInputValue(e.target.value.replace(/\D/g, '')); + }; + + const handlePageJump = async () => { + if (!pageInputValue) return; + const targetPageNum = parseInt(pageInputValue, 10); + const iframeWindow = collaboraRef.current?.getIframeWindow?.(); + if (!iframeWindow) { + toastService.warning('文档尚未加载完成,请稍候...'); + return; + } + if (targetPageNum > 0) { + try { + await customGotoPage(iframeWindow, targetPageNum); + setPageInputValue(''); + } catch (error) { + toastService.error(`跳转失败: ${error instanceof Error ? error.message : '未知错误'}`); + } + } + }; + + // ── 清除高亮 ── + const handleClearAllHighlights = async () => { + if (!collaboraRef.current?.isReady) { + toastService.warning('文档尚未加载完成,请稍候...'); + return; + } + setIsClearingHighlights(true); + try { + await collaboraRef.current.clearAllHighlights(); + toastService.success('已清除所有高亮'); + } catch (error) { + console.error('[DocxPreviewTest] 清除高亮失败:', error); + toastService.error('清除高亮失败'); + } finally { + setTimeout(() => setIsClearingHighlights(false), 500); + } + }; + + // ── 返回顶部 ── + const handleScrollToTop = async () => { + setIsScrollingToTop(true); + try { + await collaboraRef.current?.unoCommands.scrollToTop(); + } catch (error) { + console.error('[DocxPreviewTest] 返回顶部失败:', error); + toastService.error('返回顶部失败'); + } finally { + setTimeout(() => setIsScrollingToTop(false), 500); + } + }; + + // ── 跳转到高亮页 ── + const jumpToHighlight = () => { + if (!activePoint) return; + // 触发 targetPage 重新跳转(由父组件控制) + toastService.info('已定位到当前评查点所在页'); + }; + + return ( +
+ {/* ═══ 顶部工具栏 ═══ */} +
+
+ {/* 页码跳转 */} + + + e.currentTarget.select()} + onBlur={handlePageJump} + onKeyDown={e => { if (e.key === 'Enter') handlePageJump(); }} + className="w-8 h-6 text-center bg-slate-50 border border-slate-200 rounded text-[12px] font-mono outline-none focus:border-[#00684a]" + placeholder="-" + /> + + + + | + + {/* 返回顶部 */} + + + {/* 清除高亮 */} + +
+ + {/* 右侧:当前高亮标签 */} +
+ {highlightLabel && ( + <> + 当前高亮: + + + {highlightLabel} + + + + )} +
+
+ + {/* ═══ Collabora 文档区域 ═══ */} +
+ +
+
+ ); +} diff --git a/app/components/reviews/previewComponents/PdfPreviewTest.tsx b/app/components/reviews/previewComponents/PdfPreviewTest.tsx index 62131ca..1b62545 100644 --- a/app/components/reviews/previewComponents/PdfPreviewTest.tsx +++ b/app/components/reviews/previewComponents/PdfPreviewTest.tsx @@ -352,32 +352,31 @@ export function PdfPreviewTest({ file={fileUrl} onLoadSuccess={onDocumentLoadSuccess} onLoadError={onDocumentLoadError} - className="flex flex-col min-h-0 w-full" + className="flex flex-col min-h-0 w-full h-full" loading={
PDF 加载中…
} error={
PDF 文档加载失败
} noData={
无数据
} >
{/* ═════ 顶部工具栏 ═════ */}
- | + | - | + |
{p} @@ -570,15 +569,16 @@ export function PdfPreviewTest({ {/* ── 视口(单页) ── */}
{numPages !== null && ( diff --git a/app/components/reviews/rightColumn/DetailPanel.tsx b/app/components/reviews/rightColumn/DetailPanel.tsx new file mode 100644 index 0000000..f70338f --- /dev/null +++ b/app/components/reviews/rightColumn/DetailPanel.tsx @@ -0,0 +1,222 @@ +/** + * 右栏 · 详情面板 + * 包含 3 个选项卡(评查结果、抽取字段、文件信息)+ 底部操作栏 + */ +import { useState } from 'react'; +import type { ReviewPoint, CharPosition } from '../ReviewPointsList'; +import { ReviewPointDetailCard } from './ReviewPointDetailCard'; +import { FileInfoPanel } from './FileInfoPanel'; + +type TabKey = 'result' | 'fields' | 'info'; + +interface FileInfoData { + fileName: string; + contractNumber: string; + fileSize: string; + fileFormat: string; + pageCount: number; + uploadTime: string; + uploadUser: string; + fileType: string; +} + +interface ReviewInfoData { + reviewTime: string; + reviewModel: string; + ruleGroup: string; + result: string; + issueCount: number; +} + +interface DetailPanelProps { + activeTab: TabKey; + onTabChange: (tab: TabKey) => void; + activeReviewPoint: ReviewPoint | null; + reviewPoints: ReviewPoint[]; + fileInfo: FileInfoData; + reviewInfo: ReviewInfoData; + onReviewPointSelect: (id: string, page?: number, charPositions?: CharPosition[], value?: string) => void; + onStatusChange: (id: string, editAuditStatusId: string | number, status: string, message: string) => void; + onConfirmResults: () => void; + onDownload: () => void; + auditStatus?: number; + fileFormat?: string; + onUploadTemplate?: () => void; + onComparison?: () => void; + showComparisonButton?: boolean; +} + +function ExtractedFieldsPanel({ reviewPoints, onFieldClick }: { reviewPoints: ReviewPoint[]; onFieldClick: (page: number) => void }) { + const fields: Array<{ key: string; value: string; page?: number; pointName: string }> = []; + + reviewPoints.forEach((p) => { + if (p.content) { + Object.entries(p.content).forEach(([key, data]) => { + const val = (data as any)?.value; + const page = (data as any)?.page; + const text = typeof val === 'object' ? (val as any)?.text || JSON.stringify(val) : String(val || ''); + fields.push({ key, value: text, page: page ? Number(page) : undefined, pointName: p.pointName }); + }); + } + }); + + return ( +
+
+ 抽取字段 {fields.length} +
+ {fields.length === 0 ? ( +
暂无抽取字段
+ ) : ( +
+ {fields.map((f, i) => ( + + ))} +
+ )} +
+ ); +} + +const TABS: Array<{ key: TabKey; label: string }> = [ + { key: 'result', label: '评查结果' }, + { key: 'fields', label: '抽取字段' }, + { key: 'info', label: '文件信息' }, +]; + +export function DetailPanel({ + activeTab, + onTabChange, + activeReviewPoint, + reviewPoints, + fileInfo, + reviewInfo, + onReviewPointSelect, + onStatusChange, + onConfirmResults, + onDownload, + auditStatus, + fileFormat, + onUploadTemplate, + onComparison, + showComparisonButton, +}: DetailPanelProps) { + return ( + + ); +} diff --git a/app/components/reviews/rightColumn/FileInfoPanel.tsx b/app/components/reviews/rightColumn/FileInfoPanel.tsx new file mode 100644 index 0000000..cee82aa --- /dev/null +++ b/app/components/reviews/rightColumn/FileInfoPanel.tsx @@ -0,0 +1,71 @@ +/** + * 右栏 · 文件信息选项卡 + * 以简洁的 key-value 网格展示文件基本属性和评查信息 + */ + +interface FileInfoData { + fileName: string; + contractNumber: string; + fileSize: string; + fileFormat: string; + pageCount: number; + uploadTime: string; + uploadUser: string; + fileType: string; +} + +interface ReviewInfoData { + reviewTime: string; + reviewModel: string; + ruleGroup: string; + result: string; + issueCount: number; +} + +interface FileInfoPanelProps { + fileInfo: FileInfoData; + reviewInfo: ReviewInfoData; +} + +function Row({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value || '—'} +
+ ); +} + +export function FileInfoPanel({ fileInfo, reviewInfo }: FileInfoPanelProps) { + return ( +
+ {/* 文件基本信息 */} +
+
+ 文件基本信息 +
+
+ + + + + + +
+
+ + {/* 评查信息 */} +
+
+ 评查信息 +
+
+ + + + +
+
+
+ ); +} diff --git a/app/components/reviews/rightColumn/ReviewPointDetailCard.tsx b/app/components/reviews/rightColumn/ReviewPointDetailCard.tsx new file mode 100644 index 0000000..92dbd7e --- /dev/null +++ b/app/components/reviews/rightColumn/ReviewPointDetailCard.tsx @@ -0,0 +1,577 @@ +/** + * 右栏 · 评查点详情卡片 + * 展示单个评查点的完整详情,复用 renderConsistencyRule/renderOtherRule/renderModelRule 渲染逻辑 + */ +import { useState, useEffect, useRef, useMemo } from 'react'; +import { createPortal } from 'react-dom'; +import { toastService } from '~/components/ui/Toast'; +import type { ReviewPoint, CharPosition } from '../ReviewPointsList'; + +interface ReviewPointDetailCardProps { + reviewPoint: ReviewPoint; + onReviewPointSelect: (id: string, page?: number, charPositions?: CharPosition[], value?: string) => void; + onStatusChange: (id: string, editAuditStatusId: string | number, status: string, message: string) => void; + fileFormat?: string; +} + +// ── 比较方法映射 ── +const compareMethodMap: Record = { + exact: '精确匹配', + contains: '包含关系', + semantic: '大模型语义匹配', +}; +const getCompareMethodText = (method?: string): string => { + if (!method) return '相等'; + const text = compareMethodMap[method] || method; + return typeof text === 'string' ? text : String(text); +}; +const ruleTypeMap: Record = { + exists: '有无判断', format: '格式判断', logic: '逻辑判断', regex: '正则表达式', +}; +const getRuleTypeText = (type?: string): string => { + if (!type) return ''; + return ruleTypeMap[type] || type; +}; + +// ── Tooltip 系统 ── +let activeTooltip = { show: false, content: null as React.ReactNode, position: { top: 0, left: 0 }, ready: false }; +function TooltipPortal() { + const [tooltip, setTooltip] = useState(activeTooltip); + useEffect(() => { + const update = () => setTooltip({ ...activeTooltip }); + window.addEventListener('tooltip-update', update); + return () => window.removeEventListener('tooltip-update', update); + }, []); + if (!tooltip.show || !tooltip.content) return null; + return createPortal( +
+ {tooltip.content} +
+
, + document.body, + ); +} +function showTooltip(content: React.ReactNode, position: { top: number; left: number }) { + activeTooltip = { show: true, content, position, ready: false }; + window.dispatchEvent(new Event('tooltip-update')); + requestAnimationFrame(() => { + const el = document.querySelector('.fixed.bg-white.shadow-lg.rounded-md') as HTMLElement; + if (el) { + const r = el.getBoundingClientRect(); + let t = position.top, l = position.left; + if (l - r.width < 0) l = r.width + 10; + if (t - r.height / 2 < 0) t = r.height / 2 + 10; + if (t + r.height / 2 > window.innerHeight) t = window.innerHeight - r.height / 2 - 10; + activeTooltip.position = { top: t, left: l }; + activeTooltip.ready = true; + } else { + activeTooltip.ready = true; + } + window.dispatchEvent(new Event('tooltip-update')); + }); +} +function hideTooltip() { + activeTooltip.show = false; + activeTooltip.ready = false; + window.dispatchEvent(new Event('tooltip-update')); +} + +// ── ReactTableTooltip ── +function renderReactTable(text: string) { + const rows = text.split('\n').map(r => r.split('\t')); + return ( + + + {rows.map((row, i) => ( + + {row.map((cell, j) => )} + + ))} + +
{cell}
+ ); +} +function renderMarkdownTable(text: string) { + const lines = text.split('\n').filter(l => l.trim()); + const rows: string[][] = []; + for (const line of lines) { + if (/^\s*\|[-\s:]+\|/.test(line)) continue; + const cells = line.split('|').map(c => c.trim()).filter((_, i, a) => i > 0 && i < a.length); + if (cells.length > 0) rows.push(cells); + } + if (rows.length < 1) return {text}; + return ( + + + {rows.map((row, i) => ( + + {row.map((cell, j) => )} + + ))} + +
{cell}
+ ); +} +function renderPipeTable(text: string) { + const lines = text.split('\n').filter(l => l.includes('|')); + const rows = lines.map(l => l.split('|').map(c => c.trim()).filter(Boolean)); + return ( + + + {rows.map((row, i) => ( + + {row.map((cell, j) => )} + + ))} + +
{cell}
+ ); +} +const ReactTableTooltip = ({ content }: { content: string }) => { + const [showTip, setShowTip] = useState(false); + const [rendered, setRendered] = useState(null); + const ref = useRef(null); + const isTabTable = content.includes('\t') && content.includes('\n'); + const isMdTable = content.includes('|') && /\|[-\s:]+\|/.test(content); + const isPipeTable = !isMdTable && content.includes('|') && content.includes('\n') && content.split('\n').filter(l => l.includes('|')).length >= 2; + const isTableLike = isTabTable || isMdTable || isPipeTable; + useEffect(() => { + const check = () => { if (ref.current) setShowTip(isTableLike || ref.current.scrollHeight > ref.current.clientHeight); }; + if (isMdTable) setRendered(renderMarkdownTable(content)); + else if (isPipeTable) setRendered(renderPipeTable(content)); + else if (isTabTable) setRendered(renderReactTable(content)); + else setRendered(content); + requestAnimationFrame(check); + window.addEventListener('resize', check); + return () => window.removeEventListener('resize', check); + }, [content, isTableLike]); + return ( +
+
showTip && showTooltip(
{rendered}
, { top: ref.current?.getBoundingClientRect().top || 0, left: ref.current?.getBoundingClientRect().left || 0 })} onMouseLeave={hideTooltip}>{rendered}
+
+ ); +}; + +// ── filterOtherRule ── +interface MergedRule { + fieldKey: string; + fieldValue: { + type: Record; + }; +} + +function filterOtherRule(reviewPoint: ReviewPoint): MergedRule[] { + interface RuleFieldValue { page?: number | string; value?: string; char_positions?: CharPosition[]; type: Record } + const allRule: Array<{ fieldKey: string; fieldValue: RuleFieldValue }> = []; + + for (const rule of reviewPoint.evaluatedPointResultsLog?.rules || []) { + if ((rule.config as any).res !== reviewPoint.result) continue; + + if (rule.type === 'exists') { + const config = rule.config as { res: boolean; fields: Record; logic?: string }; + if (config.res) { + Object.entries(config.fields).forEach(([key, fv]) => { + if (fv.value && fv.value.trim() !== '') allRule.push({ fieldKey: key, fieldValue: { ...fv, type: { exists: true } } }); + }); + } else { + Object.entries(config.fields).forEach(([key, fv]) => { + const empty = !fv.value || fv.value.trim() === ''; + allRule.push({ fieldKey: key, fieldValue: { ...fv, type: { exists: !empty } } }); + }); + } + } + if (rule.type === 'format') { + const config = rule.config as { res: boolean; field: Record; formatType?: string; parameters?: string }; + if (config.field) { + const entries = Object.entries(config.field); + if (entries.length > 0) { const [key, fv] = entries[0]; allRule.push({ fieldKey: key, fieldValue: { ...fv, type: { format: config.res } } }); } + } + } + if (rule.type === 'logic') { + const config = rule.config as { logic: string; res: boolean; conditions: Array<{ field: Record; value: string; operator: string; res: boolean }> }; + if (config.conditions && Array.isArray(config.conditions)) { + config.conditions.forEach(cond => { + const entries = Object.entries(cond.field); + if (entries.length > 0) { const [key, fv] = entries[0]; allRule.push({ fieldKey: key, fieldValue: { ...fv, type: { logic: cond.res } } }); } + }); + } + } + if (rule.type === 'regex') { + const config = rule.config as { res: boolean; field: Record; pattern?: string }; + if (config.field) { + const entries = Object.entries(config.field); + if (entries.length > 0) { const [key, fv] = entries[0]; allRule.push({ fieldKey: key, fieldValue: { ...fv, type: { regex: config.res } } }); } + } + } + } + + const fieldKeyMap: Record = {}; + allRule.forEach(item => { + const typeKey = Object.keys(item.fieldValue.type)[0]; + const typeValue = item.fieldValue.type[typeKey]; + if (!fieldKeyMap[item.fieldKey]) fieldKeyMap[item.fieldKey] = { fieldKey: item.fieldKey, fieldValue: { type: {} } }; + fieldKeyMap[item.fieldKey].fieldValue.type[typeKey] = { res: typeValue, page: item.fieldValue.page, value: item.fieldValue.value, char_positions: item.fieldValue.char_positions }; + }); + return Object.values(fieldKeyMap); +} + +// ── renderOtherRule ── +function RenderOtherRule({ rule, reviewPoint, onReviewPointSelect }: { rule: MergedRule; reviewPoint: ReviewPoint; onReviewPointSelect: (id: string, page?: number, charPos?: CharPosition[], value?: string) => void }) { + const fieldKey = rule.fieldKey; + const fieldValue = rule.fieldValue; + const hasFailure = Object.values(fieldValue.type || {}).some(item => item.res === false); + const overallResult = !hasFailure; + const failedEntry = Object.entries(fieldValue.type || {}).find(([, item]) => item.res === false); + const mainEntry = failedEntry || Object.entries(fieldValue.type || {})[0]; + if (!mainEntry) return null; + const [, mainVal] = mainEntry; + + const tooltipContent = ( +
+ {Object.entries(fieldValue.type || {}).map(([tk, tv]) => ( +
+
{getRuleTypeText(tk)}:
+
{tv.res ? '通过' : '不通过'}
+
+ ))} +
+ ); + + return ( + + ); +} + +// ── renderConsistencyRule ── +type ChainItem = { field: string; data: { key: string; page: number; value: string; char_positions?: CharPosition[] }; res: boolean; compareMethod?: string }; + +function RenderConsistencyRule({ rule, reviewPoint, onReviewPointSelect }: { rule: Record; reviewPoint: ReviewPoint; onReviewPointSelect: (id: string, page?: number, charPos?: CharPosition[], value?: string) => void }) { + if (reviewPoint.result !== (rule.res as boolean)) return null; + const config = rule.config as { logic?: string; pairs?: Array<{ sourceField: Record; targetField: Record; res: boolean; compareMethod?: string }>; selectedFields?: string[] } | undefined; + if (!config || !config.pairs || !Array.isArray(config.pairs) || config.pairs.length === 0) return null; + const pairs = config.pairs; + + const chains = useMemo(() => { + type CI = ChainItem; + const result: Array> = []; + const visited = new Set(); + const fieldMap = new Map>(); + pairs.forEach(pair => { + const sKey = Object.keys(pair.sourceField)[0], tKey = Object.keys(pair.targetField)[0]; + if (!fieldMap.has(sKey)) fieldMap.set(sKey, []); + fieldMap.get(sKey)?.push({ targetField: tKey, data: { source: { key: sKey, ...pair.sourceField[sKey] }, target: { key: tKey, ...pair.targetField[tKey] } }, res: pair.res, compareMethod: pair.compareMethod }); + }); + const starts = new Set(); + for (const [key] of fieldMap.entries()) { let isT = false; for (const p of pairs) { if (Object.keys(p.targetField)[0] === key) { isT = true; break; } } if (!isT) starts.add(key); } + for (const sp of starts) { + if (visited.has(sp)) continue; + const chain: CI[] = []; + let cur = sp; + while (fieldMap.has(cur)) { + const targets = fieldMap.get(cur); + if (!targets || targets.length === 0) break; + let next: typeof targets[0] | null = null; + for (const t of targets) { if (!visited.has(t.targetField)) { next = t; break; } } + if (!next) break; + if (chain.length === 0) chain.push({ field: cur, data: next.data.source, res: next.res, compareMethod: next.compareMethod }); + chain.push({ field: next.targetField, data: next.data.target, res: next.res, compareMethod: next.compareMethod }); + visited.add(cur); visited.add(next.targetField); + cur = next.targetField; + } + if (chain.length > 0) result.push(chain); + } + // isolated pairs + for (const pair of pairs) { + const sKey = Object.keys(pair.sourceField)[0], tKey = Object.keys(pair.targetField)[0]; + if (!visited.has(sKey) || !visited.has(tKey)) { + result.push([ + { field: sKey, data: { key: sKey, ...pair.sourceField[sKey] }, res: pair.res }, + { field: tKey, data: { key: tKey, ...pair.targetField[tKey] }, res: pair.res }, + ]); + visited.add(sKey); visited.add(tKey); + } + } + return result; + }, [pairs]); + + return ( +
+
+ {chains.map((chain: ChainItem[], ci: number) => { + const res = chain[1]?.res ?? true; + const itemCls = res ? 'comparison-item match' : 'comparison-item mismatch'; + + if (chain.length > 2) { + return ( +
{ e.stopPropagation(); for (const item of chain) { if (item.data.page && typeof onReviewPointSelect === 'function') { onReviewPointSelect(reviewPoint.id, Number(item.data.page), item.data.char_positions); break; } } }}> +
+
+
+ {chain.map((item: ChainItem, idx: number) => ( + + {item.field} + {idx < chain.length - 1 && {typeof chain[idx + 1]?.compareMethod === 'object' ? '' : getCompareMethodText(chain[idx + 1]?.compareMethod)}} + + ))} +
+
+ {chain.map((item: ChainItem, idx: number) => ( + + ))} +
+
+
{res ? : }
+
+
+ ); + } + + // Standard pair (2 elements) + return ( +
+ + +
{ const r = e.currentTarget.getBoundingClientRect(); showTooltip(
{chain.slice(1).map((item: ChainItem, i: number) =>
{typeof item.compareMethod === 'object' ? '' : `${getCompareMethodText(item.compareMethod)}:`}
{res ? '通过' : '不通过'}
)}
, { top: r.top + r.height / 2, left: r.left }); }} onMouseLeave={hideTooltip}> + {res ? : } +
+
+ ); + })} +
+
+ ); +} + +// ── renderModelRule ── +function RenderModelRule({ rule, reviewPoint, onReviewPointSelect, fileFormat }: { rule: Record; reviewPoint: ReviewPoint; onReviewPointSelect: (id: string, page?: number, charPos?: CharPosition[], value?: string) => void; fileFormat?: string }) { + const config = rule.config as { model?: string; fields?: Record; ai_suggestion?: { summary?: string; analysis?: { failure_reason?: string; solution_approach?: string; rule_understanding?: string }; suggestions?: Record; generated_at?: string }; message?: string; res?: boolean } | undefined; + + if (config?.res !== reviewPoint.result) return null; + if (!config) return null; + + const fieldElements: JSX.Element[] = []; + if (config.fields) { + Object.entries(config.fields).forEach(([key, value], index) => { + const res = value.res !== undefined && value.res !== null ? value.res : value.value.trim() !== ''; + fieldElements.push( + + ); + }); + } + + if (config.message) { + const msg = typeof config.message === 'object' ? JSON.stringify(config.message) : String(config.message); + fieldElements.push( +
+
+ AI 评查意见 +
+
+
+

{msg}

+
+
+
+ ); + } + + return <>{fieldElements}; +} + +// ── Main Component ── +export function ReviewPointDetailCard({ reviewPoint, onReviewPointSelect, onStatusChange, fileFormat }: ReviewPointDetailCardProps) { + const [manualNote, setManualNote] = useState( + () => reviewPoint.editAuditStatusMessage || reviewPoint.actionContent || reviewPoint.suggestion || '' + ); + + // reviewPoint 切换时重置默认值 + useEffect(() => { + setManualNote(reviewPoint.editAuditStatusMessage || reviewPoint.actionContent || reviewPoint.suggestion || ''); + }, [reviewPoint.id, reviewPoint.editAuditStatusMessage, reviewPoint.actionContent, reviewPoint.suggestion]); + + const otherRules = filterOtherRule(reviewPoint); + const isPass = reviewPoint.result === true; + const isFail = reviewPoint.result === false && reviewPoint.status === 'error'; + const isWarn = reviewPoint.result === false && (reviewPoint.status === 'warning' || reviewPoint.status === 'info'); + + const statusChip = isPass + ? { cls: 'bg-emerald-50 text-emerald-700 border-emerald-200', icon: 'ri-checkbox-circle-fill', label: '通过' } + : isFail + ? { cls: 'bg-red-50 text-red-700 border-red-200', icon: 'ri-close-circle-fill', label: '不通过' } + : isWarn + ? { cls: 'bg-amber-50 text-amber-700 border-amber-200', icon: 'ri-lightbulb-flash-fill', label: '警告' } + : { cls: 'bg-slate-100 text-slate-600 border-slate-200', icon: 'ri-forbid-2-line', label: '未涉及' }; + + return ( +
+ + + {/* Header */} +
+
+ {reviewPoint.pointId && ( + + #{reviewPoint.pointId} + + )} +

+ {reviewPoint.pointName} +

+
+
+
+ + {statusChip.label} + + { reviewPoint.postAction === 'manual' && ( + + 需人工 + + )} +
+ {reviewPoint.score != null && ( + + 分值 {reviewPoint.score} + + )} +
+
+ + {/* Rule content */} +
+ {otherRules.map((rule, i) => ( + + ))} + {reviewPoint.evaluatedPointResultsLog?.rules?.map((rule, i) => { + if (rule.type === 'consistency') { + return
{otherRules.length > 0 &&
}
; + } + if (rule.type === 'ai') { + return
{otherRules.length > 0 &&
}
; + } + return null; + })} +
+ + {/* Suggestion */} + {reviewPoint.suggestion && !isPass && ( +
+
+ 修改建议 +
+
+ {reviewPoint.suggestion} +
+
+ )} + + {/* Manual review textarea */} + {reviewPoint.postAction === 'manual' && ( +
+
审核意见
+