/** * 右栏 · 评查点详情卡片 * 展示单个评查点的完整详情,复用 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' && (
审核意见