677 lines
38 KiB
TypeScript
677 lines
38 KiB
TypeScript
/**
|
|
* 右栏 · 评查点详情卡片
|
|
* 展示单个评查点的完整详情,复用 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 | number, page?: number, charPositions?: CharPosition[], value?: string) => void;
|
|
onStatusChange: (id: string | number, editAuditStatusId: string | number, status: string, message: string) => void;
|
|
fileFormat?: string;
|
|
}
|
|
|
|
// ── 比较方法映射 ──
|
|
const compareMethodMap: Record<string, string> = {
|
|
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<string, string> = {
|
|
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(
|
|
<div
|
|
className="fixed bg-white shadow-lg rounded-md p-1 border border-gray-200 z-[9999]"
|
|
style={{
|
|
top: `${tooltip.position.top}px`, left: `${tooltip.position.left}px`,
|
|
transform: 'translate(-100%, -50%)', opacity: tooltip.ready ? 1 : 0,
|
|
visibility: tooltip.ready ? 'visible' : 'hidden', transition: 'opacity 0.15s ease-out',
|
|
}}
|
|
>
|
|
{tooltip.content}
|
|
<div className="absolute top-1/2 right-0 transform translate-x-1/2 -translate-y-1/2 rotate-45 w-2 h-2 bg-white border-t border-r border-gray-200" />
|
|
</div>,
|
|
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 (
|
|
<table className="border-collapse text-[11px] w-full">
|
|
<tbody>
|
|
{rows.map((row, i) => (
|
|
<tr key={i} className={i === 0 ? 'font-semibold bg-slate-100' : ''}>
|
|
{row.map((cell, j) => <td key={j} className="border border-slate-200 px-2 py-1">{cell}</td>)}
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
);
|
|
}
|
|
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 <span>{text}</span>;
|
|
return (
|
|
<table className="border-collapse text-[11px] w-full">
|
|
<tbody>
|
|
{rows.map((row, i) => (
|
|
<tr key={i} className={i === 0 ? 'font-semibold bg-slate-100' : ''}>
|
|
{row.map((cell, j) => <td key={j} className="border border-slate-200 px-2 py-1">{cell}</td>)}
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
);
|
|
}
|
|
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 (
|
|
<table className="border-collapse text-[11px] w-full">
|
|
<tbody>
|
|
{rows.map((row, i) => (
|
|
<tr key={i} className={i === 0 ? 'font-semibold bg-slate-100' : ''}>
|
|
{row.map((cell, j) => <td key={j} className="border border-slate-200 px-2 py-1">{cell}</td>)}
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
);
|
|
}
|
|
const ReactTableTooltip = ({ content }: { content: string }) => {
|
|
const [showTip, setShowTip] = useState(false);
|
|
const [rendered, setRendered] = useState<React.ReactNode>(null);
|
|
const ref = useRef<HTMLDivElement>(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 (
|
|
<div className="relative">
|
|
<div ref={ref} className="text-xs text-gray-700 line-clamp-2 select-text" onMouseEnter={() => showTip && showTooltip(<div className="p-2 max-w-md max-h-64 overflow-auto">{rendered}</div>, { top: ref.current?.getBoundingClientRect().top || 0, left: ref.current?.getBoundingClientRect().left || 0 })} onMouseLeave={hideTooltip}>{rendered}</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ── filterOtherRule ──
|
|
interface MergedRule {
|
|
fieldKey: string;
|
|
fieldValue: {
|
|
type: Record<string, { res: boolean; page?: number | string; value?: string; char_positions?: CharPosition[] }>;
|
|
};
|
|
}
|
|
|
|
function filterOtherRule(reviewPoint: ReviewPoint): MergedRule[] {
|
|
interface RuleFieldValue { page?: number | string; value?: string; char_positions?: CharPosition[]; type: Record<string, boolean> }
|
|
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<string, { page: number; value: string; char_positions?: CharPosition[] }>; 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<string, { page: string | number; value: string; char_positions?: CharPosition[] }>; 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<string, { page: number | string; value: string; char_positions?: CharPosition[] }>; 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<string, { page: number | string; value: string; char_positions?: CharPosition[] }>; 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<string, MergedRule> = {};
|
|
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 | number, 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 = (
|
|
<div className="flex flex-row gap-2 overflow-x-auto max-h-[300px]">
|
|
{Object.entries(fieldValue.type || {}).map(([tk, tv]) => (
|
|
<div key={tk} className="rounded-md flex flex-row items-center">
|
|
<div className="text-xs text-gray-600 pl-1 whitespace-nowrap">{getRuleTypeText(tk)}:</div>
|
|
<div className="p-1 text-xs rounded-full min-w-[50px] text-center">{tv.res ? '通过' : '不通过'}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<button
|
|
className={`border border-gray rounded-md overflow-hidden mb-2 ${overallResult ? 'bg-[rgba(246,255,237,1)]' : 'bg-[rgba(255,251,230,1)]'} flex w-full text-left hover:shadow-[0_0_10px_rgba(0,0,0,0.1)] ${overallResult ? 'hover:bg-[rgba(0,128,0,0.1)]' : 'hover:bg-[rgba(255,255,0,0.1)]'}`}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
if (mainVal.page && typeof onReviewPointSelect === 'function') onReviewPointSelect(reviewPoint.id, Number(mainVal.page), mainVal.char_positions, mainVal.value);
|
|
else if (reviewPoint.contentPage && reviewPoint.contentPage[fieldKey]) onReviewPointSelect(reviewPoint.id, Number(reviewPoint.contentPage[fieldKey]), mainVal.char_positions, mainVal.value);
|
|
else toastService.error(`没有找到${fieldKey}对应的索引内容`);
|
|
}}
|
|
type="button"
|
|
>
|
|
<div className="p-1 flex-1">
|
|
<div className="text-xs text-gray-500 mb-1">
|
|
{fieldKey}
|
|
{!mainVal.page && !(reviewPoint.contentPage && reviewPoint.contentPage[fieldKey]) && <i className="ri-information-line text-red-500 text-xs ml-1" title="没有找到对应的文书内容" />}
|
|
{mainVal.res === false && !mainVal.value && <span className="ml-2 text-xs text-yellow-500">缺失</span>}
|
|
</div>
|
|
{mainVal.value && <ReactTableTooltip content={mainVal.value} />}
|
|
</div>
|
|
<div className="w-8 flex items-center justify-center rounded-r-md" onMouseEnter={(e) => { const r = e.currentTarget.getBoundingClientRect(); showTooltip(tooltipContent, { top: r.top + r.height / 2, left: r.left }); }} onMouseLeave={hideTooltip}>
|
|
{overallResult ? <i className="ri-check-line text-success text-base" /> : <i className="ri-alert-line text-warning text-base" />}
|
|
</div>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
// ── 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<string, unknown>; reviewPoint: ReviewPoint; onReviewPointSelect: (id: string | number, 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<string, { page: number; value: string; char_positions?: CharPosition[] }>; targetField: Record<string, { page: number; value: string; char_positions?: CharPosition[] }>; 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<Array<CI>> = [];
|
|
const visited = new Set<string>();
|
|
const fieldMap = new Map<string, Array<{ targetField: string; data: { source: { key: string; page: number; value: string; char_positions?: CharPosition[] }; target: { key: string; page: number; value: string; char_positions?: CharPosition[] } }; res: boolean; compareMethod?: string }>>();
|
|
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<string>();
|
|
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 (
|
|
<div className="mt-3">
|
|
<div className="comparison-group">
|
|
{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 (
|
|
<div key={`chain_${ci}`} className={`${itemCls} border border-gray rounded-md overflow-hidden mb-2 ${res ? 'bg-[rgba(246,255,237,1)]' : 'bg-[rgba(255,251,230,1)]'} hover:shadow-[0_0_10px_rgba(0,0,0,0.1)] cursor-pointer`}
|
|
onClick={(e) => { 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; } } }}>
|
|
<div className="comparison-values flex w-full">
|
|
<div className="value-box p-2 pb-1 flex-1">
|
|
<div className="value-source text-xs text-gray-500 mb-1">
|
|
{chain.map((item: ChainItem, idx: number) => (
|
|
<span key={idx} className="inline-block">
|
|
{item.field}
|
|
{idx < chain.length - 1 && <i className="ri-arrow-left-s-line text-xs ml-1 text-primary">{typeof chain[idx + 1]?.compareMethod === 'object' ? '' : getCompareMethodText(chain[idx + 1]?.compareMethod)}<i className="ri-arrow-right-s-line mr-1 text-xs text-primary" /></i>}
|
|
</span>
|
|
))}
|
|
</div>
|
|
<div className="flex flex-col">
|
|
{chain.map((item: ChainItem, idx: number) => (
|
|
<button key={`item_${idx}`} className="value-content p-1 cursor-text text-xs border-b border-dashed border-gray-200 last:border-b-0 text-left w-full rounded transition-colors"
|
|
onClick={(e) => { e.stopPropagation(); if (item.data.page) onReviewPointSelect(reviewPoint.id, Number(item.data.page), item.data.char_positions, item.data.value); else if (reviewPoint.contentPage && reviewPoint.contentPage[item.field]) onReviewPointSelect(reviewPoint.id, Number(reviewPoint.contentPage[item.field]), item.data.char_positions, item.data.value); else toastService.error(`没有找到${item.field}对应的索引内容`); }}
|
|
type="button">
|
|
<div className="flex justify-between w-full">
|
|
<ReactTableTooltip content={item.data.value?.toString() || ''} />
|
|
{!item.data.page && !(reviewPoint.contentPage && reviewPoint.contentPage[item.field]) && <i className="ri-information-line text-red-500 text-xs" title="没有找到对应的文书内容" />}
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div className="status-indicator w-8 flex items-center justify-center">{res ? <i className="ri-check-line text-success text-base" /> : <i className="ri-alert-line text-warning text-base" />}</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Standard pair (2 elements)
|
|
return (
|
|
<div key={`pair_${ci}`} className={`${itemCls} border border-gray rounded-md overflow-hidden mb-2 ${res ? 'bg-[rgba(246,255,237,1)]' : 'bg-[rgba(255,251,230,1)]'} flex`}>
|
|
<button className={`value-box hover:shadow-[0_0_10px_rgba(0,0,0,0.1)] flex-1 p-2 border-r-2 ${res ? 'border-green-200' : 'border-yellow-200'} text-left ${res ? 'hover:bg-[rgba(0,128,0,0.1)]' : 'hover:bg-[rgba(255,255,0,0.1)]'} transition-colors flex flex-col`}
|
|
onClick={(e) => { e.stopPropagation(); if (chain[0].data.page) onReviewPointSelect(reviewPoint.id, chain[0].data.page, chain[0].data.char_positions, chain[0].data.value); else if (reviewPoint.contentPage && reviewPoint.contentPage[chain[0].field]) onReviewPointSelect(reviewPoint.id, Number(reviewPoint.contentPage[chain[0].field]), chain[0].data.char_positions, chain[0].data.value); else toastService.error(`没有找到${chain[0].field}对应的索引内容`); }}
|
|
type="button">
|
|
<div className="value-source text-xs text-gray-500 mb-1">{chain[0].field}</div>
|
|
<ReactTableTooltip content={chain[0].data.value?.toString() || ''} />
|
|
</button>
|
|
<button className={`value-box flex flex-col flex-1 p-2 text-left ${res ? 'hover:bg-[rgba(0,128,0,0.1)]' : 'hover:bg-[rgba(255,255,0,0.1)]'} transition-colors hover:shadow-[0_0_10px_rgba(0,0,0,0.1)]`}
|
|
onClick={(e) => { e.stopPropagation(); if (chain[1].data.page) onReviewPointSelect(reviewPoint.id, chain[1].data.page, chain[1].data.char_positions, chain[1].data.value); else if (reviewPoint.contentPage && reviewPoint.contentPage[chain[1].field]) onReviewPointSelect(reviewPoint.id, Number(reviewPoint.contentPage[chain[1].field]), chain[1].data.char_positions, chain[1].data.value); else toastService.error(`没有找到${chain[1].field}对应的索引内容`); }}
|
|
type="button">
|
|
<div className="value-source text-xs text-gray-500 mb-1">{chain[1].field}</div>
|
|
<ReactTableTooltip content={chain[1].data.value?.toString() || ''} />
|
|
</button>
|
|
<div className="w-8 flex items-center justify-center border-l border-gray-200" onMouseEnter={(e) => { const r = e.currentTarget.getBoundingClientRect(); showTooltip(<div className="flex flex-row gap-2">{chain.slice(1).map((item: ChainItem, i: number) => <div key={i} className="rounded-md flex flex-row items-center"><div className="text-xs text-gray-600 pl-1 whitespace-nowrap">{typeof item.compareMethod === 'object' ? '' : `${getCompareMethodText(item.compareMethod)}:`}</div><div className="p-1 text-xs rounded-full min-w-[50px] text-center">{res ? '通过' : '不通过'}</div></div>)}</div>, { top: r.top + r.height / 2, left: r.left }); }} onMouseLeave={hideTooltip}>
|
|
{res ? <i className="ri-check-line text-success text-base" /> : <i className="ri-alert-line text-warning text-base" />}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── renderModelRule ──
|
|
function RenderModelRule({ rule, reviewPoint, onReviewPointSelect, fileFormat }: { rule: Record<string, unknown>; reviewPoint: ReviewPoint; onReviewPointSelect: (id: string | number, page?: number, charPos?: CharPosition[], value?: string) => void; fileFormat?: string }) {
|
|
const config = rule.config as { model?: string; fields?: Record<string, { page: number | string; value: string; char_positions?: CharPosition[]; res?: boolean }>; ai_suggestion?: { summary?: string; analysis?: { failure_reason?: string; solution_approach?: string; rule_understanding?: string }; suggestions?: Record<string, { reason: string; source: { page: number | null; type: string; field: string | null }; priority: string; confidence: number; suggested_value: string | null }>; 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(
|
|
<button key={`field-${index}`} className={`border border-gray w-full rounded-md overflow-hidden mb-2 ${res ? 'bg-[rgba(246,255,237,1)]' : 'bg-[rgba(255,251,230,1)]'} flex hover:shadow-[0_0_10px_rgba(0,0,0,0.1)] ${res ? 'hover:bg-[rgba(0,128,0,0.1)]' : 'hover:bg-[rgba(255,255,0,0.1)]'}`}
|
|
onClick={(e) => { e.stopPropagation(); if (value.page) onReviewPointSelect(reviewPoint.id, Number(value.page), value.char_positions, value.value); else if (reviewPoint.contentPage && reviewPoint.contentPage[key]) onReviewPointSelect(reviewPoint.id, Number(reviewPoint.contentPage[key]), value.char_positions, value.value); else toastService.error(`没有找到${key}对应的索引内容`); }}
|
|
type="button">
|
|
<div className="p-1 flex-1 text-left">
|
|
<div className="text-xs text-left text-gray-500 mb-1">{key}{!value.page && !(reviewPoint.contentPage && reviewPoint.contentPage[key]) && <i className="ri-information-line text-red-500 text-xs ml-1" />}{!res && !value.value && <span className="ml-2 text-xs text-yellow-500">缺失</span>}</div>
|
|
{value.value && <ReactTableTooltip content={value.value} />}
|
|
</div>
|
|
<div className="w-8 flex items-center justify-center rounded-r-md" onMouseEnter={(e) => { const r = e.currentTarget.getBoundingClientRect(); showTooltip(<div className="flex flex-row gap-2"><div className="rounded-md flex flex-row items-center"><div className="text-xs text-gray-600 pl-1 whitespace-nowrap">大模型判断:</div><div className="p-1 text-xs rounded-full min-w-[50px] text-center">{res ? '通过' : '不通过'}</div></div></div>, { top: r.top + r.height / 2, left: r.left }); }} onMouseLeave={hideTooltip}>
|
|
{res ? <i className="ri-check-line text-success text-base" /> : <i className="ri-alert-line text-warning text-base" />}
|
|
</div>
|
|
</button>
|
|
);
|
|
});
|
|
}
|
|
|
|
if (config.message) {
|
|
const msg = typeof config.message === 'object' ? JSON.stringify(config.message) : String(config.message);
|
|
fieldElements.push(
|
|
<div key="message" className="mb-3 select-text">
|
|
<div className="text-[11px] font-medium text-slate-400 uppercase tracking-wider mb-2 flex items-center gap-1">
|
|
<i className="ri-sparkling-2-fill text-fuchsia-500 text-[12px]" /> AI 评查意见
|
|
</div>
|
|
<div className="p-2 bg-blue-50 rounded border border-blue-200 text-xs">
|
|
<div className="flex flex-row items-center">
|
|
<p className="text-xs text-gray-600 select-text mb-0">{msg}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return <>{fieldElements}</>;
|
|
}
|
|
|
|
function RenderGenericRule({
|
|
rule,
|
|
reviewPoint,
|
|
onReviewPointSelect,
|
|
}: {
|
|
rule: Record<string, unknown>;
|
|
reviewPoint: ReviewPoint;
|
|
onReviewPointSelect: (id: string | number, page?: number, charPos?: CharPosition[], value?: string) => void;
|
|
}) {
|
|
const config = (rule.config && typeof rule.config === 'object' ? rule.config : {}) as Record<string, unknown>;
|
|
const detail = (config.detail && typeof config.detail === 'object' ? config.detail : {}) as Record<string, unknown>;
|
|
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 reason = [config.reason, detail.reason, reviewPoint.failMessage, reviewPoint.passMessage]
|
|
.find((item) => typeof item === 'string' && item.trim()) as string | undefined;
|
|
const passed = typeof rule.res === 'boolean' ? rule.res : reviewPoint.result === true;
|
|
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 jumpToField = (fieldName: string) => {
|
|
const fieldData = reviewPoint.content?.[fieldName];
|
|
const page = fieldData?.page || reviewPoint.contentPage?.[fieldName];
|
|
const normalizedPage = page ? Number(page) : undefined;
|
|
if (normalizedPage && Number.isFinite(normalizedPage)) {
|
|
onReviewPointSelect(
|
|
reviewPoint.id,
|
|
normalizedPage,
|
|
fieldData?.char_positions,
|
|
typeof fieldData?.value === 'string' ? fieldData.value : undefined,
|
|
);
|
|
return;
|
|
}
|
|
toastService.info(`${fieldName} 当前没有可定位页码`);
|
|
};
|
|
|
|
return (
|
|
<div className={`mb-3 rounded-md border ${passed ? 'border-emerald-200 bg-emerald-50/60' : 'border-amber-200 bg-amber-50/70'} p-3`}>
|
|
<div className="flex items-center justify-between gap-2">
|
|
<div className="text-[11px] font-medium text-slate-600">{badgeText}</div>
|
|
<span className={`inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[10.5px] ${passed ? 'bg-emerald-100 text-emerald-700' : 'bg-amber-100 text-amber-700'}`}>
|
|
<i className={passed ? 'ri-checkbox-circle-line' : 'ri-error-warning-line'} />
|
|
{passed ? '通过' : '未通过'}
|
|
</span>
|
|
</div>
|
|
|
|
{reason && (
|
|
<div className="mt-2 text-[12px] leading-5 text-slate-700">
|
|
{reason}
|
|
</div>
|
|
)}
|
|
|
|
{fieldNames.length > 0 && (
|
|
<div className="mt-3 flex flex-wrap gap-2">
|
|
{fieldNames.map((fieldName) => {
|
|
const fieldData = reviewPoint.content?.[fieldName];
|
|
const fieldValue = fieldData?.value;
|
|
const displayValue =
|
|
typeof fieldValue === 'string'
|
|
? fieldValue
|
|
: fieldValue == null
|
|
? '未抽取到值'
|
|
: JSON.stringify(fieldValue);
|
|
|
|
return (
|
|
<button
|
|
key={fieldName}
|
|
type="button"
|
|
className="min-w-0 flex-1 rounded border border-slate-200 bg-white px-2.5 py-2 text-left hover:border-[#00684a] hover:bg-[#f6fffb]"
|
|
onClick={() => jumpToField(fieldName)}
|
|
>
|
|
<div className="text-[11px] font-medium text-slate-500">{fieldName}</div>
|
|
<div className="mt-1 text-[12px] leading-5 text-slate-700 break-all">{displayValue}</div>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Main Component ──
|
|
export function ReviewPointDetailCard({ reviewPoint, onReviewPointSelect, onStatusChange, fileFormat }: ReviewPointDetailCardProps) {
|
|
const resolveManualNote = () => {
|
|
if (reviewPoint.editAuditStatusMessage) {
|
|
return reviewPoint.editAuditStatusMessage;
|
|
}
|
|
|
|
if (typeof reviewPoint.actionContent === 'string') {
|
|
return reviewPoint.actionContent;
|
|
}
|
|
|
|
if (reviewPoint.suggestion) {
|
|
return reviewPoint.suggestion;
|
|
}
|
|
|
|
return '';
|
|
};
|
|
|
|
const [manualNote, setManualNote] = useState(resolveManualNote);
|
|
|
|
// reviewPoint 切换时重置默认值
|
|
useEffect(() => {
|
|
setManualNote(resolveManualNote());
|
|
}, [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 (
|
|
<article className="bg-white border border-slate-200 rounded-lg shadow-sm overflow-hidden">
|
|
<TooltipPortal />
|
|
|
|
{/* Header */}
|
|
<header className="px-4 pt-4 pb-3 border-b border-slate-100">
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
{reviewPoint.pointId && (
|
|
<span className="font-mono text-[11px] px-1.5 py-0.5 rounded bg-slate-100 text-slate-600 shrink-0">
|
|
#{reviewPoint.pointId}
|
|
</span>
|
|
)}
|
|
<h2 className="text-[14.5px] font-semibold text-slate-900 break-all leading-snug">
|
|
{reviewPoint.pointName}
|
|
</h2>
|
|
</div>
|
|
<div className="mt-2 flex items-center justify-between">
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<span className={`inline-flex items-center gap-1 px-1.5 h-5 rounded text-[10.5px] border ${statusChip.cls}`}>
|
|
<i className={statusChip.icon} />{statusChip.label}
|
|
</span>
|
|
{ reviewPoint.postAction === 'manual' && (
|
|
<span className="inline-flex items-center gap-1 px-1.5 h-5 rounded text-[10.5px] bg-slate-50 text-slate-600 border border-slate-200">
|
|
<i className="ri-user-line" />需人工
|
|
</span>
|
|
)}
|
|
</div>
|
|
{reviewPoint.score != null && (
|
|
<span className="text-[11px] text-slate-500 shrink-0">
|
|
分值 <span className={`font-mono font-medium ${isPass ? 'text-emerald-600' : isFail ? 'text-red-600' : isWarn ? 'text-amber-600' : 'text-slate-500'}`}>{reviewPoint.score}</span>
|
|
</span>
|
|
)}
|
|
</div>
|
|
</header>
|
|
|
|
{/* Rule content */}
|
|
<section className="px-4 pt-3">
|
|
{otherRules.map((rule, i) => (
|
|
<RenderOtherRule key={`other-${i}`} rule={rule} reviewPoint={reviewPoint} onReviewPointSelect={onReviewPointSelect} />
|
|
))}
|
|
{reviewPoint.evaluatedPointResultsLog?.rules?.map((rule, i) => {
|
|
if (rule.type === 'consistency') {
|
|
return <div key={`rule-${i}`}>{otherRules.length > 0 && <div className="bg-gray-50 rounded border border-gray-200 text-xs mb-3" />}<RenderConsistencyRule rule={rule} reviewPoint={reviewPoint} onReviewPointSelect={onReviewPointSelect} /></div>;
|
|
}
|
|
if (rule.type === 'ai') {
|
|
return <div key={`rule-${i}`}>{otherRules.length > 0 && <div className="bg-gray-50 rounded border border-gray-200 text-xs mb-3" />}<RenderModelRule rule={rule} reviewPoint={reviewPoint} onReviewPointSelect={onReviewPointSelect} fileFormat={fileFormat} /></div>;
|
|
}
|
|
return <RenderGenericRule key={`rule-${i}`} rule={rule} reviewPoint={reviewPoint} onReviewPointSelect={onReviewPointSelect} />;
|
|
})}
|
|
</section>
|
|
|
|
{/* Suggestion */}
|
|
{reviewPoint.suggestion && !isPass && (
|
|
<section className="px-4 pt-3">
|
|
<div className="text-[11px] font-medium text-slate-400 uppercase tracking-wider mb-2 flex items-center gap-1">
|
|
<i className="ri-edit-2-line" /> 修改建议
|
|
</div>
|
|
<div className="p-3 bg-amber-50/50 border border-amber-200 rounded-md text-[12.5px] text-slate-700 leading-relaxed">
|
|
{reviewPoint.suggestion}
|
|
</div>
|
|
</section>
|
|
)}
|
|
|
|
{/* Manual review textarea */}
|
|
{reviewPoint.postAction === 'manual' && (
|
|
<section className="px-4 pt-3">
|
|
<div className="text-[11px] font-medium text-slate-400 uppercase tracking-wider mb-2">审核意见</div>
|
|
<textarea
|
|
rows={2}
|
|
placeholder="请输入审核意见..."
|
|
className={`w-full p-2 border border-slate-200 rounded-md text-[12.5px] min-h-[56px] focus:outline-none focus:border-[#00684a] focus:ring-2 focus:ring-[#00684a]/15 resize-none placeholder:text-slate-400 ${reviewPoint.editAuditStatus !== 0 ? 'bg-gray-100 cursor-not-allowed' : 'bg-white'}`}
|
|
value={manualNote}
|
|
onChange={(e) => setManualNote(e.target.value)}
|
|
disabled={reviewPoint.editAuditStatus !== 0}
|
|
/>
|
|
</section>
|
|
)}
|
|
|
|
{/* Footer actions — 需人工 + 未审核:通过/不通过 */}
|
|
{reviewPoint.postAction === 'manual' && reviewPoint.editAuditStatus === 0 && (
|
|
<footer className="mt-3 px-4 py-3 flex items-center justify-end gap-2 border-t border-slate-100 bg-slate-50/60">
|
|
<button type="button" className="h-8 px-3 rounded-md text-[12.5px] bg-[#1890ff] text-white hover:bg-blue-600 flex items-center gap-1 font-medium" onClick={() => {
|
|
if (manualNote.trim() === '') { toastService.error('请输入审核意见'); return; }
|
|
onStatusChange(reviewPoint.id, reviewPoint.editAuditStatusId || '', 'true', manualNote);
|
|
}}>
|
|
<i className="ri-check-line" />通过
|
|
</button>
|
|
<button type="button" className="h-8 px-3 rounded-md text-[12.5px] bg-[#f5222d] text-white hover:bg-red-600 flex items-center gap-1 font-medium shadow-sm" onClick={() => {
|
|
if (manualNote.trim() === '') { toastService.error('请输入审核意见'); return; }
|
|
onStatusChange(reviewPoint.id, reviewPoint.editAuditStatusId || '', 'false', manualNote);
|
|
}}>
|
|
<i className="ri-close-line" />不通过
|
|
</button>
|
|
</footer>
|
|
)}
|
|
|
|
{/* 已审核状态:重新审核 */}
|
|
{reviewPoint.postAction === 'manual' && reviewPoint.editAuditStatus !== 0 && (
|
|
<footer className="mt-3 px-4 py-3 flex items-center justify-end border-t border-slate-100 bg-slate-50/60">
|
|
<button type="button" className="h-8 px-3 rounded-md text-[12.5px] bg-purple-600 text-white hover:bg-purple-700 flex items-center gap-1 font-medium" onClick={() => {
|
|
onStatusChange(reviewPoint.id, reviewPoint.editAuditStatusId || '', 'review', '');
|
|
}}>
|
|
<i className="ri-refresh-line" />重新审核
|
|
</button>
|
|
</footer>
|
|
)}
|
|
|
|
{isPass && reviewPoint.postAction !== 'manual' && (
|
|
<footer className="px-4 py-3 flex items-center gap-2 border-t border-slate-100 bg-slate-50/60">
|
|
<span className="text-[11px] text-emerald-700 inline-flex items-center gap-1">
|
|
<i className="ri-verified-badge-fill" />已自动通过
|
|
</span>
|
|
</footer>
|
|
)}
|
|
</article>
|
|
);
|
|
}
|