Files
leaudit-platform-frontend/app/components/reviews/rightColumn/DetailPanel.tsx
T

375 lines
13 KiB
TypeScript

/**
* 右栏 · 详情面板
* 包含 3 个选项卡(评查结果、抽取字段、文件信息)和底部操作栏
*/
import type { ReviewPoint, CharPosition, PdfBboxHighlight } from '../ReviewPointsList';
import { ReviewPointDetailCard } from './ReviewPointDetailCard';
import { FileInfoPanel } from './FileInfoPanel';
type TabKey = 'result' | 'fields' | 'info';
interface FileInfoData {
fileName: string;
contractNumber: string;
fileSize: string;
fileFormat: string;
pageCount: number;
uploadTime: string;
uploadUser: string;
fileType: string;
}
interface ReviewInfoData {
reviewTime: string;
reviewModel: string;
ruleGroup: string;
result: string;
issueCount: number;
}
interface DetailPanelProps {
activeTab: TabKey;
onTabChange: (tab: TabKey) => void;
activeReviewPoint: ReviewPoint | null;
reviewPoints: ReviewPoint[];
detailMode?: 'legacy' | 'leaudit';
fileInfo: FileInfoData;
reviewInfo: ReviewInfoData;
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;
fileFormat?: string;
onUploadTemplate?: () => void;
onComparison?: () => void;
showComparisonButton?: boolean;
}
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 formatPageLabel(page?: number): string {
if (!page || !Number.isFinite(page) || page <= 0) return '未定位';
return `${page}`;
}
function ExtractedFieldsPanel({
reviewPoints,
onFieldClick,
}: {
reviewPoints: ReviewPoint[];
onFieldClick: (pointId: string | number, page?: number, value?: string, bboxHighlight?: PdfBboxHighlight) => void;
}) {
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, 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,
displayValue,
highlightValue,
isMissing: fieldRawValue == null,
confidence,
page,
pointId: p.id,
bboxHighlight,
});
});
}
});
return (
<div className="h-full flex flex-col min-h-0">
<div className="shrink-0 px-4 py-3 border-b border-slate-100">
<div className="flex items-baseline justify-between gap-3">
<h3 className="text-[14px] font-semibold text-slate-900">
<span className="font-mono text-[11px] text-slate-400">{fields.length}</span>
</h3>
<div className="text-[11px] text-slate-400"> · </div>
</div>
</div>
{fields.length === 0 ? (
<div className="text-center py-10 text-[12px] text-slate-400"></div>
) : (
<div className="flex-1 min-h-0 overflow-y-auto scroll-slim">
{fields.map((f, i) => (
<div
key={`${f.key}-${i}`}
role={f.page ? 'button' : undefined}
tabIndex={f.page ? 0 : undefined}
className={`w-full flex items-start gap-2 px-3 py-2 border-b border-slate-100 text-left transition ${f.page ? 'cursor-pointer hover:bg-slate-50' : 'cursor-default opacity-80'}`}
onClick={() => handleFieldNavigate(f.pointId, f.page, f.highlightValue, f.bboxHighlight)}
onKeyDown={(event) => {
if (!f.page) return;
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
handleFieldNavigate(f.pointId, f.page, f.highlightValue, f.bboxHighlight);
}
}}
>
<div className="flex-1 min-w-0">
<div className="text-[12px] font-medium text-slate-800 leading-5 break-words">{f.key}</div>
<div className="mt-0.5 select-text cursor-text">
{f.isMissing ? (
<span className="text-[11px] text-red-500"></span>
) : (
<span className="text-[11px] text-slate-500 leading-5 whitespace-pre-wrap break-words">{f.displayValue}</span>
)}
</div>
</div>
<div className="shrink-0 text-right min-w-[56px] pt-0.5">
<div className={`font-mono text-[10.5px] ${f.confidence == null ? 'text-slate-400' : f.confidence < 0.8 ? 'text-orange-600' : 'text-slate-500'}`}>
{f.confidence == null ? '-' : `${Math.round(f.confidence * 100)}%`}
</div>
{f.page ? (
<button
type="button"
className="mt-0.5 text-[10px] text-[#00684a] hover:underline"
onClick={(event) => {
event.stopPropagation();
handleFieldNavigate(f.pointId, f.page, f.highlightValue, f.bboxHighlight);
}}
>
{formatPageLabel(f.page)}
</button>
) : (
<div className="mt-0.5 text-[10px] text-slate-300"></div>
)}
</div>
</div>
))}
</div>
)}
</div>
);
}
const TABS: Array<{ key: TabKey; label: string }> = [
{ key: 'result', label: '评查结果' },
{ key: 'fields', label: '抽取字段' },
{ key: 'info', label: '文件信息' },
];
export function DetailPanel({
activeTab,
onTabChange,
activeReviewPoint,
reviewPoints,
detailMode = 'legacy',
fileInfo,
reviewInfo,
onReviewPointSelect,
onStatusChange,
onConfirmResults,
onDownload,
auditStatus,
fileFormat,
onUploadTemplate,
onComparison,
showComparisonButton,
}: DetailPanelProps) {
return (
<aside className="border-l border-slate-200 bg-white flex flex-col min-h-0 min-w-0 overflow-hidden">
{/* Tabs */}
<nav className="shrink-0 h-11 px-3 flex items-stretch gap-4 border-b border-slate-200 text-[12.5px]">
{TABS.map((tab) => (
<button
key={tab.key}
type="button"
className={`h-full flex items-center ${
activeTab === tab.key
? 'text-slate-900 font-medium border-b-2 border-[#00684a] -mb-[1px]'
: 'text-slate-500 hover:text-slate-800'
}`}
onClick={() => onTabChange(tab.key)}
>
{tab.label}
{tab.key === 'fields' && (
<span className="ml-1 font-mono text-[11px] text-slate-400">{reviewPoints.length}</span>
)}
</button>
))}
<div className="flex-1" />
{showComparisonButton && (
<>
<button
type="button"
className="h-full flex items-center text-slate-500 hover:text-slate-800 hover:bg-slate-50 px-2 transition"
title="上传模板"
onClick={onUploadTemplate}
>
<i className="ri-upload-cloud-line text-[15px]" />
</button>
<button
type="button"
className="h-full flex items-center text-slate-500 hover:text-slate-800 hover:bg-slate-50 px-2 transition"
title="结构比对"
onClick={onComparison}
>
<i className="ri-flip-horizontal-line text-[15px]" />
</button>
</>
)}
</nav>
{/* Content */}
<div className="flex-1 min-h-0 overflow-y-auto scroll-slim">
{activeTab === 'result' && (
activeReviewPoint ? (
<div className="p-3">
<ReviewPointDetailCard
reviewPoint={activeReviewPoint}
onReviewPointSelect={onReviewPointSelect}
onStatusChange={onStatusChange}
fileFormat={fileFormat}
detailMode={detailMode}
/>
</div>
) : (
<div className="flex flex-col items-center justify-center h-full text-slate-400 text-[13px]">
<i className="ri-cursor-line text-3xl mb-2" />
<span></span>
</div>
)
)}
{activeTab === 'fields' && (
<ExtractedFieldsPanel
reviewPoints={reviewPoints}
onFieldClick={(pointId, page, value, bboxHighlight) => {
onReviewPointSelect(pointId, page, undefined, value, bboxHighlight);
}}
/>
)}
{activeTab === 'info' && (
<FileInfoPanel fileInfo={fileInfo} reviewInfo={reviewInfo} />
)}
</div>
{/* Bottom action bar */}
<div className="shrink-0 border-t border-slate-200 p-2.5 bg-slate-50/60 flex items-center gap-2">
<button
type="button"
className="h-9 w-9 rounded-md text-slate-500 hover:bg-slate-200 grid place-items-center"
title="下载文档"
onClick={onDownload}
>
<i className="ri-download-line text-[16px]" />
</button>
<button
type="button"
className={`flex-1 h-9 rounded-md text-white text-[12.5px] font-medium flex items-center justify-center gap-1.5 shadow-sm ${
auditStatus === 1
? 'bg-slate-300 cursor-not-allowed'
: 'bg-[#00684a] hover:bg-[#005a3f]'
}`}
onClick={auditStatus === 1 ? undefined : onConfirmResults}
disabled={auditStatus === 1}
>
<i className="ri-checkbox-circle-line" />
</button>
</div>
</aside>
);
}