diff --git a/app/api/evaluation_points/reviews.ts b/app/api/evaluation_points/reviews.ts index 2e7324a..e12e129 100644 --- a/app/api/evaluation_points/reviews.ts +++ b/app/api/evaluation_points/reviews.ts @@ -92,6 +92,7 @@ interface StatsData { success: number; warning: number; error: number; + notApplicable?: number; score: number; } diff --git a/app/components/reviews/ReviewPointsList.tsx b/app/components/reviews/ReviewPointsList.tsx index b33dbda..a094b5c 100644 --- a/app/components/reviews/ReviewPointsList.tsx +++ b/app/components/reviews/ReviewPointsList.tsx @@ -82,6 +82,16 @@ export interface CharPosition { score: number; // OCR识别置信度 } +export interface PdfBboxHighlight { + fieldKey: string; + bbox: [number, number, number, number]; + pageBox: [number, number, number, number]; + pageNum?: number; + page?: number; + confidence?: number; + matchMethod?: string; +} + /** * 评查点类型定义 * 用于展示单个评查结果 @@ -98,7 +108,7 @@ export interface ReviewPoint { title: string; groupName: string; status: string; - content: Record; + content: Record; suggestion: string; needsHumanReview?: boolean; humanReviewNote?: string; @@ -124,6 +134,7 @@ export interface ReviewPoint { failMessage?: string; passMessage?: string; evaluationConfig?: { + confidence?: number; rules?: Array<{ type: string; config?: { @@ -140,7 +151,22 @@ export interface ReviewPoint { res?: boolean; config: Record; }>; + skip_reason?: string; + stages?: Array>; + [key: string]: unknown; }; + fieldPositions?: Record; + confidence?: number; + ruleStatus?: string; + skipReason?: string; + remediation?: unknown; + riskLevel?: string; } // 统计数据类型 @@ -149,6 +175,7 @@ interface Statistics { success: number; warning: number; error: number; + notApplicable?: number; score: number; } @@ -194,8 +221,8 @@ interface EvaluationSummary { interface ReviewPointsListProps { reviewPoints: ReviewPoint[]; statistics: Statistics; - activeReviewPointResultId: string | null; - onReviewPointSelect: (id: string, page?: number, charPositions?: CharPosition[], value?: string) => void; + activeReviewPointResultId: string | number | null; + onReviewPointSelect: (id: string | number, page?: number, charPositions?: CharPosition[], value?: string) => void; onStatusChange: (id: string, editAuditStatusId: string | number, status: string, message: string) => void; fileFormat?: string; // 文档格式类型(PDF、DOCX等) onAiSuggestionReplace?: (searchText: string, replaceText: string, pageNumber: number) => void; // AI建议替换回调 diff --git a/app/components/reviews/index.ts b/app/components/reviews/index.ts index 1f741e4..bfdba1b 100644 --- a/app/components/reviews/index.ts +++ b/app/components/reviews/index.ts @@ -6,7 +6,7 @@ export { FileInfo } from './FileInfo'; export { ReviewTabs } from './ReviewTabs'; export { FilePreview } from './FilePreview'; export { ReviewPointsList } from './ReviewPointsList'; -export type { ReviewPoint } from './ReviewPointsList'; +export type { ReviewPoint, PdfBboxHighlight } from './ReviewPointsList'; export { AIAnalysis } from './AIAnalysis'; export { FileDetails } from './FileDetails'; export { Comparison } from './Comparison'; @@ -15,4 +15,4 @@ 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 +export { FileInfoPanel } from './rightColumn/FileInfoPanel'; diff --git a/app/components/reviews/leftColumn/RulesDirectory.tsx b/app/components/reviews/leftColumn/RulesDirectory.tsx index 93ae5ab..00a0e7b 100644 --- a/app/components/reviews/leftColumn/RulesDirectory.tsx +++ b/app/components/reviews/leftColumn/RulesDirectory.tsx @@ -1,8 +1,8 @@ /** - * 左栏 · 规则目录 - * 显示文件名、分数进度条、搜索、评查点分组列表 + * 左栏 / 规则目录 + * 展示文件信息、分数进度、搜索与评查点列表。 */ -import { useState, useMemo } from 'react'; +import { useMemo, useState } from 'react'; import type { ReviewPoint } from '../ReviewPointsList'; interface Statistics { @@ -24,14 +24,17 @@ interface RulesDirectoryProps { } type PointStatus = 'pass' | 'warn' | 'fail' | 'skipped'; +type StatusFilter = 'all' | PointStatus; -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'; +function classifyPoint(point: ReviewPoint): PointStatus { + if (point.status === 'notApplicable' || point.status === 'not_applicable') return 'skipped'; + if (point.result === true || (point.result === undefined && point.status === 'success')) return 'pass'; + if (point.result === false) { + if (point.status === 'error') return 'fail'; + if (point.status === 'warning' || point.status === 'info') return 'warn'; } + if (point.status === 'error') return 'fail'; + if (point.status === 'warning' || point.status === 'info') return 'warn'; return 'pass'; } @@ -42,6 +45,53 @@ const STATUS_ICON: Record = { skipped: { icon: 'ri-forbid-2-line', color: 'text-slate-400' }, }; +const FILTER_CHIPS: Array<{ + key: StatusFilter; + label: string; + getClassName: (active: boolean) => string; +}> = [ + { + key: 'all', + label: '全部', + getClassName: (active) => + active + ? 'bg-slate-700 text-white border-slate-700' + : 'bg-white text-slate-600 border-slate-200 hover:bg-slate-50', + }, + { + key: 'warn', + label: '提醒', + getClassName: (active) => + active + ? 'bg-amber-500 text-white border-amber-500' + : 'bg-white text-amber-700 border-amber-200 hover:bg-amber-50', + }, + { + key: 'fail', + label: '问题', + getClassName: (active) => + active + ? 'bg-red-500 text-white border-red-500' + : 'bg-white text-red-700 border-red-200 hover:bg-red-50', + }, + { + key: 'pass', + label: '通过', + getClassName: (active) => + active + ? 'bg-emerald-500 text-white border-emerald-500' + : 'bg-white text-emerald-700 border-emerald-200 hover:bg-emerald-50', + }, + { + key: 'skipped', + label: '跳过', + getClassName: (active) => + active + ? 'bg-slate-500 text-white border-slate-500' + : 'bg-white text-slate-600 border-slate-200 hover:bg-slate-100', + }, +]; + function RuleListItem({ point, isActive, @@ -53,33 +103,28 @@ function RuleListItem({ showCategory?: boolean; onClick: () => void; }) { - const cls = classifyPoint(point); - const s = STATUS_ICON[cls]; + const status = classifyPoint(point); + const statusMeta = STATUS_ICON[status]; + const trailingLabel = showCategory ? point.groupName : point.pointCode || point.pointId || ''; return ( @@ -95,60 +140,76 @@ export function RulesDirectory({ onBack, }: RulesDirectoryProps) { const [searchText, setSearchText] = useState(''); + const [statusFilter, setStatusFilter] = useState('all'); const [passOpen, setPassOpen] = useState(true); - const [openCategories, setOpenCategories] = useState>(new Set()); + const [openCategories, setOpenCategories] = useState>( + () => new Set(reviewPoints.map((point) => point.groupName).filter(Boolean)) + ); const q = searchText.toLowerCase(); - const matchSearch = (p: ReviewPoint) => + const matchSearch = (point: ReviewPoint) => !q || - (p.pointName?.toLowerCase().includes(q)) || - (p.pointCode?.toLowerCase().includes(q)) || - (p.groupName?.toLowerCase().includes(q)); + point.pointName?.toLowerCase().includes(q) || + String(point.pointId ?? '').toLowerCase().includes(q) || + String(point.pointCode ?? '').toLowerCase().includes(q) || + point.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 matchStatus = (point: ReviewPoint) => + statusFilter === 'all' || classifyPoint(point) === statusFilter; + + const statusCounts = useMemo(() => { + return reviewPoints.reduce>( + (acc, point) => { + const status = classifyPoint(point); + acc.all += 1; + acc[status] += 1; + return acc; + }, + { all: 0, pass: 0, warn: 0, fail: 0, skipped: 0 } ); - const passed = filtered.filter((p) => classifyPoint(p) === 'pass'); + }, [reviewPoints]); + + const { needAttention, passed, passedByGroup, hasSearchResult } = useMemo(() => { + const filtered = reviewPoints.filter((point) => matchSearch(point) && matchStatus(point)); + const needAttention = filtered.filter((point) => classifyPoint(point) !== 'pass'); + const passed = filtered.filter((point) => classifyPoint(point) === 'pass'); const passedByGroup: Record = {}; - passed.forEach((p) => { - const key = p.groupName || '未分组'; - (passedByGroup[key] = passedByGroup[key] || []).push(p); + passed.forEach((point) => { + const key = point.groupName || '未分组'; + (passedByGroup[key] = passedByGroup[key] || []).push(point); }); - return { needAttention, passed, passedByGroup }; - }, [reviewPoints, searchText]); + return { + needAttention, + passed, + passedByGroup, + hasSearchResult: filtered.length > 0, + }; + }, [reviewPoints, q, statusFilter]); - // 计算进度条百分比 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 naPct = total > 0 ? ((statistics.notApplicable || 0) / total) * 100 : 0; - const attentionCount = needAttention.length; - const passedCount = passed.length; + const attentionCount = reviewPoints.filter((point) => classifyPoint(point) !== 'pass').length; + const passedCount = reviewPoints.filter((point) => classifyPoint(point) === 'pass').length; - const toggleCategory = (cat: string) => { + const toggleCategory = (category: string) => { setOpenCategories((prev) => { const next = new Set(prev); - if (next.has(cat)) next.delete(cat); - else next.add(cat); + if (next.has(category)) next.delete(category); + else next.add(category); return next; }); }; return ( ); diff --git a/app/components/reviews/previewComponents/PdfPreviewTest.tsx b/app/components/reviews/previewComponents/PdfPreviewTest.tsx index 1b62545..d5c6ac6 100644 --- a/app/components/reviews/previewComponents/PdfPreviewTest.tsx +++ b/app/components/reviews/previewComponents/PdfPreviewTest.tsx @@ -11,7 +11,7 @@ import { useState, useEffect, useMemo, useRef, useCallback } from 'react'; import { Document, Page, pdfjs } from 'react-pdf'; import { toastService } from '~/components/ui/Toast'; -import type { ReviewPoint } from '../ReviewPointsList'; +import type { ReviewPoint, PdfBboxHighlight } from '../ReviewPointsList'; import 'react-pdf/dist/esm/Page/TextLayer.css'; import 'react-pdf/dist/esm/Page/AnnotationLayer.css'; @@ -33,6 +33,7 @@ interface PdfPreviewProps { filePath: string; targetPage?: number; charPositions?: Array<{ box: number[][]; char: string; score: number }>; + bboxHighlight?: PdfBboxHighlight; isStructuredView?: boolean; activeReviewPointResultId?: string | null; pageOffset?: number; @@ -43,11 +44,20 @@ interface PdfPreviewProps { reviewPoints?: ReviewPoint[]; } +const THUMB_WIDTH = 112; +const THUMB_ESTIMATED_HEIGHT = 210; +const THUMB_OVERSCAN = 3; +const MAIN_PAGE_MAX_DEVICE_PIXEL_RATIO = 1.5; + // ============================================================ // ReviewPoint → 状态映射 // ============================================================ const STATUS_ORDER: Record = { fail: 0, warn: 1, pending: 2, pass: 3 }; +function getContentItemPage(item: ReviewPoint['content'][string]): number | string | undefined { + return typeof item === 'string' ? undefined : item?.page; +} + const STATUS_BADGE: Record = { fail: { cls: 'bg-red-500', ic: 'ri-close-circle-fill' }, warn: { cls: 'bg-amber-500', ic: 'ri-lightbulb-flash-fill' }, @@ -77,7 +87,7 @@ function getPointPages(p: ReviewPoint): number[] { if (Number.isFinite(n) && n > 0) set.add(n); }; if (p.contentPage) Object.values(p.contentPage).forEach(addMaybe); - if (p.content) Object.values(p.content).forEach(v => addMaybe(v?.page)); + if (p.content) Object.values(p.content).forEach(v => addMaybe(getContentItemPage(v))); return [...set].sort((a, b) => a - b); } @@ -95,12 +105,20 @@ function getFieldsOnPage(p: ReviewPoint, page: number): string[] { } if (!fields.length && p.content) { Object.entries(p.content).forEach(([k, v]) => { - if (matches(v?.page)) fields.push(k); + if (matches(getContentItemPage(v))) fields.push(k); }); } return fields; } +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} + +function isValidQuad(value: unknown): value is [number, number, number, number] { + return Array.isArray(value) && value.length === 4 && value.every(item => typeof item === 'number' && Number.isFinite(item)); +} + // ============================================================ // 组件 // ============================================================ @@ -108,6 +126,7 @@ export function PdfPreviewTest({ filePath, targetPage, charPositions, + bboxHighlight, isStructuredView = false, activeReviewPointResultId, pageOffset = 0, @@ -115,63 +134,55 @@ export function PdfPreviewTest({ onZoomChange, reviewPoints, }: PdfPreviewProps) { - // ---------- 基础状态 ---------- const [numPages, setNumPages] = useState(null); const [currentPage, setCurrentPage] = useState(1); const [zoomLevel, setZoomLevel] = useState(100); const [loadError, setLoadError] = useState(null); - // 略缩图面板 const [showThumbs, setShowThumbs] = useState(true); const [thumbMode, setThumbMode] = useState<'filtered' | 'all'>('filtered'); - - // 坐标校准(保留主视口高亮逻辑) const [coordinateScale, setCoordinateScale] = useState(0.83); const [isScaleAutoCalculated, setIsScaleAutoCalculated] = useState(false); - - // 页码跳转输入 const [pageInputValue, setPageInputValue] = useState(''); + const [pageRenderTick, setPageRenderTick] = useState(0); + const [thumbsScrollTop, setThumbsScrollTop] = useState(0); + const [thumbsViewportHeight, setThumbsViewportHeight] = useState(0); const viewportRef = useRef(null); const thumbsPanelRef = useRef(null); - // ---------- 派生数据 ---------- - const fileUrl = useMemo( - () => `/api/pdf-proxy?path=${encodeURIComponent(filePath)}`, - [filePath], - ); + const fileUrl = useMemo(() => `/api/pdf-proxy?path=${encodeURIComponent(filePath)}`, [filePath]); const activePoint = useMemo( () => reviewPoints?.find(p => p.id === activeReviewPointResultId), [reviewPoints, activeReviewPointResultId], ); - // 当前规则涉及的页(字段级,带 pageOffset) const rulePages = useMemo(() => { if (!activePoint) return []; - return getPointPages(activePoint).map(p => p + pageOffset); + return getPointPages(activePoint).map(page => page + pageOffset); }, [activePoint, pageOffset]); - // 每页状态聚合 const pageStatusMap = useMemo>(() => { - const m = new Map(); - if (!reviewPoints?.length) return m; - reviewPoints.forEach(p => { - const cls = classifyReviewPoint(p); - if (cls === 'skipped') return; - const pages = getPointPages(p).map(x => x + pageOffset); - pages.forEach(pg => { - const cur = m.get(pg) || { worst: 'pass', count: 0, issues: 0 }; - if (STATUS_ORDER[cls] < STATUS_ORDER[cur.worst]) cur.worst = cls; - cur.count += 1; - if (cls !== 'pass') cur.issues += 1; - m.set(pg, cur); + const map = new Map(); + if (!reviewPoints?.length) return map; + + reviewPoints.forEach(point => { + const status = classifyReviewPoint(point); + if (status === 'skipped') return; + + getPointPages(point).map(page => page + pageOffset).forEach(page => { + const current = map.get(page) || { worst: 'pass', count: 0, issues: 0 }; + if (STATUS_ORDER[status] < STATUS_ORDER[current.worst]) current.worst = status; + current.count += 1; + if (status !== 'pass') current.issues += 1; + map.set(page, current); }); }); - return m; + + return map; }, [reviewPoints, pageOffset]); - // 当前高亮标签(工具栏右侧) const highlightLabel = useMemo(() => { if (!activePoint) return null; const code = activePoint.pointCode || activePoint.id; @@ -179,7 +190,42 @@ export function PdfPreviewTest({ return `${code}${name ? ' · ' + name : ''}`; }, [activePoint]); - // ---------- 通知上层 ---------- + const effThumbMode: 'filtered' | 'all' = + thumbMode === 'filtered' && rulePages.length > 0 ? 'filtered' : 'all'; + + const thumbPages = useMemo(() => { + if (!numPages) return []; + if (effThumbMode === 'filtered') return rulePages.filter(page => page >= 1 && page <= numPages); + return Array.from({ length: numPages }, (_, index) => index + 1); + }, [effThumbMode, rulePages, numPages]); + + const rulePageSet = useMemo(() => new Set(rulePages), [rulePages]); + + const totalThumbHeight = thumbPages.length * THUMB_ESTIMATED_HEIGHT; + + const visibleThumbRange = useMemo(() => { + if (!thumbPages.length) { + return { start: 0, end: 0 }; + } + + const viewportHeight = thumbsViewportHeight || THUMB_ESTIMATED_HEIGHT * 3; + const start = Math.max(0, Math.floor(thumbsScrollTop / THUMB_ESTIMATED_HEIGHT) - THUMB_OVERSCAN); + const visibleCount = Math.ceil(viewportHeight / THUMB_ESTIMATED_HEIGHT) + THUMB_OVERSCAN * 2; + const end = Math.min(thumbPages.length, start + visibleCount); + + return { start, end }; + }, [thumbPages.length, thumbsScrollTop, thumbsViewportHeight]); + + const visibleThumbItems = useMemo( + () => thumbPages.slice(visibleThumbRange.start, visibleThumbRange.end), + [thumbPages, visibleThumbRange.start, visibleThumbRange.end], + ); + + const mainPageDevicePixelRatio = useMemo(() => { + if (typeof window === 'undefined') return 1; + return Math.min(window.devicePixelRatio || 1, MAIN_PAGE_MAX_DEVICE_PIXEL_RATIO); + }, []); + useEffect(() => { if (numPages && onNumPagesChange) onNumPagesChange(numPages); }, [numPages, onNumPagesChange]); @@ -188,7 +234,6 @@ export function PdfPreviewTest({ if (onZoomChange) onZoomChange(zoomLevel); }, [zoomLevel, onZoomChange]); - // ---------- targetPage 跳转 ---------- useEffect(() => { if (targetPage && numPages) { const next = Math.max(1, Math.min(numPages, targetPage + pageOffset)); @@ -196,33 +241,77 @@ export function PdfPreviewTest({ } }, [targetPage, numPages, pageOffset, activeReviewPointResultId]); - // ---------- 切换规则:重置略缩图模式为 filtered ---------- useEffect(() => { if (activeReviewPointResultId) setThumbMode('filtered'); }, [activeReviewPointResultId]); - // ---------- 文件路径变化:重置坐标自动计算 ---------- useEffect(() => { setIsScaleAutoCalculated(false); }, [filePath]); - // ---------- 略缩图滚动到当前页 ---------- useEffect(() => { const host = thumbsPanelRef.current; if (!host) return; - const el = host.querySelector(`[data-thumb-page="${currentPage}"]`); - if (el) el.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); - }, [currentPage, thumbMode, showThumbs]); - // ---------- PDF 加载 ---------- + const currentIndex = thumbPages.indexOf(currentPage); + if (currentIndex < 0) return; + + const itemTop = currentIndex * THUMB_ESTIMATED_HEIGHT; + const itemBottom = itemTop + THUMB_ESTIMATED_HEIGHT; + const viewportTop = host.scrollTop; + const viewportBottom = viewportTop + host.clientHeight; + + if (itemTop < viewportTop) { + host.scrollTo({ top: itemTop, behavior: 'smooth' }); + return; + } + + if (itemBottom > viewportBottom) { + host.scrollTo({ + top: Math.max(0, itemBottom - host.clientHeight), + behavior: 'smooth', + }); + } + }, [currentPage, thumbMode, showThumbs, thumbPages]); + + useEffect(() => { + const host = thumbsPanelRef.current; + if (!host) return; + + const updateViewport = () => { + setThumbsViewportHeight(host.clientHeight); + setThumbsScrollTop(host.scrollTop); + }; + + updateViewport(); + + const handleScroll = () => { + setThumbsScrollTop(host.scrollTop); + }; + + host.addEventListener('scroll', handleScroll, { passive: true }); + + let resizeObserver: ResizeObserver | null = null; + if (typeof ResizeObserver !== 'undefined') { + resizeObserver = new ResizeObserver(() => { + updateViewport(); + }); + resizeObserver.observe(host); + } + + return () => { + host.removeEventListener('scroll', handleScroll); + resizeObserver?.disconnect(); + }; + }, [showThumbs, thumbMode, filePath]); + const onDocumentLoadSuccess = useCallback( - ({ numPages: n }: { numPages: number }) => { - setNumPages(n); - // 初始页:优先 targetPage,否则第 1 页 + ({ numPages: loadedNumPages }: { numPages: number }) => { + setNumPages(loadedNumPages); if (targetPage) { - setCurrentPage(Math.max(1, Math.min(n, targetPage + pageOffset))); + setCurrentPage(Math.max(1, Math.min(loadedNumPages, targetPage + pageOffset))); } else { - setCurrentPage(p => Math.max(1, Math.min(n, p))); + setCurrentPage(page => Math.max(1, Math.min(loadedNumPages, page))); } }, [targetPage, pageOffset], @@ -233,19 +322,18 @@ export function PdfPreviewTest({ setLoadError('PDF文档加载失败:' + (error.message || '未知错误')); }, []); - // ---------- 主页面加载(自动校准坐标) ---------- const onMainPageLoadSuccess = useCallback( (page: any) => { + setPageRenderTick(tick => tick + 1); if (isScaleAutoCalculated) return; + setTimeout(() => { const pdfOriginalWidthPt = page.view?.[2] || page.originalWidth || page.width; - const canvas = viewportRef.current?.querySelector( - '.pdf-main-canvas .react-pdf__Page__canvas', - ) as HTMLCanvasElement | null; + const canvas = viewportRef.current?.querySelector('.pdf-main-canvas .react-pdf__Page__canvas') as HTMLCanvasElement | null; if (canvas && pdfOriginalWidthPt) { const canvasDisplayWidth = canvas.offsetWidth; const currentScale = zoomLevel / 100; - const autoScale = (canvasDisplayWidth / currentScale) / pdfOriginalWidthPt; + const autoScale = canvasDisplayWidth / currentScale / pdfOriginalWidthPt; setCoordinateScale(autoScale); setIsScaleAutoCalculated(true); } @@ -254,71 +342,89 @@ export function PdfPreviewTest({ [isScaleAutoCalculated, zoomLevel], ); - // ---------- 翻页 / 缩放 ---------- - const goPrev = () => setCurrentPage(p => Math.max(1, p - 1)); - const goNext = () => setCurrentPage(p => Math.min(numPages || p, p + 1)); - const zoomIn = () => setZoomLevel(z => Math.min(200, z + 10)); - const zoomOut = () => setZoomLevel(z => Math.max(50, z - 10)); + const goPrev = () => setCurrentPage(page => Math.max(1, page - 1)); + const goNext = () => setCurrentPage(page => Math.min(numPages || page, page + 1)); + const zoomIn = () => setZoomLevel(level => Math.min(200, level + 10)); + const zoomOut = () => setZoomLevel(level => Math.max(50, level - 10)); const jumpToHighlight = () => { if (!activePoint || rulePages.length === 0) { toastService.info('当前规则无关联页'); return; } - const first = rulePages[0]; - if (numPages && first >= 1 && first <= numPages) setCurrentPage(first); + + const firstPage = rulePages[0]; + if (numPages && firstPage >= 1 && firstPage <= numPages) setCurrentPage(firstPage); }; const handlePageInputChange = (e: React.ChangeEvent) => { setPageInputValue(e.target.value.replace(/\D/g, '')); }; + const handlePageJump = () => { if (!pageInputValue || !numPages) return; - const n = parseInt(pageInputValue, 10); - if (n > 0 && n <= numPages) { - setCurrentPage(n); - setPageInputValue(''); - } else { - toastService.warning(`请输入有效页码 (1-${numPages})`); + const nextPage = parseInt(pageInputValue, 10); + if (nextPage > 0 && nextPage <= numPages) { + setCurrentPage(nextPage); setPageInputValue(''); + return; } + + toastService.warning(`请输入有效页码 (1-${numPages})`); + setPageInputValue(''); }; - // ---------- 高亮矩形(对齐原 PdfPreview 的字符位置) ---------- - const mainHighlight = useMemo(() => { - if (!charPositions?.length || !targetPage) { - console.log('[PdfPreviewTest] highlight skipped: no charPositions/targetPage', { - hasCharPositions: !!charPositions?.length, - targetPage, - }); - return null; - } - if (currentPage !== targetPage + pageOffset) { - console.log('[PdfPreviewTest] highlight skipped: page mismatch', { - currentPage, - targetPage, - pageOffset, - expected: targetPage + pageOffset, - }); - return null; - } + const bboxRectHighlight = useMemo(() => { + if (!bboxHighlight || !isValidQuad(bboxHighlight.bbox) || !isValidQuad(bboxHighlight.pageBox)) return null; + + const expectedPage = bboxHighlight.page ?? (typeof bboxHighlight.pageNum === 'number' ? bboxHighlight.pageNum + 1 : targetPage); + if (!expectedPage || currentPage !== expectedPage + pageOffset) return null; + + const canvas = viewportRef.current?.querySelector('.pdf-main-canvas .react-pdf__Page__canvas') as HTMLCanvasElement | null; + if (!canvas) return null; + + const [pageX0, pageY0, pageX1, pageY1] = bboxHighlight.pageBox; + const [bboxLeft, bboxTop, bboxRight, bboxBottom] = bboxHighlight.bbox; + const pageWidth = pageX1 - pageX0; + const pageHeight = pageY1 - pageY0; + if (pageWidth <= 0 || pageHeight <= 0) return null; + + const left = clamp(Math.min(bboxLeft, bboxRight), pageX0, pageX1); + const right = clamp(Math.max(bboxLeft, bboxRight), pageX0, pageX1); + const top = clamp(Math.min(bboxTop, bboxBottom), pageY0, pageY1); + const bottom = clamp(Math.max(bboxTop, bboxBottom), pageY0, pageY1); + if (right <= left || bottom <= top) return null; + + return { + x: ((left - pageX0) / pageWidth) * canvas.offsetWidth, + y: ((top - pageY0) / pageHeight) * canvas.offsetHeight, + width: ((right - left) / pageWidth) * canvas.offsetWidth, + height: ((bottom - top) / pageHeight) * canvas.offsetHeight, + text: bboxHighlight.fieldKey, + }; + }, [bboxHighlight, targetPage, currentPage, pageOffset, zoomLevel, pageRenderTick]); + + const charRectHighlight = useMemo(() => { + if (bboxRectHighlight || !charPositions?.length || !targetPage) return null; + if (currentPage !== targetPage + pageOffset) return null; const scale = zoomLevel / 100; - let minX = Infinity, - minY = Infinity, - maxX = -Infinity, - maxY = -Infinity; + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; const chars: string[] = []; - charPositions.forEach(cp => { - chars.push(cp.char); - cp.box.forEach(pt => { - const [x, y] = pt; + + charPositions.forEach(position => { + chars.push(position.char); + position.box.forEach(([x, y]) => { if (x < minX) minX = x; if (x > maxX) maxX = x; if (y < minY) minY = y; if (y > maxY) maxY = y; }); }); + return { x: minX * coordinateScale * scale, y: minY * coordinateScale * scale, @@ -326,21 +432,10 @@ export function PdfPreviewTest({ height: (maxY - minY) * coordinateScale * scale, text: chars.join(''), }; - }, [charPositions, targetPage, currentPage, pageOffset, zoomLevel, coordinateScale]); + }, [bboxRectHighlight, charPositions, targetPage, currentPage, pageOffset, zoomLevel, coordinateScale, pageRenderTick]); - // ---------- 略缩图可见页列表 ---------- - const effThumbMode: 'filtered' | 'all' = - thumbMode === 'filtered' && rulePages.length > 0 ? 'filtered' : 'all'; + const mainHighlight = bboxRectHighlight || charRectHighlight; - const thumbPages = useMemo(() => { - if (!numPages) return []; - if (effThumbMode === 'filtered') return rulePages.filter(p => p >= 1 && p <= numPages); - return Array.from({ length: numPages }, (_, i) => i + 1); - }, [effThumbMode, rulePages, numPages]); - - // ============================================================ - // 渲染 - // ============================================================ if (loadError) { return (
{loadError}
@@ -391,7 +486,7 @@ export function PdfPreviewTest({ onKeyDown={e => { if (e.key === 'Enter') handlePageJump(); }} - className="w-5 h-6 text-center bg-slate-50 border border-slate-200 rounded text-[12px] font-mono outline-none focus:border-primary" + className="w-6 h-6 text-center bg-slate-50 border border-slate-200 rounded text-[12px] font-mono outline-none focus:border-primary" /> / {numPages ?? '-'} @@ -481,7 +576,7 @@ export function PdfPreviewTest({ {/* 略缩图列表 */}
{numPages === null ? (
加载中…
@@ -491,76 +586,81 @@ export function PdfPreviewTest({
此规则无关联页面
) : ( - thumbPages.map(p => { - const info = pageStatusMap.get(p); - const isCur = p === currentPage; - const isRulePage = rulePages.includes(p); +
+ {visibleThumbItems.map((p, visibleIndex) => { + const itemIndex = visibleThumbRange.start + visibleIndex; + const info = pageStatusMap.get(p); + const isCur = p === currentPage; + const isRulePage = rulePageSet.has(p); - let badge: React.ReactNode = null; - if (info) { - const b = STATUS_BADGE[info.worst]; - const num = info.issues > 0 ? info.issues : info.worst === 'pass' ? '' : info.count; - badge = ( - - {num ? num : } - - ); - } + let badge: React.ReactNode = null; + if (info) { + const b = STATUS_BADGE[info.worst]; + const num = info.issues > 0 ? info.issues : info.worst === 'pass' ? '' : info.count; + badge = ( + + {num ? num : } + + ); + } - const frameCls = isCur - ? 'ring-2 ring-[#00684a] shadow-md' - : effThumbMode === 'all' && isRulePage - ? 'ring-1 ring-[#00684a]/40 shadow-sm' - : 'border border-slate-200 group-hover:border-slate-400 shadow-sm'; + const frameCls = isCur + ? 'ring-2 ring-[#00684a] shadow-md' + : effThumbMode === 'all' && isRulePage + ? 'ring-1 ring-[#00684a]/40 shadow-sm' + : 'border border-slate-200 group-hover:border-slate-400 shadow-sm'; - let fieldsLabel: React.ReactNode = null; - if (effThumbMode === 'filtered' && activePoint) { - const fs = getFieldsOnPage(activePoint, p - pageOffset); - const txt = fs.length ? fs.join(' · ') : '规则锚定页'; - fieldsLabel = ( -
- {txt} -
- ); - } - - return ( - - ); - }) +
+
+ } + error={
} + /> +
+ {badge} +
+
+ {p} +
+ {fieldsLabel} + + ); + })} +
)}
@@ -586,7 +686,7 @@ export function PdfPreviewTest({ void; activeReviewPoint: ReviewPoint | null; reviewPoints: ReviewPoint[]; + detailMode?: 'legacy' | 'leaudit'; fileInfo: FileInfoData; reviewInfo: ReviewInfoData; - onReviewPointSelect: (id: string | number, page?: number, charPositions?: CharPosition[], value?: string) => void; - onStatusChange: (id: string | number, editAuditStatusId: string | number, status: string, message: string) => void; + onReviewPointSelect: (id: string | number, page?: number, charPositions?: CharPosition[], value?: string, bboxHighlight?: PdfBboxHighlight) => void; + onStatusChange: (id: string, editAuditStatusId: string | number, status: string, message: string) => void; onConfirmResults: () => void; onDownload: () => void; auditStatus?: number; @@ -45,63 +46,191 @@ interface DetailPanelProps { showComparisonButton?: boolean; } -type ExtractedFieldValue = { - value?: unknown; - page?: number | string; -}; +function isValidQuad(value: unknown): value is [number, number, number, number] { + return Array.isArray(value) && value.length === 4 && value.every(item => typeof item === 'number' && Number.isFinite(item)); +} + +function hasNonZeroQuad(value: [number, number, number, number]): boolean { + return value.some(item => item !== 0); +} + +function getFieldRawValue(value: ReviewPoint['content'][string]): unknown { + if (value == null) return null; + + if (typeof value === 'object' && 'value' in value) { + return (value as { value?: unknown }).value ?? null; + } + + return value; +} + +function getFieldDisplayText(rawValue: unknown): string { + if (rawValue == null) return '缺失'; + if (typeof rawValue === 'string' || typeof rawValue === 'number' || typeof rawValue === 'boolean') return String(rawValue); + + try { + return JSON.stringify(rawValue); + } catch { + return String(rawValue); + } +} + +function getFieldHighlightText(rawValue: unknown): string | undefined { + if (rawValue == null) return undefined; + if (typeof rawValue !== 'string' && typeof rawValue !== 'number' && typeof rawValue !== 'boolean') return undefined; + + const text = String(rawValue).trim(); + return text ? String(rawValue) : undefined; +} + +function getFieldPage(point: ReviewPoint, key: string, value: ReviewPoint['content'][string]): number | undefined { + const contentPage = point.contentPage?.[key]; + const parsedContentPage = Number(contentPage); + if (Number.isFinite(parsedContentPage) && parsedContentPage > 0) return parsedContentPage; + + const inlinePage = typeof value === 'object' && value && 'page' in value + ? Number((value as { page?: unknown }).page) + : NaN; + if (Number.isFinite(inlinePage) && inlinePage > 0) return inlinePage; + + const pageNum = point.fieldPositions?.[key]?.page_num; + if (typeof pageNum === 'number' && Number.isFinite(pageNum) && pageNum >= 0) return pageNum + 1; + + return undefined; +} + +function getFieldConfidence(point: ReviewPoint, key: string): number | undefined { + const confidence = point.fieldPositions?.[key]?.confidence; + if (typeof confidence !== 'number' || !Number.isFinite(confidence)) return undefined; + return confidence; +} + +function getFieldBboxHighlight(point: ReviewPoint, key: string, page?: number): PdfBboxHighlight | undefined { + const fieldPosition = point.fieldPositions?.[key]; + if (!fieldPosition || !isValidQuad(fieldPosition.bbox) || !isValidQuad(fieldPosition.page_box)) return undefined; + if (!hasNonZeroQuad(fieldPosition.bbox) || !hasNonZeroQuad(fieldPosition.page_box)) return undefined; + + return { + fieldKey: key, + bbox: [...fieldPosition.bbox], + pageBox: [...fieldPosition.page_box], + pageNum: fieldPosition.page_num, + page, + confidence: fieldPosition.confidence, + matchMethod: fieldPosition.match_method, + }; +} function ExtractedFieldsPanel({ reviewPoints, onFieldClick, }: { reviewPoints: ReviewPoint[]; - onFieldClick: (pointId: string | number, page: number) => void; + onFieldClick: (pointId: string | number, page?: number, value?: string, bboxHighlight?: PdfBboxHighlight) => void; }) { - const fields: Array<{ key: string; value: string; page?: number; pointName: string; pointId: string | number }> = []; + const handleFieldNavigate = (pointId: string, page?: number, value?: string, bboxHighlight?: PdfBboxHighlight) => { + if (!page) return; + const selectedText = typeof window !== 'undefined' ? window.getSelection?.()?.toString().trim() : ''; + if (selectedText) return; + onFieldClick(pointId, page, value, bboxHighlight); + }; + + const fields: Array<{ + key: string; + displayValue: string; + highlightValue?: string; + isMissing: boolean; + confidence?: number; + page?: number; + pointId: string | number; + bboxHighlight?: PdfBboxHighlight; + }> = []; reviewPoints.forEach((p) => { if (p.content) { - Object.entries(p.content).forEach(([key, data]) => { - const fieldData = (data && typeof data === 'object' ? data : {}) as ExtractedFieldValue & { text?: string }; - const val = fieldData.value; - const page = fieldData.page; - const text = typeof val === 'object' && val !== null - ? ('text' in (val as Record) ? String((val as Record).text || '') : JSON.stringify(val)) - : String(val || ''); + Object.entries(p.content).forEach(([key, rawValue]) => { + const fieldRawValue = getFieldRawValue(rawValue); + const displayValue = getFieldDisplayText(fieldRawValue); + const highlightValue = getFieldHighlightText(fieldRawValue); + const page = getFieldPage(p, key, rawValue); + const confidence = getFieldConfidence(p, key); + const bboxHighlight = getFieldBboxHighlight(p, key, page); + fields.push({ key, - value: text, - page: page ? Number(page) : undefined, - pointName: p.pointName, + displayValue, + highlightValue, + isMissing: fieldRawValue == null, + confidence, + page, pointId: p.id, + bboxHighlight, }); }); } }); return ( -
-
- 抽取字段 {fields.length} +
+
+
+

+ 抽取字段 {fields.length} +

+
置信度 · 锚定页
+
+ {fields.length === 0 ? ( -
暂无抽取字段
+
暂无抽取字段
) : ( -
+
{fields.map((f, i) => ( - + +
+
+ {f.confidence == null ? '-' : `${Math.round(f.confidence * 100)}%`} +
+ {f.page ? ( + + ) : ( +
-
+ )} +
+
))}
)} @@ -120,6 +249,7 @@ export function DetailPanel({ onTabChange, activeReviewPoint, reviewPoints, + detailMode = 'legacy', fileInfo, reviewInfo, onReviewPointSelect, @@ -186,6 +316,7 @@ export function DetailPanel({ onReviewPointSelect={onReviewPointSelect} onStatusChange={onStatusChange} fileFormat={fileFormat} + detailMode={detailMode} />
) : ( @@ -199,8 +330,8 @@ export function DetailPanel({ {activeTab === 'fields' && ( { - onReviewPointSelect(pointId, page); + onFieldClick={(pointId, page, value, bboxHighlight) => { + onReviewPointSelect(pointId, page, undefined, value, bboxHighlight); }} /> )} diff --git a/app/components/reviews/rightColumn/ReviewPointDetailCard.tsx b/app/components/reviews/rightColumn/ReviewPointDetailCard.tsx index cab0926..e8300c9 100644 --- a/app/components/reviews/rightColumn/ReviewPointDetailCard.tsx +++ b/app/components/reviews/rightColumn/ReviewPointDetailCard.tsx @@ -5,13 +5,17 @@ import { useState, useEffect, useRef, useMemo } from 'react'; import { createPortal } from 'react-dom'; import { toastService } from '~/components/ui/Toast'; -import type { ReviewPoint, CharPosition } from '../ReviewPointsList'; +import type { ReviewPoint, CharPosition, PdfBboxHighlight } from '../ReviewPointsList'; +import { CorporateInfoModal } from '../../corporate-information'; +import type { BusinessInfoResult, DishonestyResult } from '../../corporate-information'; +import { queryCompanyInfo } from '~/api/corporate-information/qichacha'; interface ReviewPointDetailCardProps { reviewPoint: ReviewPoint; - onReviewPointSelect: (id: string | number, page?: number, charPositions?: CharPosition[], value?: string) => void; - onStatusChange: (id: string | number, editAuditStatusId: string | number, status: string, message: string) => void; + onReviewPointSelect: (id: string | number, page?: number, charPositions?: CharPosition[], value?: string, bboxHighlight?: PdfBboxHighlight) => void; + onStatusChange: (id: string, editAuditStatusId: string | number, status: string, message: string) => void; fileFormat?: string; + detailMode?: 'legacy' | 'leaudit'; } // ── 比较方法映射 ── @@ -33,6 +37,58 @@ const getRuleTypeText = (type?: string): string => { return ruleTypeMap[type] || type; }; +function normalizeActionContent(actionContent?: string | string[]): string { + if (typeof actionContent === 'string') return actionContent; + if (Array.isArray(actionContent)) { + return actionContent + .map(item => typeof item === 'string' ? item : JSON.stringify(item)) + .filter(Boolean) + .join('\n'); + } + return ''; +} + +function getLeauditNote(reviewPoint: ReviewPoint): string { + return reviewPoint.editAuditStatusMessage || normalizeActionContent(reviewPoint.actionContent) || reviewPoint.suggestion || ''; +} + +function getLeauditRawFieldValue(value: ReviewPoint['content'][string]): unknown { + if (value == null) return undefined; + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') return value; + if (typeof value === 'object' && 'value' in value) return value.value; + return value; +} + +function isValidQuad(value: unknown): value is [number, number, number, number] { + return Array.isArray(value) && value.length === 4 && value.every(item => typeof item === 'number' && Number.isFinite(item)); +} + +function getLeauditTargetPage(reviewPoint: ReviewPoint, fieldKey: string): number | undefined { + const contentPage = reviewPoint.contentPage?.[fieldKey]; + const parsedContentPage = Number(contentPage); + if (Number.isFinite(parsedContentPage) && parsedContentPage > 0) return parsedContentPage; + + const pageNum = reviewPoint.fieldPositions?.[fieldKey]?.page_num; + if (typeof pageNum === 'number' && Number.isFinite(pageNum) && pageNum >= 0) return pageNum + 1; + + return undefined; +} + +function getLeauditBboxHighlight(reviewPoint: ReviewPoint, fieldKey: string, page?: number): PdfBboxHighlight | undefined { + const fieldPosition = reviewPoint.fieldPositions?.[fieldKey]; + if (!fieldPosition || !isValidQuad(fieldPosition.bbox) || !isValidQuad(fieldPosition.page_box)) return undefined; + + return { + fieldKey, + bbox: [...fieldPosition.bbox] as [number, number, number, number], + pageBox: [...fieldPosition.page_box] as [number, number, number, number], + pageNum: fieldPosition.page_num, + page, + confidence: fieldPosition.confidence, + matchMethod: fieldPosition.match_method, + }; +} + // ── Tooltip 系统 ── let activeTooltip = { show: false, content: null as React.ReactNode, position: { top: 0, left: 0 }, ready: false }; function TooltipPortal() { @@ -223,7 +279,7 @@ function filterOtherRule(reviewPoint: ReviewPoint): MergedRule[] { } // ── renderOtherRule ── -function RenderOtherRule({ rule, reviewPoint, onReviewPointSelect }: { rule: MergedRule; reviewPoint: ReviewPoint; onReviewPointSelect: (id: string | number, page?: number, charPos?: CharPosition[], value?: string) => void }) { +function RenderOtherRule({ rule, reviewPoint, onReviewPointSelect }: { rule: MergedRule; reviewPoint: ReviewPoint; onReviewPointSelect: (id: string | number, page?: number, charPos?: CharPosition[], value?: string, bboxHighlight?: PdfBboxHighlight) => void }) { const fieldKey = rule.fieldKey; const fieldValue = rule.fieldValue; const hasFailure = Object.values(fieldValue.type || {}).some(item => item.res === false); @@ -273,7 +329,7 @@ function RenderOtherRule({ rule, reviewPoint, onReviewPointSelect }: { rule: Mer // ── 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 | number, page?: number, charPos?: CharPosition[], value?: string) => void }) { +function RenderConsistencyRule({ rule, reviewPoint, onReviewPointSelect }: { rule: Record; reviewPoint: ReviewPoint; onReviewPointSelect: (id: string | number, page?: number, charPos?: CharPosition[], value?: string, bboxHighlight?: PdfBboxHighlight) => 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; @@ -389,7 +445,7 @@ function RenderConsistencyRule({ rule, reviewPoint, onReviewPointSelect }: { rul } // ── renderModelRule ── -function RenderModelRule({ rule, reviewPoint, onReviewPointSelect, fileFormat }: { rule: Record; reviewPoint: ReviewPoint; onReviewPointSelect: (id: string | number, page?: number, charPos?: CharPosition[], value?: string) => void; fileFormat?: string }) { +function RenderModelRule({ rule, reviewPoint, onReviewPointSelect, fileFormat }: { rule: Record; reviewPoint: ReviewPoint; onReviewPointSelect: (id: string | number, page?: number, charPos?: CharPosition[], value?: string, bboxHighlight?: PdfBboxHighlight) => 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; @@ -434,167 +490,685 @@ function RenderModelRule({ rule, reviewPoint, onReviewPointSelect, fileFormat }: return <>{fieldElements}; } -function RenderGenericRule({ - rule, - reviewPoint, - onReviewPointSelect, -}: { - rule: Record; +// ── Main Component ── + + +function stringifyUnknown(value: unknown): string { + if (typeof value === 'string') return value; + if (typeof value === 'number' || typeof value === 'boolean') return String(value); + if (value == null) return ''; + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } +} + +function getLeauditFieldText(value: ReviewPoint['content'][string]): string { + if (value == null) return '未填写'; + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + return String(value); + } + if (typeof value === 'object' && value && 'value' in value) { + return stringifyUnknown(value.value); + } + return stringifyUnknown(value); +} + +function parseMissingArrayString(rawText: string): string[] { + const match = rawText.match(/missing[\w-]*\s*:\s*\[([\s\S]*?)\]/i) || rawText.match(/\[([\s\S]*?)\]/); + if (!match) return []; + + const innerText = match[1].trim(); + if (!innerText) return []; + + const quotedItems = Array.from(innerText.matchAll(/['"]([^'"]+)['"]/g)) + .map(item => item[1].trim()) + .filter(Boolean); + + if (quotedItems.length > 0) { + return quotedItems; + } + + return innerText + .split(',') + .map(item => item.trim().replace(/^['"]|['"]$/g, '')) + .filter(Boolean); +} + +function getLeauditMissingItems(reviewPoint: ReviewPoint): string[] { + const textCandidates = [ + reviewPoint.skipReason, + typeof reviewPoint.evaluatedPointResultsLog?.skip_reason === 'string' ? reviewPoint.evaluatedPointResultsLog.skip_reason : '', + reviewPoint.suggestion, + ].filter(Boolean) as string[]; + + for (const text of textCandidates) { + if (!/missing[\w-]*\s*:/i.test(text)) continue; + const items = parseMissingArrayString(text); + if (items.length > 0) return items; + } + + return []; +} + +function normalizeAiResponseItems(value: unknown, options?: { hideNone?: boolean }): string[] { + const hideNone = options?.hideNone === true; + + if (Array.isArray(value)) { + return value + .map(item => String(item).trim()) + .filter(item => item && (!hideNone || item !== '无')); + } + + if (typeof value === 'string') { + const trimmed = value.trim(); + if (!trimmed) return []; + if (hideNone && trimmed === '无') return []; + return [trimmed]; + } + + return []; +} + +function LeauditReviewPointDetailCard({ reviewPoint, onReviewPointSelect, onStatusChange }: { reviewPoint: ReviewPoint; - onReviewPointSelect: (id: string | number, page?: number, charPos?: CharPosition[], value?: string) => void; + onReviewPointSelect: (id: string | number, page?: number, charPos?: CharPosition[], value?: string, bboxHighlight?: PdfBboxHighlight) => void; + onStatusChange: (id: string, editAuditStatusId: string | number, status: string, message: string) => void; }) { - const config = (rule.config && typeof rule.config === 'object' ? rule.config : {}) as Record; - const detail = (config.detail && typeof config.detail === 'object' ? config.detail : {}) as Record; - const fieldNames = Array.isArray(detail.fields) - ? detail.fields.map((field) => String(field)) - : Array.isArray((config as any).fields) - ? (config as any).fields.map((field: unknown) => String(field)) - : []; - const passed = typeof rule.res === 'boolean' ? rule.res : reviewPoint.result === true; - const reasonCandidates = passed - ? [config.reason, detail.reason, reviewPoint.passMessage] - : [config.reason, detail.reason, reviewPoint.failMessage, reviewPoint.suggestion]; - const reason = reasonCandidates.find((item) => typeof item === 'string' && item.trim()) as string | undefined; - const checkType = typeof config.check_type === 'string' ? config.check_type : ''; - const primitiveType = typeof config.primitive_type === 'string' ? config.primitive_type : ''; - const badgeText = checkType || primitiveType || '规则检查'; + const [manualNote, setManualNote] = useState(() => getLeauditNote(reviewPoint)); + const [corporateModalVisible, setCorporateModalVisible] = useState(false); + const [corporateCompanyName, setCorporateCompanyName] = useState(''); + const [corporateLoading, setCorporateLoading] = useState(false); + const [corporateBusinessInfo, setCorporateBusinessInfo] = useState(null); + const [corporateDishonestyInfo, setCorporateDishonestyInfo] = useState(null); + const [corporateError, setCorporateError] = useState(null); + const [corporateUpdatedAt, setCorporateUpdatedAt] = useState(null); - const getFieldLocatorState = (fieldName: string) => { - const fieldData = reviewPoint.content?.[fieldName]; - const page = fieldData?.page || reviewPoint.contentPage?.[fieldName]; - const normalizedPage = page ? Number(page) : undefined; - const hasPage = !!(normalizedPage && Number.isFinite(normalizedPage)); - const rawValue = fieldData?.value; - const normalizedValue = - typeof rawValue === 'string' - ? rawValue.trim() - : rawValue == null - ? '' - : String(rawValue); + useEffect(() => { + setManualNote(getLeauditNote(reviewPoint)); + }, [reviewPoint.id, reviewPoint.editAuditStatusMessage, reviewPoint.actionContent, reviewPoint.suggestion]); - return { - fieldData, - normalizedPage: hasPage ? normalizedPage : undefined, - normalizedValue, - canLocate: hasPage || normalizedValue.length > 0, - }; + const stages = Array.isArray(reviewPoint.evaluatedPointResultsLog?.stages) + ? (reviewPoint.evaluatedPointResultsLog.stages as Array>) + : []; + const missingItems = getLeauditMissingItems(reviewPoint); + const legalBasisList = Array.isArray(reviewPoint.legalBasis) + ? reviewPoint.legalBasis + : reviewPoint.legalBasis?.articles?.map(item => typeof item === 'string' ? item : (item.name || item.content || stringifyUnknown(item))) || []; + const riskLabelMap: Record = { + high: { cls: 'bg-red-50 text-red-700 border-red-200', label: '高风险' }, + medium: { cls: 'bg-amber-50 text-amber-700 border-amber-200', label: '中风险' }, + low: { cls: 'bg-emerald-50 text-emerald-700 border-emerald-200', label: '低风险' }, + }; + const riskMeta = riskLabelMap[reviewPoint.riskLevel || ''] || { + cls: 'bg-slate-100 text-slate-600 border-slate-200', + label: '未知风险', + }; + const configConfidence = reviewPoint.evaluationConfig && typeof reviewPoint.evaluationConfig === 'object' + ? reviewPoint.evaluationConfig.confidence + : undefined; + const confidencePct = typeof reviewPoint.confidence === 'number' + ? `${Math.round(reviewPoint.confidence * 100)}%` + : typeof configConfidence === 'number' + ? `${Math.round(configConfidence * 100)}%` + : null; + const isPass = reviewPoint.status === 'success' && reviewPoint.result === true; + const isWarning = reviewPoint.status === 'warning' || (reviewPoint.ruleStatus || '').startsWith('skipped_'); + const statusChip = isPass + ? { cls: 'bg-emerald-50 text-emerald-700 border-emerald-200', icon: 'ri-checkbox-circle-fill', label: '通过' } + : isWarning + ? { cls: 'bg-amber-50 text-amber-700 border-amber-200', icon: 'ri-error-warning-fill', label: '提醒' } + : { cls: 'bg-red-50 text-red-700 border-red-200', icon: 'ri-close-circle-fill', label: '不通过' }; + const summaryText = isPass + ? (reviewPoint.passMessage || reviewPoint.suggestion || '校验通过') + : isWarning + ? (reviewPoint.skipReason || reviewPoint.suggestion || '当前规则未执行或需人工关注') + : (reviewPoint.failMessage || reviewPoint.suggestion || '发现问题,请处理'); + const partyANameRaw = getLeauditRawFieldValue(reviewPoint.content?.['甲方名称']); + const partyBNameRaw = getLeauditRawFieldValue(reviewPoint.content?.['乙方名称']); + const partyAName = typeof partyANameRaw === 'string' ? partyANameRaw.trim() : String(partyANameRaw || '').trim(); + const partyBName = typeof partyBNameRaw === 'string' ? partyBNameRaw.trim() : String(partyBNameRaw || '').trim(); + const shouldShowEnterpriseButtons = reviewPoint.groupName?.trim() === '合同主体'; + + const handleCorporateInfoClick = async (companyName: string, forceRefresh = false) => { + if (!companyName) { + toastService.warning('企业名称为空,无法查询'); + return; + } + + setCorporateModalVisible(true); + setCorporateCompanyName(companyName); + setCorporateLoading(true); + setCorporateError(null); + setCorporateBusinessInfo(null); + setCorporateDishonestyInfo(null); + setCorporateUpdatedAt(null); + + try { + const response = await queryCompanyInfo({ keyword: companyName, force_refresh: forceRefresh }); + + if (response.success && response.data) { + setCorporateBusinessInfo(response.data.enterprise); + setCorporateUpdatedAt(response.data.updated_at); + if (response.data.dishonesty) { + setCorporateDishonestyInfo({ + VerifyResult: response.data.dishonesty.VerifyResult, + Data: response.data.dishonesty.Data || [], + }); + } + } else { + setCorporateError(response.message || '查询失败'); + } + } catch (error) { + console.error('查询企业信息失败:', error); + setCorporateError(error instanceof Error ? error.message : '查询失败'); + } finally { + setCorporateLoading(false); + } }; - const jumpToField = (fieldName: string) => { - const { fieldData, normalizedPage, normalizedValue } = getFieldLocatorState(fieldName); - if (normalizedPage) { - onReviewPointSelect( - reviewPoint.id, - normalizedPage, - fieldData?.char_positions, - normalizedValue || undefined, + const handleCorporateForceRefresh = async () => { + if (corporateCompanyName) { + await handleCorporateInfoClick(corporateCompanyName, true); + } + }; + + const handleCloseCorporateModal = () => { + setCorporateModalVisible(false); + setCorporateCompanyName(''); + setCorporateBusinessInfo(null); + setCorporateDishonestyInfo(null); + setCorporateError(null); + setCorporateUpdatedAt(null); + }; + + const renderFieldCard = (fieldKey: string, fieldValue: string) => { + const page = getLeauditTargetPage(reviewPoint, fieldKey); + const bboxHighlight = getLeauditBboxHighlight(reviewPoint, fieldKey, page); + const enterpriseButton = + shouldShowEnterpriseButtons && fieldKey === '甲方名称' && partyAName + ? renderEnterpriseInfoButton('甲方企业信息', partyAName) + : shouldShowEnterpriseButtons && fieldKey === '乙方名称' && partyBName + ? renderEnterpriseInfoButton('乙方企业信息', partyBName) + : null; + return ( + + ); + }; + + const renderEnterpriseInfoButton = (label: string, companyName: string) => ( + + ); + + const renderStageContent = (stage: Record, index: number) => { + const detail = (stage.detail || {}) as Record; + const checkType = String(stage.check_type || 'unknown'); + const passed = stage.passed === true; + const hasPassedState = typeof stage.passed === 'boolean'; + const stageCardClass = passed ? 'border-emerald-200 bg-emerald-50/60' : 'border-slate-200 bg-slate-50/60'; + const stageBadgeClass = passed ? 'text-emerald-700' : 'text-slate-600'; + const stageLabelMap: Record = { + required: '字段必填', + match: '一致性比对', + ai: 'AI 评查', + contains: '包含校验', + compare: '比较校验', + }; + const stageDisplayName = typeof stage.check_type_chinese === 'string' && stage.check_type_chinese.trim() + ? stage.check_type_chinese.trim() + : (stageLabelMap[checkType] || checkType); + const stageReason = typeof stage.reason === 'string' ? stage.reason.trim() : ''; + + const getStageDisplayValue = (value: unknown) => { + if (value == null || value === '') return '—'; + if (typeof value === 'string') return value; + if (typeof value === 'number' || typeof value === 'boolean') return String(value); + return stringifyUnknown(value); + }; + + const renderStageInfoRow = ( + label: string, + value: unknown, + options?: { valueClassName?: string; mono?: boolean }, + ) => ( +
+
{label}
+
+ {getStageDisplayValue(value)} +
+
+ ); + + if (checkType === 'required') { + const fields = Array.isArray(detail.fields) ? detail.fields.map(item => String(item)) : []; + const missing = Array.isArray(detail.missing) ? detail.missing.map(item => String(item)) : []; + return ( +
+
+
{stageDisplayName}
+ + {passed ? '通过' : '缺失'} + +
+ {fields.length > 0 &&
{`命中字段:${fields.join('、')}`}
} + {missing.length > 0 &&
{`缺失字段:${missing.join('、')}`}
} +
); - return; } - if (normalizedValue) { - onReviewPointSelect( - reviewPoint.id, - undefined, - fieldData?.char_positions, - normalizedValue, + if (checkType === 'match') { + const failures = Array.isArray(detail.failures) + ? detail.failures.map(item => item as Record) + : []; + return ( +
+
+
{stageDisplayName}
+ + {passed ? '通过' : '不一致'} + +
+ {failures.length > 0 ? ( +
+ {failures.map((failure, failureIndex) => { + const leftField = String(failure.a || '左侧字段'); + const rightField = String(failure.b || '右侧字段'); + const leftValue = failure.a_value == null ? '—' : String(failure.a_value); + const rightValue = failure.b_value == null ? '—' : String(failure.b_value); + + return ( +
+
+
{`差异项 ${failureIndex + 1}`}
+
+
+
+
{leftField}
+ {failure.a_value == null ? ( + + + {'未填写'} + + ) : ( +
+ {leftValue} +
+ )} +
+
+
{rightField}
+ {failure.b_value == null ? ( + + + {'未填写'} + + ) : ( +
+ {rightValue} +
+ )} +
+
+
+ ); + })} +
+ ) : ( +
{'未发现不一致项'}
+ )} +
); - toastService.info(`${fieldName} 当前没有页码,已改为按文本定位`); - return; } - toastService.info(`${fieldName} 当前既没有页码,也没有可定位文本`); + if (checkType === 'compare') { + const opMap: Record = { + '>=': '≥', '<=': '≤', '!=': '≠', '<>': '≠', '==': '=', '>': '>', '<': '<', '=': '=', + }; + const displayOp = opMap[String(detail.op)] || String(detail.op); + + const fmtNum = (v: unknown) => { + if (v == null || String(v).trim() === '') return getStageDisplayValue(v); + const n = Number(v); + return !isNaN(n) ? n.toLocaleString('zh-CN') : getStageDisplayValue(v); + }; + + const buildOperand = (field: unknown, value: unknown) => { + const fieldStr = getStageDisplayValue(field); + if (value == null || value === '') return fieldStr; + return `${fieldStr}(${fmtNum(value)})`; + }; + + const leftOperand = buildOperand(detail.left, detail.left_value); + const rightDisplay = typeof detail.right === 'number' + ? fmtNum(detail.right) + : buildOperand(detail.right, detail.right_value); + + return ( +
+
+
{stageDisplayName}
+ + + {passed ? '通过' : '未通过'} + +
+
+ {leftOperand} {displayOp} {rightDisplay} +
+ {stageReason && ( +
{stageReason}
+ )} +
+ ); + } + + if (checkType === 'ai') { + const response = (detail.response || {}) as Record; + const reasonText = typeof response.reason === 'string' ? response.reason.trim() : ''; + const strengthItems = normalizeAiResponseItems(response.strengths); + const suggestionItems = normalizeAiResponseItems(response.suggestion, { hideNone: true }); + const dividerClass = passed ? 'border-emerald-200/70' : 'border-fuchsia-200/70'; + + return ( +
+
+ + {'AI 评查意见'} +
+
+ {reasonText && ( +
+ +
{reasonText}
+
+ )} + + {strengthItems.length > 0 && ( +
+
+ + {'亮点'} + {strengthItems.length} +
+
    + {strengthItems.map((item, itemIndex) => ( +
  • + + {item} +
  • + ))} +
+
+ )} + + {suggestionItems.length > 0 && ( +
+
+ + {'修改建议'} + {suggestionItems.length} +
+
    + {suggestionItems.map((item, itemIndex) => ( +
  • + {'•'} + {item} +
  • + ))} +
+
+ )} +
+
+ ); + } + + return ( +
+
+
+ {stageDisplayName} +
+ {hasPassedState && ( + + + {passed ? '通过' : '未通过'} + + )} +
+
+ {/* {renderStageInfoRow('阶段类型', stageDisplayName)} */} + {/* {hasPassedState && renderStageInfoRow('结果', passed ? '通过' : '未通过', { valueClassName: passed ? 'text-emerald-700' : 'text-red-700' })} */} + {stageReason && renderStageInfoRow('原因', stageReason)} +
+
+ ); }; return ( -
-
-
{badgeText}
- - - {passed ? '通过' : '未通过'} - -
- - {reason && ( -
- {reason} + <> +
+
+
+ {reviewPoint.pointId && ( + + {reviewPoint.pointId} + + )} +

{reviewPoint.pointName}

+
+
+ + {statusChip.label} + + + {riskMeta.label} + + {reviewPoint.postAction === 'manual' && ( + + 需人工 + + )} +
+
+ {reviewPoint.score != null && 得分 {reviewPoint.finalScore ?? reviewPoint.machineScore ?? reviewPoint.score}/{reviewPoint.score}} + {confidencePct && 置信度 {confidencePct}} +
+
+
+ + {Object.keys(reviewPoint.content || {}).length > 0 && ( +
+
+ 命中字段 {Object.keys(reviewPoint.content).length} +
+
+ {Object.entries(reviewPoint.content).map(([key, value]) => renderFieldCard(key, getLeauditFieldText(value)))} +
+
)} - {fieldNames.length > 0 && ( -
- {fieldNames.map((fieldName) => { - const { fieldData, normalizedPage, normalizedValue, canLocate } = getFieldLocatorState(fieldName); - const fieldValue = fieldData?.value; - const displayValue = - typeof fieldValue === 'string' - ? fieldValue - : fieldValue == null - ? '未抽取到值' - : JSON.stringify(fieldValue); - - return ( - - ); - })} -
+
+ ))} +
+ )} -
+ + {stages.length > 0 && ( +
+
+ 阶段结果 {stages.length} +
+
+ {stages.map((stage, index) => renderStageContent(stage, index))} +
+
+ )} + + {!isWarning && ( +
+
+ + {isPass ? '校验结果' : '问题说明'} +
+
+ {summaryText} +
+
+ )} + + {legalBasisList.length > 0 && ( +
+
法律依据
+
+ {legalBasisList.map((item, index) => ( + + {item} + + ))} +
+
+ )} + + {reviewPoint.postAction === 'manual' && ( +
+
审核意见
+