fix: restore reviews detail layout and leaudit data wiring
This commit is contained in:
@@ -92,6 +92,7 @@ interface StatsData {
|
|||||||
success: number;
|
success: number;
|
||||||
warning: number;
|
warning: number;
|
||||||
error: number;
|
error: number;
|
||||||
|
notApplicable?: number;
|
||||||
score: number;
|
score: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -82,6 +82,16 @@ export interface CharPosition {
|
|||||||
score: number; // OCR识别置信度
|
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;
|
title: string;
|
||||||
groupName: string;
|
groupName: string;
|
||||||
status: string;
|
status: string;
|
||||||
content: Record<string, { page?: number | string, value?: object }>;
|
content: Record<string, unknown>;
|
||||||
suggestion: string;
|
suggestion: string;
|
||||||
needsHumanReview?: boolean;
|
needsHumanReview?: boolean;
|
||||||
humanReviewNote?: string;
|
humanReviewNote?: string;
|
||||||
@@ -124,6 +134,7 @@ export interface ReviewPoint {
|
|||||||
failMessage?: string;
|
failMessage?: string;
|
||||||
passMessage?: string;
|
passMessage?: string;
|
||||||
evaluationConfig?: {
|
evaluationConfig?: {
|
||||||
|
confidence?: number;
|
||||||
rules?: Array<{
|
rules?: Array<{
|
||||||
type: string;
|
type: string;
|
||||||
config?: {
|
config?: {
|
||||||
@@ -140,7 +151,22 @@ export interface ReviewPoint {
|
|||||||
res?: boolean;
|
res?: boolean;
|
||||||
config: Record<string, unknown>;
|
config: Record<string, unknown>;
|
||||||
}>;
|
}>;
|
||||||
|
skip_reason?: string;
|
||||||
|
stages?: Array<Record<string, unknown>>;
|
||||||
|
[key: string]: unknown;
|
||||||
};
|
};
|
||||||
|
fieldPositions?: Record<string, {
|
||||||
|
bbox?: [number, number, number, number];
|
||||||
|
page_box?: [number, number, number, number];
|
||||||
|
page_num?: number;
|
||||||
|
confidence?: number;
|
||||||
|
match_method?: string;
|
||||||
|
}>;
|
||||||
|
confidence?: number;
|
||||||
|
ruleStatus?: string;
|
||||||
|
skipReason?: string;
|
||||||
|
remediation?: unknown;
|
||||||
|
riskLevel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 统计数据类型
|
// 统计数据类型
|
||||||
@@ -149,6 +175,7 @@ interface Statistics {
|
|||||||
success: number;
|
success: number;
|
||||||
warning: number;
|
warning: number;
|
||||||
error: number;
|
error: number;
|
||||||
|
notApplicable?: number;
|
||||||
score: number;
|
score: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,8 +221,8 @@ interface EvaluationSummary {
|
|||||||
interface ReviewPointsListProps {
|
interface ReviewPointsListProps {
|
||||||
reviewPoints: ReviewPoint[];
|
reviewPoints: ReviewPoint[];
|
||||||
statistics: Statistics;
|
statistics: Statistics;
|
||||||
activeReviewPointResultId: string | null;
|
activeReviewPointResultId: string | number | null;
|
||||||
onReviewPointSelect: (id: string, page?: number, charPositions?: CharPosition[], value?: string) => void;
|
onReviewPointSelect: (id: string | number, page?: number, charPositions?: CharPosition[], value?: string) => void;
|
||||||
onStatusChange: (id: string, editAuditStatusId: string | number, status: string, message: string) => void;
|
onStatusChange: (id: string, editAuditStatusId: string | number, status: string, message: string) => void;
|
||||||
fileFormat?: string; // 文档格式类型(PDF、DOCX等)
|
fileFormat?: string; // 文档格式类型(PDF、DOCX等)
|
||||||
onAiSuggestionReplace?: (searchText: string, replaceText: string, pageNumber: number) => void; // AI建议替换回调
|
onAiSuggestionReplace?: (searchText: string, replaceText: string, pageNumber: number) => void; // AI建议替换回调
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export { FileInfo } from './FileInfo';
|
|||||||
export { ReviewTabs } from './ReviewTabs';
|
export { ReviewTabs } from './ReviewTabs';
|
||||||
export { FilePreview } from './FilePreview';
|
export { FilePreview } from './FilePreview';
|
||||||
export { ReviewPointsList } from './ReviewPointsList';
|
export { ReviewPointsList } from './ReviewPointsList';
|
||||||
export type { ReviewPoint } from './ReviewPointsList';
|
export type { ReviewPoint, PdfBboxHighlight } from './ReviewPointsList';
|
||||||
export { AIAnalysis } from './AIAnalysis';
|
export { AIAnalysis } from './AIAnalysis';
|
||||||
export { FileDetails } from './FileDetails';
|
export { FileDetails } from './FileDetails';
|
||||||
export { Comparison } from './Comparison';
|
export { Comparison } from './Comparison';
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* 左栏 · 规则目录
|
* 左栏 / 规则目录
|
||||||
* 显示文件名、分数进度条、搜索、评查点分组列表
|
* 展示文件信息、分数进度、搜索与评查点列表。
|
||||||
*/
|
*/
|
||||||
import { useState, useMemo } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import type { ReviewPoint } from '../ReviewPointsList';
|
import type { ReviewPoint } from '../ReviewPointsList';
|
||||||
|
|
||||||
interface Statistics {
|
interface Statistics {
|
||||||
@@ -24,14 +24,17 @@ interface RulesDirectoryProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type PointStatus = 'pass' | 'warn' | 'fail' | 'skipped';
|
type PointStatus = 'pass' | 'warn' | 'fail' | 'skipped';
|
||||||
|
type StatusFilter = 'all' | PointStatus;
|
||||||
|
|
||||||
function classifyPoint(p: ReviewPoint): PointStatus {
|
function classifyPoint(point: ReviewPoint): PointStatus {
|
||||||
if (p.status === 'notApplicable' || p.status === 'not_applicable') return 'skipped';
|
if (point.status === 'notApplicable' || point.status === 'not_applicable') return 'skipped';
|
||||||
if (p.result === true || (p.result === undefined && p.status === 'success')) return 'pass';
|
if (point.result === true || (point.result === undefined && point.status === 'success')) return 'pass';
|
||||||
if (p.result === false) {
|
if (point.result === false) {
|
||||||
if (p.status === 'error') return 'fail';
|
if (point.status === 'error') return 'fail';
|
||||||
if (p.status === 'warning' || p.status === 'info') return 'warn';
|
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';
|
return 'pass';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,6 +45,53 @@ const STATUS_ICON: Record<PointStatus, { icon: string; color: string }> = {
|
|||||||
skipped: { icon: 'ri-forbid-2-line', color: 'text-slate-400' },
|
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({
|
function RuleListItem({
|
||||||
point,
|
point,
|
||||||
isActive,
|
isActive,
|
||||||
@@ -53,33 +103,28 @@ function RuleListItem({
|
|||||||
showCategory?: boolean;
|
showCategory?: boolean;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
}) {
|
}) {
|
||||||
const cls = classifyPoint(point);
|
const status = classifyPoint(point);
|
||||||
const s = STATUS_ICON[cls];
|
const statusMeta = STATUS_ICON[status];
|
||||||
|
const trailingLabel = showCategory ? point.groupName : point.pointCode || point.pointId || '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`rule-item relative w-full py-2 px-3 text-left transition ${
|
className={`rule-item relative w-full py-2 px-3 text-left transition ${
|
||||||
isActive ? 'bg-[rgba(0,104,74,0.08)]' : 'hover:bg-slate-50'
|
isActive ? 'bg-[#e8f3ef]' : 'hover:bg-slate-50'
|
||||||
}`}
|
}`}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
>
|
>
|
||||||
{isActive && (
|
{isActive && <span className="absolute left-0 top-2 bottom-2 w-0.5 bg-[#00684a] rounded-r" />}
|
||||||
<span className="absolute left-0 top-2 bottom-2 w-0.5 bg-[#00684a] rounded-r" />
|
|
||||||
)}
|
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<i className={`${s.icon} ${s.color} shrink-0 text-[14px]`} />
|
<i className={`${statusMeta.icon} ${statusMeta.color} shrink-0 text-[14px]`} />
|
||||||
<span
|
<span
|
||||||
className={`text-[12.5px] text-slate-800 truncate flex-1 ${
|
className={`text-[12.5px] text-slate-800 truncate flex-1 ${isActive ? 'font-semibold' : ''}`}
|
||||||
isActive ? 'font-semibold' : ''
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{point.pointName}
|
{point.pointName}
|
||||||
</span>
|
</span>
|
||||||
{showCategory && (
|
{trailingLabel && (
|
||||||
<span className="shrink-0 text-[10px] text-slate-400">
|
<span className="shrink-0 text-[10px] text-slate-400 font-mono">{trailingLabel}</span>
|
||||||
{point.groupName}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@@ -95,60 +140,76 @@ export function RulesDirectory({
|
|||||||
onBack,
|
onBack,
|
||||||
}: RulesDirectoryProps) {
|
}: RulesDirectoryProps) {
|
||||||
const [searchText, setSearchText] = useState('');
|
const [searchText, setSearchText] = useState('');
|
||||||
|
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
|
||||||
const [passOpen, setPassOpen] = useState(true);
|
const [passOpen, setPassOpen] = useState(true);
|
||||||
const [openCategories, setOpenCategories] = useState<Set<string>>(new Set());
|
const [openCategories, setOpenCategories] = useState<Set<string>>(
|
||||||
|
() => new Set(reviewPoints.map((point) => point.groupName).filter(Boolean))
|
||||||
|
);
|
||||||
|
|
||||||
const q = searchText.toLowerCase();
|
const q = searchText.toLowerCase();
|
||||||
|
|
||||||
const matchSearch = (p: ReviewPoint) =>
|
const matchSearch = (point: ReviewPoint) =>
|
||||||
!q ||
|
!q ||
|
||||||
(p.pointName?.toLowerCase().includes(q)) ||
|
point.pointName?.toLowerCase().includes(q) ||
|
||||||
(p.pointCode?.toLowerCase().includes(q)) ||
|
String(point.pointId ?? '').toLowerCase().includes(q) ||
|
||||||
(p.groupName?.toLowerCase().includes(q));
|
String(point.pointCode ?? '').toLowerCase().includes(q) ||
|
||||||
|
point.groupName?.toLowerCase().includes(q);
|
||||||
|
|
||||||
const { needAttention, passed, passedByGroup } = useMemo(() => {
|
const matchStatus = (point: ReviewPoint) =>
|
||||||
const filtered = reviewPoints.filter(matchSearch);
|
statusFilter === 'all' || classifyPoint(point) === statusFilter;
|
||||||
const needAttention = filtered.filter(
|
|
||||||
(p) => classifyPoint(p) !== 'pass' && classifyPoint(p) !== 'skipped'
|
const statusCounts = useMemo(() => {
|
||||||
|
return reviewPoints.reduce<Record<StatusFilter, number>>(
|
||||||
|
(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<string, ReviewPoint[]> = {};
|
const passedByGroup: Record<string, ReviewPoint[]> = {};
|
||||||
passed.forEach((p) => {
|
passed.forEach((point) => {
|
||||||
const key = p.groupName || '未分组';
|
const key = point.groupName || '未分组';
|
||||||
(passedByGroup[key] = passedByGroup[key] || []).push(p);
|
(passedByGroup[key] = passedByGroup[key] || []).push(point);
|
||||||
});
|
});
|
||||||
|
|
||||||
return { needAttention, passed, passedByGroup };
|
return {
|
||||||
}, [reviewPoints, searchText]);
|
needAttention,
|
||||||
|
passed,
|
||||||
|
passedByGroup,
|
||||||
|
hasSearchResult: filtered.length > 0,
|
||||||
|
};
|
||||||
|
}, [reviewPoints, q, statusFilter]);
|
||||||
|
|
||||||
// 计算进度条百分比
|
|
||||||
const total = statistics.total || reviewPoints.length;
|
const total = statistics.total || reviewPoints.length;
|
||||||
const passPct = total > 0 ? (statistics.success / total) * 100 : 0;
|
const passPct = total > 0 ? (statistics.success / total) * 100 : 0;
|
||||||
const warnPct = total > 0 ? (statistics.warning / total) * 100 : 0;
|
const warnPct = total > 0 ? (statistics.warning / total) * 100 : 0;
|
||||||
const errPct = total > 0 ? (statistics.error / total) * 100 : 0;
|
const errPct = total > 0 ? (statistics.error / total) * 100 : 0;
|
||||||
const naPct =
|
const naPct = total > 0 ? ((statistics.notApplicable || 0) / total) * 100 : 0;
|
||||||
total > 0
|
|
||||||
? ((statistics.notApplicable || 0) / total) * 100
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
const attentionCount = needAttention.length;
|
const attentionCount = reviewPoints.filter((point) => classifyPoint(point) !== 'pass').length;
|
||||||
const passedCount = passed.length;
|
const passedCount = reviewPoints.filter((point) => classifyPoint(point) === 'pass').length;
|
||||||
|
|
||||||
const toggleCategory = (cat: string) => {
|
const toggleCategory = (category: string) => {
|
||||||
setOpenCategories((prev) => {
|
setOpenCategories((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
if (next.has(cat)) next.delete(cat);
|
if (next.has(category)) next.delete(category);
|
||||||
else next.add(cat);
|
else next.add(category);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="border-r border-slate-200 bg-white flex flex-col min-h-0">
|
<aside className="border-r border-slate-200 bg-white flex flex-col min-h-0">
|
||||||
{/* 顶部区域: 文件名 + 分数 + 进度条 */}
|
|
||||||
<div className="shrink-0 px-3 py-3 border-b border-slate-100 space-y-2">
|
<div className="shrink-0 px-3 py-3 border-b border-slate-100 space-y-2">
|
||||||
{/* 文件名 */}
|
|
||||||
<div className="flex items-center gap-1.5 text-[11px] text-slate-500">
|
<div className="flex items-center gap-1.5 text-[11px] text-slate-500">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -162,7 +223,6 @@ export function RulesDirectory({
|
|||||||
<span className="flex-1 break-all">{fileName}</span>
|
<span className="flex-1 break-all">{fileName}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 分数 + 进度条 */}
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-[22px] font-semibold text-slate-900 tabular-nums leading-none">
|
<span className="text-[22px] font-semibold text-slate-900 tabular-nums leading-none">
|
||||||
{Math.round(statistics.score)}
|
{Math.round(statistics.score)}
|
||||||
@@ -170,41 +230,26 @@ export function RulesDirectory({
|
|||||||
<span className="text-[10.5px] text-slate-400">/100</span>
|
<span className="text-[10.5px] text-slate-400">/100</span>
|
||||||
<div className="flex-1 h-1.5 rounded-full overflow-hidden bg-slate-100 ml-1">
|
<div className="flex-1 h-1.5 rounded-full overflow-hidden bg-slate-100 ml-1">
|
||||||
<div className="flex h-full">
|
<div className="flex h-full">
|
||||||
<div
|
<div className="bg-emerald-500" style={{ width: `${passPct}%` }} />
|
||||||
className="bg-emerald-500"
|
<div className="bg-amber-400" style={{ width: `${warnPct}%` }} />
|
||||||
style={{ width: `${passPct}%` }}
|
<div className="bg-red-500" style={{ width: `${errPct}%` }} />
|
||||||
/>
|
<div className="bg-slate-300" style={{ width: `${naPct}%` }} />
|
||||||
<div
|
|
||||||
className="bg-amber-400"
|
|
||||||
style={{ width: `${warnPct}%` }}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className="bg-red-500"
|
|
||||||
style={{ width: `${errPct}%` }}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className="bg-slate-300"
|
|
||||||
style={{ width: `${naPct}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 状态摘要 */}
|
|
||||||
<div className="flex items-center justify-between text-[11px]">
|
<div className="flex items-center justify-between text-[11px]">
|
||||||
<span className="inline-flex items-center gap-1 text-orange-700 bg-orange-50 border border-orange-200 rounded px-1.5 py-0.5 font-medium">
|
<span className="inline-flex items-center gap-1 text-orange-700 bg-orange-50 border border-orange-200 rounded px-1.5 py-0.5 font-medium">
|
||||||
<i className="ri-focus-3-line" />{' '}
|
<i className="ri-focus-3-line" />
|
||||||
<span className="font-mono">{attentionCount}</span> 项需关注
|
<span className="font-mono">{attentionCount}</span> 项需关注
|
||||||
</span>
|
</span>
|
||||||
<span className="text-slate-400">
|
<span className="text-slate-400">
|
||||||
<span className="font-mono text-slate-500">{passedCount}</span> /{' '}
|
<span className="font-mono text-slate-500">{passedCount}</span> / {total} 已通过
|
||||||
{total} 已通过
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 搜索框 */}
|
<div className="shrink-0 p-2.5 border-b border-slate-100 space-y-2">
|
||||||
<div className="shrink-0 p-2.5 border-b border-slate-100">
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<i className="ri-search-line absolute left-2.5 top-1/2 -translate-y-1/2 text-slate-400 text-[14px]" />
|
<i className="ri-search-line absolute left-2.5 top-1/2 -translate-y-1/2 text-slate-400 text-[14px]" />
|
||||||
<input
|
<input
|
||||||
@@ -214,33 +259,46 @@ export function RulesDirectory({
|
|||||||
onChange={(e) => setSearchText(e.target.value)}
|
onChange={(e) => setSearchText(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{FILTER_CHIPS.map((filter) => {
|
||||||
|
const isActive = statusFilter === filter.key;
|
||||||
|
const count = statusCounts[filter.key];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={filter.key}
|
||||||
|
type="button"
|
||||||
|
className={`inline-flex items-center gap-1 px-2 h-6 rounded border text-[11px] font-medium transition ${filter.getClassName(
|
||||||
|
isActive
|
||||||
|
)}`}
|
||||||
|
onClick={() => setStatusFilter(filter.key)}
|
||||||
|
>
|
||||||
|
<span>{filter.label}</span>
|
||||||
|
<span className="font-mono text-[10px] opacity-80">{count}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 评查点列表 */}
|
|
||||||
<div className="flex-1 min-h-0 overflow-y-auto scroll-slim py-1">
|
<div className="flex-1 min-h-0 overflow-y-auto scroll-slim py-1">
|
||||||
{/* 需关注 */}
|
{needAttention.length > 0 && (
|
||||||
{needAttention.length > 0 ? (
|
|
||||||
<div>
|
<div>
|
||||||
{needAttention.map((p) => (
|
<div className="px-3 pt-1 pb-1 text-[10px] font-medium uppercase tracking-[0.18em] text-slate-400">
|
||||||
|
需关注
|
||||||
|
</div>
|
||||||
|
{needAttention.map((point) => (
|
||||||
<RuleListItem
|
<RuleListItem
|
||||||
key={p.id}
|
key={point.id}
|
||||||
point={p}
|
point={point}
|
||||||
isActive={p.id === activeReviewPointResultId}
|
isActive={point.id === activeReviewPointResultId}
|
||||||
showCategory
|
showCategory
|
||||||
onClick={() => onRuleSelect(p.id)}
|
onClick={() => onRuleSelect(point.id)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<div className="text-center py-6 text-[12px] text-slate-400">
|
|
||||||
<i className="ri-check-double-line text-2xl text-emerald-400" />
|
|
||||||
<div className="mt-1">
|
|
||||||
{q ? '没有匹配' : '已全部处理'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 已通过 */}
|
|
||||||
{passed.length > 0 && (
|
{passed.length > 0 && (
|
||||||
<div className="border-t border-slate-200 mt-2">
|
<div className="border-t border-slate-200 mt-2">
|
||||||
<button
|
<button
|
||||||
@@ -255,40 +313,38 @@ export function RulesDirectory({
|
|||||||
/>
|
/>
|
||||||
<i className="ri-checkbox-circle-fill text-emerald-500 text-[14px]" />
|
<i className="ri-checkbox-circle-fill text-emerald-500 text-[14px]" />
|
||||||
<span className="text-[12px] text-slate-600">已通过</span>
|
<span className="text-[12px] text-slate-600">已通过</span>
|
||||||
<span className="ml-auto font-mono text-[11px] text-slate-400">
|
<span className="ml-auto font-mono text-[11px] text-slate-400">{passed.length}</span>
|
||||||
{passed.length}
|
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{passOpen &&
|
{passOpen &&
|
||||||
Object.entries(passedByGroup).map(([cat, points]) => {
|
Object.entries(passedByGroup).map(([category, points]) => {
|
||||||
const catOpen = openCategories.has(cat);
|
const isOpen = openCategories.has(category);
|
||||||
return (
|
return (
|
||||||
<div key={cat}>
|
<div key={category}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="w-full flex items-center gap-1.5 px-3 py-1.5 bg-slate-50/40 hover:bg-slate-100 text-left"
|
className="w-full flex items-center gap-1.5 px-3 py-1.5 bg-slate-50/40 hover:bg-slate-100 text-left"
|
||||||
onClick={() => toggleCategory(cat)}
|
onClick={() => toggleCategory(category)}
|
||||||
>
|
>
|
||||||
<i
|
<i
|
||||||
className={`ri-arrow-right-s-line text-slate-400 text-[11px] transition-transform ${
|
className={`ri-arrow-right-s-line text-slate-400 text-[11px] transition-transform ${
|
||||||
catOpen ? 'rotate-90' : ''
|
isOpen ? 'rotate-90' : ''
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
<span className="text-[10px] font-medium text-slate-500 uppercase tracking-wider">
|
<span className="text-[10px] font-medium text-slate-500 uppercase tracking-wider">
|
||||||
{cat}
|
{category}
|
||||||
</span>
|
</span>
|
||||||
<span className="ml-auto font-mono text-[10px] text-slate-400">
|
<span className="ml-auto font-mono text-[10px] text-slate-400">
|
||||||
{points.length}
|
{points.length}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
{catOpen &&
|
{isOpen &&
|
||||||
points.map((p) => (
|
points.map((point) => (
|
||||||
<RuleListItem
|
<RuleListItem
|
||||||
key={p.id}
|
key={point.id}
|
||||||
point={p}
|
point={point}
|
||||||
isActive={p.id === activeReviewPointResultId}
|
isActive={point.id === activeReviewPointResultId}
|
||||||
onClick={() => onRuleSelect(p.id)}
|
onClick={() => onRuleSelect(point.id)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -296,6 +352,17 @@ export function RulesDirectory({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!hasSearchResult && (
|
||||||
|
<div className="text-center py-8 text-[12px] text-slate-400">
|
||||||
|
<i
|
||||||
|
className={`text-2xl ${
|
||||||
|
q ? 'ri-search-eye-line text-slate-300' : 'ri-check-double-line text-emerald-400'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<div className="mt-1">{q ? '没有匹配结果' : '当前筛选下暂无规则'}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
import { useState, useEffect, useMemo, useRef, useCallback } from 'react';
|
import { useState, useEffect, useMemo, useRef, useCallback } from 'react';
|
||||||
import { Document, Page, pdfjs } from 'react-pdf';
|
import { Document, Page, pdfjs } from 'react-pdf';
|
||||||
import { toastService } from '~/components/ui/Toast';
|
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/TextLayer.css';
|
||||||
import 'react-pdf/dist/esm/Page/AnnotationLayer.css';
|
import 'react-pdf/dist/esm/Page/AnnotationLayer.css';
|
||||||
@@ -33,6 +33,7 @@ interface PdfPreviewProps {
|
|||||||
filePath: string;
|
filePath: string;
|
||||||
targetPage?: number;
|
targetPage?: number;
|
||||||
charPositions?: Array<{ box: number[][]; char: string; score: number }>;
|
charPositions?: Array<{ box: number[][]; char: string; score: number }>;
|
||||||
|
bboxHighlight?: PdfBboxHighlight;
|
||||||
isStructuredView?: boolean;
|
isStructuredView?: boolean;
|
||||||
activeReviewPointResultId?: string | null;
|
activeReviewPointResultId?: string | null;
|
||||||
pageOffset?: number;
|
pageOffset?: number;
|
||||||
@@ -43,11 +44,20 @@ interface PdfPreviewProps {
|
|||||||
reviewPoints?: ReviewPoint[];
|
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 → 状态映射
|
// ReviewPoint → 状态映射
|
||||||
// ============================================================
|
// ============================================================
|
||||||
const STATUS_ORDER: Record<WorstStatus, number> = { fail: 0, warn: 1, pending: 2, pass: 3 };
|
const STATUS_ORDER: Record<WorstStatus, number> = { 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<WorstStatus, { cls: string; ic: string }> = {
|
const STATUS_BADGE: Record<WorstStatus, { cls: string; ic: string }> = {
|
||||||
fail: { cls: 'bg-red-500', ic: 'ri-close-circle-fill' },
|
fail: { cls: 'bg-red-500', ic: 'ri-close-circle-fill' },
|
||||||
warn: { cls: 'bg-amber-500', ic: 'ri-lightbulb-flash-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 (Number.isFinite(n) && n > 0) set.add(n);
|
||||||
};
|
};
|
||||||
if (p.contentPage) Object.values(p.contentPage).forEach(addMaybe);
|
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);
|
return [...set].sort((a, b) => a - b);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,12 +105,20 @@ function getFieldsOnPage(p: ReviewPoint, page: number): string[] {
|
|||||||
}
|
}
|
||||||
if (!fields.length && p.content) {
|
if (!fields.length && p.content) {
|
||||||
Object.entries(p.content).forEach(([k, v]) => {
|
Object.entries(p.content).forEach(([k, v]) => {
|
||||||
if (matches(v?.page)) fields.push(k);
|
if (matches(getContentItemPage(v))) fields.push(k);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return fields;
|
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,
|
filePath,
|
||||||
targetPage,
|
targetPage,
|
||||||
charPositions,
|
charPositions,
|
||||||
|
bboxHighlight,
|
||||||
isStructuredView = false,
|
isStructuredView = false,
|
||||||
activeReviewPointResultId,
|
activeReviewPointResultId,
|
||||||
pageOffset = 0,
|
pageOffset = 0,
|
||||||
@@ -115,63 +134,55 @@ export function PdfPreviewTest({
|
|||||||
onZoomChange,
|
onZoomChange,
|
||||||
reviewPoints,
|
reviewPoints,
|
||||||
}: PdfPreviewProps) {
|
}: PdfPreviewProps) {
|
||||||
// ---------- 基础状态 ----------
|
|
||||||
const [numPages, setNumPages] = useState<number | null>(null);
|
const [numPages, setNumPages] = useState<number | null>(null);
|
||||||
const [currentPage, setCurrentPage] = useState<number>(1);
|
const [currentPage, setCurrentPage] = useState<number>(1);
|
||||||
const [zoomLevel, setZoomLevel] = useState(100);
|
const [zoomLevel, setZoomLevel] = useState(100);
|
||||||
const [loadError, setLoadError] = useState<string | null>(null);
|
const [loadError, setLoadError] = useState<string | null>(null);
|
||||||
|
|
||||||
// 略缩图面板
|
|
||||||
const [showThumbs, setShowThumbs] = useState(true);
|
const [showThumbs, setShowThumbs] = useState(true);
|
||||||
const [thumbMode, setThumbMode] = useState<'filtered' | 'all'>('filtered');
|
const [thumbMode, setThumbMode] = useState<'filtered' | 'all'>('filtered');
|
||||||
|
|
||||||
// 坐标校准(保留主视口高亮逻辑)
|
|
||||||
const [coordinateScale, setCoordinateScale] = useState(0.83);
|
const [coordinateScale, setCoordinateScale] = useState(0.83);
|
||||||
const [isScaleAutoCalculated, setIsScaleAutoCalculated] = useState(false);
|
const [isScaleAutoCalculated, setIsScaleAutoCalculated] = useState(false);
|
||||||
|
|
||||||
// 页码跳转输入
|
|
||||||
const [pageInputValue, setPageInputValue] = useState<string>('');
|
const [pageInputValue, setPageInputValue] = useState<string>('');
|
||||||
|
const [pageRenderTick, setPageRenderTick] = useState(0);
|
||||||
|
const [thumbsScrollTop, setThumbsScrollTop] = useState(0);
|
||||||
|
const [thumbsViewportHeight, setThumbsViewportHeight] = useState(0);
|
||||||
|
|
||||||
const viewportRef = useRef<HTMLDivElement>(null);
|
const viewportRef = useRef<HTMLDivElement>(null);
|
||||||
const thumbsPanelRef = useRef<HTMLDivElement>(null);
|
const thumbsPanelRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// ---------- 派生数据 ----------
|
const fileUrl = useMemo(() => `/api/pdf-proxy?path=${encodeURIComponent(filePath)}`, [filePath]);
|
||||||
const fileUrl = useMemo(
|
|
||||||
() => `/api/pdf-proxy?path=${encodeURIComponent(filePath)}`,
|
|
||||||
[filePath],
|
|
||||||
);
|
|
||||||
|
|
||||||
const activePoint = useMemo<ReviewPoint | undefined>(
|
const activePoint = useMemo<ReviewPoint | undefined>(
|
||||||
() => reviewPoints?.find(p => p.id === activeReviewPointResultId),
|
() => reviewPoints?.find(p => p.id === activeReviewPointResultId),
|
||||||
[reviewPoints, activeReviewPointResultId],
|
[reviewPoints, activeReviewPointResultId],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 当前规则涉及的页(字段级,带 pageOffset)
|
|
||||||
const rulePages = useMemo<number[]>(() => {
|
const rulePages = useMemo<number[]>(() => {
|
||||||
if (!activePoint) return [];
|
if (!activePoint) return [];
|
||||||
return getPointPages(activePoint).map(p => p + pageOffset);
|
return getPointPages(activePoint).map(page => page + pageOffset);
|
||||||
}, [activePoint, pageOffset]);
|
}, [activePoint, pageOffset]);
|
||||||
|
|
||||||
// 每页状态聚合
|
|
||||||
const pageStatusMap = useMemo<Map<number, PageAgg>>(() => {
|
const pageStatusMap = useMemo<Map<number, PageAgg>>(() => {
|
||||||
const m = new Map<number, PageAgg>();
|
const map = new Map<number, PageAgg>();
|
||||||
if (!reviewPoints?.length) return m;
|
if (!reviewPoints?.length) return map;
|
||||||
reviewPoints.forEach(p => {
|
|
||||||
const cls = classifyReviewPoint(p);
|
reviewPoints.forEach(point => {
|
||||||
if (cls === 'skipped') return;
|
const status = classifyReviewPoint(point);
|
||||||
const pages = getPointPages(p).map(x => x + pageOffset);
|
if (status === 'skipped') return;
|
||||||
pages.forEach(pg => {
|
|
||||||
const cur = m.get(pg) || { worst: 'pass', count: 0, issues: 0 };
|
getPointPages(point).map(page => page + pageOffset).forEach(page => {
|
||||||
if (STATUS_ORDER[cls] < STATUS_ORDER[cur.worst]) cur.worst = cls;
|
const current = map.get(page) || { worst: 'pass', count: 0, issues: 0 };
|
||||||
cur.count += 1;
|
if (STATUS_ORDER[status] < STATUS_ORDER[current.worst]) current.worst = status;
|
||||||
if (cls !== 'pass') cur.issues += 1;
|
current.count += 1;
|
||||||
m.set(pg, cur);
|
if (status !== 'pass') current.issues += 1;
|
||||||
|
map.set(page, current);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
return m;
|
|
||||||
|
return map;
|
||||||
}, [reviewPoints, pageOffset]);
|
}, [reviewPoints, pageOffset]);
|
||||||
|
|
||||||
// 当前高亮标签(工具栏右侧)
|
|
||||||
const highlightLabel = useMemo(() => {
|
const highlightLabel = useMemo(() => {
|
||||||
if (!activePoint) return null;
|
if (!activePoint) return null;
|
||||||
const code = activePoint.pointCode || activePoint.id;
|
const code = activePoint.pointCode || activePoint.id;
|
||||||
@@ -179,7 +190,42 @@ export function PdfPreviewTest({
|
|||||||
return `${code}${name ? ' · ' + name : ''}`;
|
return `${code}${name ? ' · ' + name : ''}`;
|
||||||
}, [activePoint]);
|
}, [activePoint]);
|
||||||
|
|
||||||
// ---------- 通知上层 ----------
|
const effThumbMode: 'filtered' | 'all' =
|
||||||
|
thumbMode === 'filtered' && rulePages.length > 0 ? 'filtered' : 'all';
|
||||||
|
|
||||||
|
const thumbPages = useMemo<number[]>(() => {
|
||||||
|
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(() => {
|
useEffect(() => {
|
||||||
if (numPages && onNumPagesChange) onNumPagesChange(numPages);
|
if (numPages && onNumPagesChange) onNumPagesChange(numPages);
|
||||||
}, [numPages, onNumPagesChange]);
|
}, [numPages, onNumPagesChange]);
|
||||||
@@ -188,7 +234,6 @@ export function PdfPreviewTest({
|
|||||||
if (onZoomChange) onZoomChange(zoomLevel);
|
if (onZoomChange) onZoomChange(zoomLevel);
|
||||||
}, [zoomLevel, onZoomChange]);
|
}, [zoomLevel, onZoomChange]);
|
||||||
|
|
||||||
// ---------- targetPage 跳转 ----------
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (targetPage && numPages) {
|
if (targetPage && numPages) {
|
||||||
const next = Math.max(1, Math.min(numPages, targetPage + pageOffset));
|
const next = Math.max(1, Math.min(numPages, targetPage + pageOffset));
|
||||||
@@ -196,33 +241,77 @@ export function PdfPreviewTest({
|
|||||||
}
|
}
|
||||||
}, [targetPage, numPages, pageOffset, activeReviewPointResultId]);
|
}, [targetPage, numPages, pageOffset, activeReviewPointResultId]);
|
||||||
|
|
||||||
// ---------- 切换规则:重置略缩图模式为 filtered ----------
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeReviewPointResultId) setThumbMode('filtered');
|
if (activeReviewPointResultId) setThumbMode('filtered');
|
||||||
}, [activeReviewPointResultId]);
|
}, [activeReviewPointResultId]);
|
||||||
|
|
||||||
// ---------- 文件路径变化:重置坐标自动计算 ----------
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsScaleAutoCalculated(false);
|
setIsScaleAutoCalculated(false);
|
||||||
}, [filePath]);
|
}, [filePath]);
|
||||||
|
|
||||||
// ---------- 略缩图滚动到当前页 ----------
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const host = thumbsPanelRef.current;
|
const host = thumbsPanelRef.current;
|
||||||
if (!host) return;
|
if (!host) return;
|
||||||
const el = host.querySelector<HTMLElement>(`[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(
|
const onDocumentLoadSuccess = useCallback(
|
||||||
({ numPages: n }: { numPages: number }) => {
|
({ numPages: loadedNumPages }: { numPages: number }) => {
|
||||||
setNumPages(n);
|
setNumPages(loadedNumPages);
|
||||||
// 初始页:优先 targetPage,否则第 1 页
|
|
||||||
if (targetPage) {
|
if (targetPage) {
|
||||||
setCurrentPage(Math.max(1, Math.min(n, targetPage + pageOffset)));
|
setCurrentPage(Math.max(1, Math.min(loadedNumPages, targetPage + pageOffset)));
|
||||||
} else {
|
} else {
|
||||||
setCurrentPage(p => Math.max(1, Math.min(n, p)));
|
setCurrentPage(page => Math.max(1, Math.min(loadedNumPages, page)));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[targetPage, pageOffset],
|
[targetPage, pageOffset],
|
||||||
@@ -233,19 +322,18 @@ export function PdfPreviewTest({
|
|||||||
setLoadError('PDF文档加载失败:' + (error.message || '未知错误'));
|
setLoadError('PDF文档加载失败:' + (error.message || '未知错误'));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// ---------- 主页面加载(自动校准坐标) ----------
|
|
||||||
const onMainPageLoadSuccess = useCallback(
|
const onMainPageLoadSuccess = useCallback(
|
||||||
(page: any) => {
|
(page: any) => {
|
||||||
|
setPageRenderTick(tick => tick + 1);
|
||||||
if (isScaleAutoCalculated) return;
|
if (isScaleAutoCalculated) return;
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const pdfOriginalWidthPt = page.view?.[2] || page.originalWidth || page.width;
|
const pdfOriginalWidthPt = page.view?.[2] || page.originalWidth || page.width;
|
||||||
const canvas = viewportRef.current?.querySelector(
|
const canvas = viewportRef.current?.querySelector('.pdf-main-canvas .react-pdf__Page__canvas') as HTMLCanvasElement | null;
|
||||||
'.pdf-main-canvas .react-pdf__Page__canvas',
|
|
||||||
) as HTMLCanvasElement | null;
|
|
||||||
if (canvas && pdfOriginalWidthPt) {
|
if (canvas && pdfOriginalWidthPt) {
|
||||||
const canvasDisplayWidth = canvas.offsetWidth;
|
const canvasDisplayWidth = canvas.offsetWidth;
|
||||||
const currentScale = zoomLevel / 100;
|
const currentScale = zoomLevel / 100;
|
||||||
const autoScale = (canvasDisplayWidth / currentScale) / pdfOriginalWidthPt;
|
const autoScale = canvasDisplayWidth / currentScale / pdfOriginalWidthPt;
|
||||||
setCoordinateScale(autoScale);
|
setCoordinateScale(autoScale);
|
||||||
setIsScaleAutoCalculated(true);
|
setIsScaleAutoCalculated(true);
|
||||||
}
|
}
|
||||||
@@ -254,71 +342,89 @@ export function PdfPreviewTest({
|
|||||||
[isScaleAutoCalculated, zoomLevel],
|
[isScaleAutoCalculated, zoomLevel],
|
||||||
);
|
);
|
||||||
|
|
||||||
// ---------- 翻页 / 缩放 ----------
|
const goPrev = () => setCurrentPage(page => Math.max(1, page - 1));
|
||||||
const goPrev = () => setCurrentPage(p => Math.max(1, p - 1));
|
const goNext = () => setCurrentPage(page => Math.min(numPages || page, page + 1));
|
||||||
const goNext = () => setCurrentPage(p => Math.min(numPages || p, p + 1));
|
const zoomIn = () => setZoomLevel(level => Math.min(200, level + 10));
|
||||||
const zoomIn = () => setZoomLevel(z => Math.min(200, z + 10));
|
const zoomOut = () => setZoomLevel(level => Math.max(50, level - 10));
|
||||||
const zoomOut = () => setZoomLevel(z => Math.max(50, z - 10));
|
|
||||||
|
|
||||||
const jumpToHighlight = () => {
|
const jumpToHighlight = () => {
|
||||||
if (!activePoint || rulePages.length === 0) {
|
if (!activePoint || rulePages.length === 0) {
|
||||||
toastService.info('当前规则无关联页');
|
toastService.info('当前规则无关联页');
|
||||||
return;
|
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<HTMLInputElement>) => {
|
const handlePageInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
setPageInputValue(e.target.value.replace(/\D/g, ''));
|
setPageInputValue(e.target.value.replace(/\D/g, ''));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePageJump = () => {
|
const handlePageJump = () => {
|
||||||
if (!pageInputValue || !numPages) return;
|
if (!pageInputValue || !numPages) return;
|
||||||
const n = parseInt(pageInputValue, 10);
|
const nextPage = parseInt(pageInputValue, 10);
|
||||||
if (n > 0 && n <= numPages) {
|
if (nextPage > 0 && nextPage <= numPages) {
|
||||||
setCurrentPage(n);
|
setCurrentPage(nextPage);
|
||||||
setPageInputValue('');
|
setPageInputValue('');
|
||||||
} else {
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
toastService.warning(`请输入有效页码 (1-${numPages})`);
|
toastService.warning(`请输入有效页码 (1-${numPages})`);
|
||||||
setPageInputValue('');
|
setPageInputValue('');
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------- 高亮矩形(对齐原 PdfPreview 的字符位置) ----------
|
const bboxRectHighlight = useMemo(() => {
|
||||||
const mainHighlight = useMemo(() => {
|
if (!bboxHighlight || !isValidQuad(bboxHighlight.bbox) || !isValidQuad(bboxHighlight.pageBox)) return null;
|
||||||
if (!charPositions?.length || !targetPage) {
|
|
||||||
console.log('[PdfPreviewTest] highlight skipped: no charPositions/targetPage', {
|
const expectedPage = bboxHighlight.page ?? (typeof bboxHighlight.pageNum === 'number' ? bboxHighlight.pageNum + 1 : targetPage);
|
||||||
hasCharPositions: !!charPositions?.length,
|
if (!expectedPage || currentPage !== expectedPage + pageOffset) return null;
|
||||||
targetPage,
|
|
||||||
});
|
const canvas = viewportRef.current?.querySelector('.pdf-main-canvas .react-pdf__Page__canvas') as HTMLCanvasElement | null;
|
||||||
return null;
|
if (!canvas) return null;
|
||||||
}
|
|
||||||
if (currentPage !== targetPage + pageOffset) {
|
const [pageX0, pageY0, pageX1, pageY1] = bboxHighlight.pageBox;
|
||||||
console.log('[PdfPreviewTest] highlight skipped: page mismatch', {
|
const [bboxLeft, bboxTop, bboxRight, bboxBottom] = bboxHighlight.bbox;
|
||||||
currentPage,
|
const pageWidth = pageX1 - pageX0;
|
||||||
targetPage,
|
const pageHeight = pageY1 - pageY0;
|
||||||
pageOffset,
|
if (pageWidth <= 0 || pageHeight <= 0) return null;
|
||||||
expected: targetPage + pageOffset,
|
|
||||||
});
|
const left = clamp(Math.min(bboxLeft, bboxRight), pageX0, pageX1);
|
||||||
return null;
|
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;
|
const scale = zoomLevel / 100;
|
||||||
let minX = Infinity,
|
let minX = Infinity;
|
||||||
minY = Infinity,
|
let minY = Infinity;
|
||||||
maxX = -Infinity,
|
let maxX = -Infinity;
|
||||||
maxY = -Infinity;
|
let maxY = -Infinity;
|
||||||
const chars: string[] = [];
|
const chars: string[] = [];
|
||||||
charPositions.forEach(cp => {
|
|
||||||
chars.push(cp.char);
|
charPositions.forEach(position => {
|
||||||
cp.box.forEach(pt => {
|
chars.push(position.char);
|
||||||
const [x, y] = pt;
|
position.box.forEach(([x, y]) => {
|
||||||
if (x < minX) minX = x;
|
if (x < minX) minX = x;
|
||||||
if (x > maxX) maxX = x;
|
if (x > maxX) maxX = x;
|
||||||
if (y < minY) minY = y;
|
if (y < minY) minY = y;
|
||||||
if (y > maxY) maxY = y;
|
if (y > maxY) maxY = y;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
x: minX * coordinateScale * scale,
|
x: minX * coordinateScale * scale,
|
||||||
y: minY * coordinateScale * scale,
|
y: minY * coordinateScale * scale,
|
||||||
@@ -326,21 +432,10 @@ export function PdfPreviewTest({
|
|||||||
height: (maxY - minY) * coordinateScale * scale,
|
height: (maxY - minY) * coordinateScale * scale,
|
||||||
text: chars.join(''),
|
text: chars.join(''),
|
||||||
};
|
};
|
||||||
}, [charPositions, targetPage, currentPage, pageOffset, zoomLevel, coordinateScale]);
|
}, [bboxRectHighlight, charPositions, targetPage, currentPage, pageOffset, zoomLevel, coordinateScale, pageRenderTick]);
|
||||||
|
|
||||||
// ---------- 略缩图可见页列表 ----------
|
const mainHighlight = bboxRectHighlight || charRectHighlight;
|
||||||
const effThumbMode: 'filtered' | 'all' =
|
|
||||||
thumbMode === 'filtered' && rulePages.length > 0 ? 'filtered' : 'all';
|
|
||||||
|
|
||||||
const thumbPages = useMemo<number[]>(() => {
|
|
||||||
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) {
|
if (loadError) {
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full grid place-items-center text-red-500 p-4">{loadError}</div>
|
<div className="w-full h-full grid place-items-center text-red-500 p-4">{loadError}</div>
|
||||||
@@ -391,7 +486,7 @@ export function PdfPreviewTest({
|
|||||||
onKeyDown={e => {
|
onKeyDown={e => {
|
||||||
if (e.key === 'Enter') handlePageJump();
|
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"
|
||||||
/>
|
/>
|
||||||
<span className="text-slate-400"> / {numPages ?? '-'}</span>
|
<span className="text-slate-400"> / {numPages ?? '-'}</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -481,7 +576,7 @@ export function PdfPreviewTest({
|
|||||||
{/* 略缩图列表 */}
|
{/* 略缩图列表 */}
|
||||||
<div
|
<div
|
||||||
ref={thumbsPanelRef}
|
ref={thumbsPanelRef}
|
||||||
className="flex-1 overflow-y-auto py-2 px-2 space-y-2"
|
className="flex-1 overflow-y-auto py-2 px-2"
|
||||||
>
|
>
|
||||||
{numPages === null ? (
|
{numPages === null ? (
|
||||||
<div className="text-center text-[11px] text-slate-400 py-4">加载中…</div>
|
<div className="text-center text-[11px] text-slate-400 py-4">加载中…</div>
|
||||||
@@ -491,10 +586,12 @@ export function PdfPreviewTest({
|
|||||||
<div className="mt-1">此规则无关联页面</div>
|
<div className="mt-1">此规则无关联页面</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
thumbPages.map(p => {
|
<div className="relative w-full" style={{ height: totalThumbHeight }}>
|
||||||
|
{visibleThumbItems.map((p, visibleIndex) => {
|
||||||
|
const itemIndex = visibleThumbRange.start + visibleIndex;
|
||||||
const info = pageStatusMap.get(p);
|
const info = pageStatusMap.get(p);
|
||||||
const isCur = p === currentPage;
|
const isCur = p === currentPage;
|
||||||
const isRulePage = rulePages.includes(p);
|
const isRulePage = rulePageSet.has(p);
|
||||||
|
|
||||||
let badge: React.ReactNode = null;
|
let badge: React.ReactNode = null;
|
||||||
if (info) {
|
if (info) {
|
||||||
@@ -534,14 +631,16 @@ export function PdfPreviewTest({
|
|||||||
key={p}
|
key={p}
|
||||||
data-thumb-page={p}
|
data-thumb-page={p}
|
||||||
onClick={() => setCurrentPage(p)}
|
onClick={() => setCurrentPage(p)}
|
||||||
className="block w-full group"
|
className="absolute left-0 block w-full group"
|
||||||
|
style={{ top: itemIndex * THUMB_ESTIMATED_HEIGHT }}
|
||||||
title={`第 ${p} 页`}
|
title={`第 ${p} 页`}
|
||||||
>
|
>
|
||||||
<div className={`relative rounded overflow-hidden bg-white transition ${frameCls}`}>
|
<div className={`relative rounded overflow-hidden bg-white transition ${frameCls}`}>
|
||||||
<div className="w-full bg-gradient-to-b from-white to-slate-50 overflow-hidden">
|
<div className="w-full bg-gradient-to-b from-white to-slate-50 overflow-hidden">
|
||||||
<Page
|
<Page
|
||||||
pageNumber={p}
|
pageNumber={p}
|
||||||
width={112}
|
width={THUMB_WIDTH}
|
||||||
|
devicePixelRatio={1}
|
||||||
renderTextLayer={false}
|
renderTextLayer={false}
|
||||||
renderAnnotationLayer={false}
|
renderAnnotationLayer={false}
|
||||||
loading={<div className="w-full h-[150px] bg-slate-50" />}
|
loading={<div className="w-full h-[150px] bg-slate-50" />}
|
||||||
@@ -560,7 +659,8 @@ export function PdfPreviewTest({
|
|||||||
{fieldsLabel}
|
{fieldsLabel}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})
|
})}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -586,7 +686,7 @@ export function PdfPreviewTest({
|
|||||||
<Page
|
<Page
|
||||||
pageNumber={Math.min(currentPage, numPages)}
|
pageNumber={Math.min(currentPage, numPages)}
|
||||||
scale={zoomLevel / 100}
|
scale={zoomLevel / 100}
|
||||||
devicePixelRatio={typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1}
|
devicePixelRatio={mainPageDevicePixelRatio}
|
||||||
renderTextLayer={false}
|
renderTextLayer={false}
|
||||||
renderAnnotationLayer={false}
|
renderAnnotationLayer={false}
|
||||||
onLoadSuccess={onMainPageLoadSuccess}
|
onLoadSuccess={onMainPageLoadSuccess}
|
||||||
@@ -610,7 +710,8 @@ export function PdfPreviewTest({
|
|||||||
y={mainHighlight.y}
|
y={mainHighlight.y}
|
||||||
width={mainHighlight.width}
|
width={mainHighlight.width}
|
||||||
height={mainHighlight.height}
|
height={mainHighlight.height}
|
||||||
fill="#00AA00"
|
// fill="#00AA00"
|
||||||
|
fill="#abf694"
|
||||||
fillOpacity="0.1"
|
fillOpacity="0.1"
|
||||||
stroke="#00684a"
|
stroke="#00684a"
|
||||||
strokeWidth="0.5"
|
strokeWidth="0.5"
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* 右栏 · 详情面板
|
* 右栏 · 详情面板
|
||||||
* 包含 3 个选项卡(评查结果、抽取字段、文件信息)+ 底部操作栏
|
* 包含 3 个选项卡(评查结果、抽取字段、文件信息)和底部操作栏
|
||||||
*/
|
*/
|
||||||
import type { ReviewPoint, CharPosition } from '../ReviewPointsList';
|
import type { ReviewPoint, CharPosition, PdfBboxHighlight } from '../ReviewPointsList';
|
||||||
import { ReviewPointDetailCard } from './ReviewPointDetailCard';
|
import { ReviewPointDetailCard } from './ReviewPointDetailCard';
|
||||||
import { FileInfoPanel } from './FileInfoPanel';
|
import { FileInfoPanel } from './FileInfoPanel';
|
||||||
|
|
||||||
@@ -32,10 +32,11 @@ interface DetailPanelProps {
|
|||||||
onTabChange: (tab: TabKey) => void;
|
onTabChange: (tab: TabKey) => void;
|
||||||
activeReviewPoint: ReviewPoint | null;
|
activeReviewPoint: ReviewPoint | null;
|
||||||
reviewPoints: ReviewPoint[];
|
reviewPoints: ReviewPoint[];
|
||||||
|
detailMode?: 'legacy' | 'leaudit';
|
||||||
fileInfo: FileInfoData;
|
fileInfo: FileInfoData;
|
||||||
reviewInfo: ReviewInfoData;
|
reviewInfo: ReviewInfoData;
|
||||||
onReviewPointSelect: (id: string | number, page?: number, charPositions?: CharPosition[], value?: string) => void;
|
onReviewPointSelect: (id: string | number, page?: number, charPositions?: CharPosition[], value?: string, bboxHighlight?: PdfBboxHighlight) => void;
|
||||||
onStatusChange: (id: string | number, editAuditStatusId: string | number, status: string, message: string) => void;
|
onStatusChange: (id: string, editAuditStatusId: string | number, status: string, message: string) => void;
|
||||||
onConfirmResults: () => void;
|
onConfirmResults: () => void;
|
||||||
onDownload: () => void;
|
onDownload: () => void;
|
||||||
auditStatus?: number;
|
auditStatus?: number;
|
||||||
@@ -45,63 +46,191 @@ interface DetailPanelProps {
|
|||||||
showComparisonButton?: boolean;
|
showComparisonButton?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ExtractedFieldValue = {
|
function isValidQuad(value: unknown): value is [number, number, number, number] {
|
||||||
value?: unknown;
|
return Array.isArray(value) && value.length === 4 && value.every(item => typeof item === 'number' && Number.isFinite(item));
|
||||||
page?: number | string;
|
}
|
||||||
};
|
|
||||||
|
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({
|
function ExtractedFieldsPanel({
|
||||||
reviewPoints,
|
reviewPoints,
|
||||||
onFieldClick,
|
onFieldClick,
|
||||||
}: {
|
}: {
|
||||||
reviewPoints: ReviewPoint[];
|
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) => {
|
reviewPoints.forEach((p) => {
|
||||||
if (p.content) {
|
if (p.content) {
|
||||||
Object.entries(p.content).forEach(([key, data]) => {
|
Object.entries(p.content).forEach(([key, rawValue]) => {
|
||||||
const fieldData = (data && typeof data === 'object' ? data : {}) as ExtractedFieldValue & { text?: string };
|
const fieldRawValue = getFieldRawValue(rawValue);
|
||||||
const val = fieldData.value;
|
const displayValue = getFieldDisplayText(fieldRawValue);
|
||||||
const page = fieldData.page;
|
const highlightValue = getFieldHighlightText(fieldRawValue);
|
||||||
const text = typeof val === 'object' && val !== null
|
const page = getFieldPage(p, key, rawValue);
|
||||||
? ('text' in (val as Record<string, unknown>) ? String((val as Record<string, unknown>).text || '') : JSON.stringify(val))
|
const confidence = getFieldConfidence(p, key);
|
||||||
: String(val || '');
|
const bboxHighlight = getFieldBboxHighlight(p, key, page);
|
||||||
|
|
||||||
fields.push({
|
fields.push({
|
||||||
key,
|
key,
|
||||||
value: text,
|
displayValue,
|
||||||
page: page ? Number(page) : undefined,
|
highlightValue,
|
||||||
pointName: p.pointName,
|
isMissing: fieldRawValue == null,
|
||||||
|
confidence,
|
||||||
|
page,
|
||||||
pointId: p.id,
|
pointId: p.id,
|
||||||
|
bboxHighlight,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-3">
|
<div className="h-full flex flex-col min-h-0">
|
||||||
<div className="text-[11px] font-medium text-slate-400 uppercase tracking-wider mb-2">
|
<div className="shrink-0 px-4 py-3 border-b border-slate-100">
|
||||||
抽取字段 <span className="font-mono normal-case text-[10.5px]">{fields.length}</span>
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
{fields.length === 0 ? (
|
{fields.length === 0 ? (
|
||||||
<div className="text-center py-6 text-[12px] text-slate-400">暂无抽取字段</div>
|
<div className="text-center py-10 text-[12px] text-slate-400">暂无抽取字段</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="flex-1 min-h-0 overflow-y-auto scroll-slim">
|
||||||
{fields.map((f, i) => (
|
{fields.map((f, i) => (
|
||||||
<button
|
<div
|
||||||
key={`${f.key}-${i}`}
|
key={`${f.key}-${i}`}
|
||||||
type="button"
|
role={f.page ? 'button' : undefined}
|
||||||
className={`w-full border border-slate-200 rounded-md text-left hover:bg-slate-50 transition p-2.5 ${f.page ? 'cursor-pointer' : 'cursor-default opacity-70'}`}
|
tabIndex={f.page ? 0 : undefined}
|
||||||
onClick={() => f.page && onFieldClick(f.pointId, f.page)}
|
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 items-center justify-between gap-2">
|
<div className="flex-1 min-w-0">
|
||||||
<span className="text-[11px] text-slate-500 truncate font-medium">{f.key}</span>
|
<div className="text-[12px] font-medium text-slate-800 leading-5 break-words">{f.key}</div>
|
||||||
{f.page && <span className="text-[10.5px] text-slate-400 shrink-0">P{f.page}</span>}
|
<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>
|
||||||
{f.value && <div className="text-[12px] text-slate-700 mt-1 leading-relaxed line-clamp-2">{f.value}</div>}
|
</div>
|
||||||
<div className="text-[10px] text-slate-400 mt-0.5">{f.pointName}</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);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
p.{f.page}
|
||||||
</button>
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="mt-0.5 text-[10px] text-slate-300">-</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -120,6 +249,7 @@ export function DetailPanel({
|
|||||||
onTabChange,
|
onTabChange,
|
||||||
activeReviewPoint,
|
activeReviewPoint,
|
||||||
reviewPoints,
|
reviewPoints,
|
||||||
|
detailMode = 'legacy',
|
||||||
fileInfo,
|
fileInfo,
|
||||||
reviewInfo,
|
reviewInfo,
|
||||||
onReviewPointSelect,
|
onReviewPointSelect,
|
||||||
@@ -186,6 +316,7 @@ export function DetailPanel({
|
|||||||
onReviewPointSelect={onReviewPointSelect}
|
onReviewPointSelect={onReviewPointSelect}
|
||||||
onStatusChange={onStatusChange}
|
onStatusChange={onStatusChange}
|
||||||
fileFormat={fileFormat}
|
fileFormat={fileFormat}
|
||||||
|
detailMode={detailMode}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -199,8 +330,8 @@ export function DetailPanel({
|
|||||||
{activeTab === 'fields' && (
|
{activeTab === 'fields' && (
|
||||||
<ExtractedFieldsPanel
|
<ExtractedFieldsPanel
|
||||||
reviewPoints={reviewPoints}
|
reviewPoints={reviewPoints}
|
||||||
onFieldClick={(pointId, page) => {
|
onFieldClick={(pointId, page, value, bboxHighlight) => {
|
||||||
onReviewPointSelect(pointId, page);
|
onReviewPointSelect(pointId, page, undefined, value, bboxHighlight);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -5,13 +5,17 @@
|
|||||||
import { useState, useEffect, useRef, useMemo } from 'react';
|
import { useState, useEffect, useRef, useMemo } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { toastService } from '~/components/ui/Toast';
|
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 {
|
interface ReviewPointDetailCardProps {
|
||||||
reviewPoint: ReviewPoint;
|
reviewPoint: ReviewPoint;
|
||||||
onReviewPointSelect: (id: string | number, page?: number, charPositions?: CharPosition[], value?: string) => void;
|
onReviewPointSelect: (id: string | number, page?: number, charPositions?: CharPosition[], value?: string, bboxHighlight?: PdfBboxHighlight) => void;
|
||||||
onStatusChange: (id: string | number, editAuditStatusId: string | number, status: string, message: string) => void;
|
onStatusChange: (id: string, editAuditStatusId: string | number, status: string, message: string) => void;
|
||||||
fileFormat?: string;
|
fileFormat?: string;
|
||||||
|
detailMode?: 'legacy' | 'leaudit';
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 比较方法映射 ──
|
// ── 比较方法映射 ──
|
||||||
@@ -33,6 +37,58 @@ const getRuleTypeText = (type?: string): string => {
|
|||||||
return ruleTypeMap[type] || type;
|
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 系统 ──
|
// ── Tooltip 系统 ──
|
||||||
let activeTooltip = { show: false, content: null as React.ReactNode, position: { top: 0, left: 0 }, ready: false };
|
let activeTooltip = { show: false, content: null as React.ReactNode, position: { top: 0, left: 0 }, ready: false };
|
||||||
function TooltipPortal() {
|
function TooltipPortal() {
|
||||||
@@ -223,7 +279,7 @@ function filterOtherRule(reviewPoint: ReviewPoint): MergedRule[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── renderOtherRule ──
|
// ── 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 fieldKey = rule.fieldKey;
|
||||||
const fieldValue = rule.fieldValue;
|
const fieldValue = rule.fieldValue;
|
||||||
const hasFailure = Object.values(fieldValue.type || {}).some(item => item.res === false);
|
const hasFailure = Object.values(fieldValue.type || {}).some(item => item.res === false);
|
||||||
@@ -273,7 +329,7 @@ function RenderOtherRule({ rule, reviewPoint, onReviewPointSelect }: { rule: Mer
|
|||||||
// ── renderConsistencyRule ──
|
// ── renderConsistencyRule ──
|
||||||
type ChainItem = { field: string; data: { key: string; page: number; value: string; char_positions?: CharPosition[] }; res: boolean; compareMethod?: string };
|
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 }) {
|
function RenderConsistencyRule({ rule, reviewPoint, onReviewPointSelect }: { rule: Record<string, unknown>; reviewPoint: ReviewPoint; onReviewPointSelect: (id: string | number, page?: number, charPos?: CharPosition[], value?: string, bboxHighlight?: PdfBboxHighlight) => void }) {
|
||||||
if (reviewPoint.result !== (rule.res as boolean)) return null;
|
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;
|
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;
|
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 ──
|
// ── 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 }) {
|
function RenderModelRule({ rule, reviewPoint, onReviewPointSelect, fileFormat }: { rule: Record<string, unknown>; 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<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;
|
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?.res !== reviewPoint.result) return null;
|
||||||
@@ -434,167 +490,685 @@ function RenderModelRule({ rule, reviewPoint, onReviewPointSelect, fileFormat }:
|
|||||||
return <>{fieldElements}</>;
|
return <>{fieldElements}</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function RenderGenericRule({
|
// ── Main Component ──
|
||||||
rule,
|
|
||||||
reviewPoint,
|
|
||||||
onReviewPointSelect,
|
function stringifyUnknown(value: unknown): string {
|
||||||
}: {
|
if (typeof value === 'string') return value;
|
||||||
rule: Record<string, unknown>;
|
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;
|
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<string, unknown>;
|
const [manualNote, setManualNote] = useState(() => getLeauditNote(reviewPoint));
|
||||||
const detail = (config.detail && typeof config.detail === 'object' ? config.detail : {}) as Record<string, unknown>;
|
const [corporateModalVisible, setCorporateModalVisible] = useState(false);
|
||||||
const fieldNames = Array.isArray(detail.fields)
|
const [corporateCompanyName, setCorporateCompanyName] = useState('');
|
||||||
? detail.fields.map((field) => String(field))
|
const [corporateLoading, setCorporateLoading] = useState(false);
|
||||||
: Array.isArray((config as any).fields)
|
const [corporateBusinessInfo, setCorporateBusinessInfo] = useState<BusinessInfoResult | null>(null);
|
||||||
? (config as any).fields.map((field: unknown) => String(field))
|
const [corporateDishonestyInfo, setCorporateDishonestyInfo] = useState<DishonestyResult | null>(null);
|
||||||
|
const [corporateError, setCorporateError] = useState<string | null>(null);
|
||||||
|
const [corporateUpdatedAt, setCorporateUpdatedAt] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setManualNote(getLeauditNote(reviewPoint));
|
||||||
|
}, [reviewPoint.id, reviewPoint.editAuditStatusMessage, reviewPoint.actionContent, reviewPoint.suggestion]);
|
||||||
|
|
||||||
|
const stages = Array.isArray(reviewPoint.evaluatedPointResultsLog?.stages)
|
||||||
|
? (reviewPoint.evaluatedPointResultsLog.stages as Array<Record<string, unknown>>)
|
||||||
: [];
|
: [];
|
||||||
const passed = typeof rule.res === 'boolean' ? rule.res : reviewPoint.result === true;
|
const missingItems = getLeauditMissingItems(reviewPoint);
|
||||||
const reasonCandidates = passed
|
const legalBasisList = Array.isArray(reviewPoint.legalBasis)
|
||||||
? [config.reason, detail.reason, reviewPoint.passMessage]
|
? reviewPoint.legalBasis
|
||||||
: [config.reason, detail.reason, reviewPoint.failMessage, reviewPoint.suggestion];
|
: reviewPoint.legalBasis?.articles?.map(item => typeof item === 'string' ? item : (item.name || item.content || stringifyUnknown(item))) || [];
|
||||||
const reason = reasonCandidates.find((item) => typeof item === 'string' && item.trim()) as string | undefined;
|
const riskLabelMap: Record<string, { cls: string; label: string }> = {
|
||||||
const checkType = typeof config.check_type === 'string' ? config.check_type : '';
|
high: { cls: 'bg-red-50 text-red-700 border-red-200', label: '高风险' },
|
||||||
const primitiveType = typeof config.primitive_type === 'string' ? config.primitive_type : '';
|
medium: { cls: 'bg-amber-50 text-amber-700 border-amber-200', label: '中风险' },
|
||||||
const badgeText = checkType || primitiveType || '规则检查';
|
low: { cls: 'bg-emerald-50 text-emerald-700 border-emerald-200', label: '低风险' },
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
return {
|
|
||||||
fieldData,
|
|
||||||
normalizedPage: hasPage ? normalizedPage : undefined,
|
|
||||||
normalizedValue,
|
|
||||||
canLocate: hasPage || normalizedValue.length > 0,
|
|
||||||
};
|
};
|
||||||
|
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 jumpToField = (fieldName: string) => {
|
const handleCorporateInfoClick = async (companyName: string, forceRefresh = false) => {
|
||||||
const { fieldData, normalizedPage, normalizedValue } = getFieldLocatorState(fieldName);
|
if (!companyName) {
|
||||||
if (normalizedPage) {
|
toastService.warning('企业名称为空,无法查询');
|
||||||
onReviewPointSelect(
|
|
||||||
reviewPoint.id,
|
|
||||||
normalizedPage,
|
|
||||||
fieldData?.char_positions,
|
|
||||||
normalizedValue || undefined,
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (normalizedValue) {
|
setCorporateModalVisible(true);
|
||||||
onReviewPointSelect(
|
setCorporateCompanyName(companyName);
|
||||||
reviewPoint.id,
|
setCorporateLoading(true);
|
||||||
undefined,
|
setCorporateError(null);
|
||||||
fieldData?.char_positions,
|
setCorporateBusinessInfo(null);
|
||||||
normalizedValue,
|
setCorporateDishonestyInfo(null);
|
||||||
);
|
setCorporateUpdatedAt(null);
|
||||||
toastService.info(`${fieldName} 当前没有页码,已改为按文本定位`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
toastService.info(`${fieldName} 当前既没有页码,也没有可定位文本`);
|
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);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const handleCorporateForceRefresh = async () => {
|
||||||
<div className={`mb-3 rounded-md border ${passed ? 'border-emerald-200 bg-emerald-50/60' : 'border-amber-200 bg-amber-50/70'} p-3`}>
|
if (corporateCompanyName) {
|
||||||
<div className="flex items-center justify-between gap-2">
|
await handleCorporateInfoClick(corporateCompanyName, true);
|
||||||
<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 && (
|
const handleCloseCorporateModal = () => {
|
||||||
<div className="mt-2 text-[12px] leading-5 text-slate-700">
|
setCorporateModalVisible(false);
|
||||||
{reason}
|
setCorporateCompanyName('');
|
||||||
</div>
|
setCorporateBusinessInfo(null);
|
||||||
)}
|
setCorporateDishonestyInfo(null);
|
||||||
|
setCorporateError(null);
|
||||||
{fieldNames.length > 0 && (
|
setCorporateUpdatedAt(null);
|
||||||
<div className="mt-3 flex flex-wrap gap-2">
|
};
|
||||||
{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);
|
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<button
|
<button
|
||||||
key={fieldName}
|
key={fieldKey}
|
||||||
type="button"
|
type="button"
|
||||||
className={`min-w-0 flex-1 rounded border px-2.5 py-2 text-left ${
|
className={`w-full border rounded-md text-left transition ${page ? 'hover:bg-[#f6ffed] hover:border-[#b7eb8f]' : 'opacity-90'} border-slate-200 bg-slate-50 field-btn`}
|
||||||
canLocate
|
onClick={() => {
|
||||||
? 'border-slate-200 bg-white hover:border-[#00684a] hover:bg-[#f6fffb]'
|
if (page) onReviewPointSelect(reviewPoint.id, page, undefined, fieldValue, bboxHighlight);
|
||||||
: 'border-slate-200 bg-slate-50 text-slate-400 cursor-not-allowed'
|
}}
|
||||||
}`}
|
|
||||||
onClick={() => canLocate && jumpToField(fieldName)}
|
|
||||||
disabled={!canLocate}
|
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between gap-2 text-[11px] font-medium">
|
<div className="p-2.5 flex-1 min-w-0">
|
||||||
<span className={canLocate ? 'text-slate-500' : 'text-slate-400'}>{fieldName}</span>
|
<div className="flex items-center justify-between gap-2">
|
||||||
{normalizedPage ? (
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<span className="rounded bg-emerald-50 px-1.5 py-0.5 text-[10px] text-emerald-700">
|
<div className="inline-flex items-center text-[11px] text-slate-500 truncate font-medium">{fieldKey}</div>
|
||||||
第 {normalizedPage} 页
|
{enterpriseButton && enterpriseButton}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
{page && <span className="text-[10.5px] text-slate-400 shrink-0">P{page}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="text-[12px] text-slate-700 mt-1 leading-relaxed whitespace-pre-wrap break-words select-text cursor-text"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{fieldValue}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* <div className="w-8 shrink-0 flex items-center justify-center border-l border-slate-200">
|
||||||
|
<i className="ri-focus-3-line text-[#00684a] text-[16px]" />
|
||||||
|
</div> */}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderEnterpriseInfoButton = (label: string, companyName: string) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`inline-flex items-center gap-1 h-5 px-1.5 rounded border text-[10.5px] transition-colors flex-shrink-0 ${
|
||||||
|
companyName
|
||||||
|
? 'bg-[#00684a] text-white border-[#00684a] hover:bg-[#005a3f] hover:border-[#005a3f]'
|
||||||
|
: 'bg-slate-100 text-slate-400 border-slate-200 cursor-not-allowed'
|
||||||
|
}`}
|
||||||
|
disabled={!companyName}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (companyName) {
|
||||||
|
void handleCorporateInfoClick(companyName);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<i className="ri-building-4-line text-[11px]" />
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderStageContent = (stage: Record<string, unknown>, index: number) => {
|
||||||
|
const detail = (stage.detail || {}) as Record<string, unknown>;
|
||||||
|
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<string, string> = {
|
||||||
|
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 },
|
||||||
|
) => (
|
||||||
|
<div className="px-2.5 py-2 flex items-start justify-between gap-3 border-t border-slate-100">
|
||||||
|
<div className="text-[11px] text-slate-500 shrink-0">{label}</div>
|
||||||
|
<div className={`text-[11px] text-slate-700 text-left break-words whitespace-pre-wrap max-w-[72%] ml-auto ${options?.mono ? 'font-mono' : ''} ${options?.valueClassName || ''}`}>
|
||||||
|
{getStageDisplayValue(value)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div key={`stage-${index}`} className={`border rounded-md p-3 ${stageCardClass}`}>
|
||||||
|
<div className="flex items-center justify-between gap-2 mb-2">
|
||||||
|
<div className="text-[11px] font-medium text-slate-500 uppercase tracking-wider">{stageDisplayName}</div>
|
||||||
|
<span className={`text-[10.5px] inline-flex items-center gap-1 ${stageBadgeClass}`}>
|
||||||
|
<i className={passed ? 'ri-checkbox-circle-fill' : 'ri-error-warning-fill'} />{passed ? '通过' : '缺失'}
|
||||||
</span>
|
</span>
|
||||||
) : normalizedValue ? (
|
</div>
|
||||||
<span className="rounded bg-amber-50 px-1.5 py-0.5 text-[10px] text-amber-700">
|
{fields.length > 0 && <div className="text-[12px] text-slate-700">{`命中字段:${fields.join('、')}`}</div>}
|
||||||
按文本定位
|
{missing.length > 0 && <div className="text-[12px] text-amber-700 mt-1">{`缺失字段:${missing.join('、')}`}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (checkType === 'match') {
|
||||||
|
const failures = Array.isArray(detail.failures)
|
||||||
|
? detail.failures.map(item => item as Record<string, unknown>)
|
||||||
|
: [];
|
||||||
|
return (
|
||||||
|
<div key={`stage-${index}`} className={`border rounded-md p-3 ${stageCardClass}`}>
|
||||||
|
<div className="flex items-center justify-between gap-2 mb-2">
|
||||||
|
<div className="text-[11px] font-medium text-slate-500 uppercase tracking-wider">{stageDisplayName}</div>
|
||||||
|
<span className={`text-[10.5px] inline-flex items-center gap-1 ${stageBadgeClass}`}>
|
||||||
|
<i className={passed ? 'ri-checkbox-circle-fill' : 'ri-error-warning-fill'} />{passed ? '通过' : '不一致'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{failures.length > 0 ? (
|
||||||
|
<div className="space-y-2 mt-2">
|
||||||
|
{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 (
|
||||||
|
<div key={`failure-${index}-${failureIndex}`} className="border border-red-200 rounded-md bg-white/90 overflow-hidden">
|
||||||
|
<div className="px-3 py-1 flex items-center justify-between gap-2 border-b border-red-100 bg-red-50/70">
|
||||||
|
<div className="text-[11px] text-slate-500">{`差异项 ${failureIndex + 1}`}</div>
|
||||||
|
</div>
|
||||||
|
<div className="py-1">
|
||||||
|
<div className="px-2.5 py-1.5 flex items-start justify-between gap-3">
|
||||||
|
<div className="text-[11px] text-slate-500 max-w-[46%] basis-[46%] break-words whitespace-normal">{leftField}</div>
|
||||||
|
{failure.a_value == null ? (
|
||||||
|
<span className="text-[11px] text-red-600 inline-flex items-center justify-start gap-0.5 font-medium max-w-[46%] basis-[46%] whitespace-normal break-words text-left ml-auto">
|
||||||
|
<i className="ri-prohibited-line" />
|
||||||
|
{'未填写'}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="rounded bg-slate-100 px-1.5 py-0.5 text-[10px] text-slate-500">
|
<div className={`text-[11px] text-slate-800 max-w-[46%] basis-[46%] text-left break-words whitespace-normal ml-auto ${/^\d/.test(leftValue) ? 'font-mono' : ''}`}>
|
||||||
无定位信息
|
{leftValue}
|
||||||
</span>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 text-[12px] leading-5 text-slate-700 break-all">{displayValue}</div>
|
<div className="px-2.5 py-1.5 flex items-start justify-between gap-3 border-t border-slate-100">
|
||||||
</button>
|
<div className="text-[11px] text-slate-500 max-w-[46%] basis-[46%] break-words whitespace-normal">{rightField}</div>
|
||||||
|
{failure.b_value == null ? (
|
||||||
|
<span className="text-[11px] text-red-600 inline-flex items-center justify-start gap-0.5 font-medium max-w-[46%] basis-[46%] whitespace-normal break-words text-left ml-auto">
|
||||||
|
<i className="ri-prohibited-line" />
|
||||||
|
{'未填写'}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<div className={`text-[11px] text-slate-800 max-w-[46%] basis-[46%] text-left break-words whitespace-normal ml-auto ${/^\d/.test(rightValue) ? 'font-mono' : ''}`}>
|
||||||
|
{rightValue}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-[12px] text-slate-600">{'未发现不一致项'}</div>
|
||||||
)}
|
)}
|
||||||
</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') {
|
if (checkType === 'compare') {
|
||||||
return reviewPoint.actionContent;
|
const opMap: Record<string, string> = {
|
||||||
}
|
'>=': '≥', '<=': '≤', '!=': '≠', '<>': '≠', '==': '=', '>': '>', '<': '<', '=': '=',
|
||||||
|
};
|
||||||
|
const displayOp = opMap[String(detail.op)] || String(detail.op);
|
||||||
|
|
||||||
if (reviewPoint.suggestion) {
|
const fmtNum = (v: unknown) => {
|
||||||
return reviewPoint.suggestion;
|
if (v == null || String(v).trim() === '') return getStageDisplayValue(v);
|
||||||
}
|
const n = Number(v);
|
||||||
|
return !isNaN(n) ? n.toLocaleString('zh-CN') : getStageDisplayValue(v);
|
||||||
return '';
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const [manualNote, setManualNote] = useState(resolveManualNote);
|
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 (
|
||||||
|
<div key={`stage-${index}`} className={`border rounded-md p-3 ${stageCardClass}`}>
|
||||||
|
<div className="flex items-center justify-between gap-2 mb-2">
|
||||||
|
<div className="text-[11px] font-medium text-slate-500 uppercase tracking-wider">{stageDisplayName}</div>
|
||||||
|
<span className={`text-[10.5px] inline-flex items-center gap-1 ${stageBadgeClass}`}>
|
||||||
|
<i className={passed ? 'ri-checkbox-circle-fill' : 'ri-close-circle-fill'} />
|
||||||
|
{passed ? '通过' : '未通过'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={`text-[12px] font-mono ${passed ? 'text-slate-800' : 'text-red-800'}`}>
|
||||||
|
{leftOperand} <span className="mx-1 font-bold">{displayOp}</span> {rightDisplay}
|
||||||
|
</div>
|
||||||
|
{stageReason && (
|
||||||
|
<div className="text-[11px] text-slate-500 mt-1">{stageReason}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (checkType === 'ai') {
|
||||||
|
const response = (detail.response || {}) as Record<string, unknown>;
|
||||||
|
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 (
|
||||||
|
<section key={`stage-${index}`} className="px-0 pt-0">
|
||||||
|
<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-3 border rounded-md space-y-3 ${passed ? 'bg-emerald-50/70 border-emerald-200' : 'bg-fuchsia-50/60 border-fuchsia-200'}`}>
|
||||||
|
{reasonText && (
|
||||||
|
<div className="flex gap-2 text-[12.5px] text-slate-700 leading-relaxed">
|
||||||
|
<i className={`${passed ? 'ri-checkbox-circle-line text-emerald-500' : 'ri-error-warning-line text-fuchsia-500'} shrink-0 mt-0.5`} />
|
||||||
|
<div className="whitespace-pre-wrap break-words">{reasonText}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{strengthItems.length > 0 && (
|
||||||
|
<div className={`pt-2 border-t ${dividerClass}`}>
|
||||||
|
<div className="flex items-center gap-1 text-[11px] font-medium text-emerald-700 mb-1.5">
|
||||||
|
<i className="ri-medal-line" />
|
||||||
|
{'亮点'}
|
||||||
|
<span className="font-mono text-[10.5px] text-emerald-500">{strengthItems.length}</span>
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-1 text-[12px] text-slate-700">
|
||||||
|
{strengthItems.map((item, itemIndex) => (
|
||||||
|
<li key={`strength-${index}-${itemIndex}`} className="flex gap-1.5">
|
||||||
|
<i className="ri-check-line text-emerald-500 mt-[1px] shrink-0" />
|
||||||
|
<span className="break-words">{item}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{suggestionItems.length > 0 && (
|
||||||
|
<div className={`pt-2 border-t ${dividerClass}`}>
|
||||||
|
<div className="flex items-center gap-1 text-[11px] font-medium text-fuchsia-700 mb-1.5">
|
||||||
|
<i className="ri-edit-2-line" />
|
||||||
|
{'修改建议'}
|
||||||
|
<span className="font-mono text-[10.5px] text-fuchsia-500">{suggestionItems.length}</span>
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-1 text-[12px] text-slate-700">
|
||||||
|
{suggestionItems.map((item, itemIndex) => (
|
||||||
|
<li key={`suggestion-${index}-${itemIndex}`} className="flex gap-1.5">
|
||||||
|
<span className="text-fuchsia-400 shrink-0">{'•'}</span>
|
||||||
|
<span className="break-words">{item}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={`stage-${index}`} className={`border rounded-md p-3 ${stageCardClass}`}>
|
||||||
|
<div className="flex items-center justify-between gap-2 mb-2">
|
||||||
|
<div className="text-[11px] font-medium text-slate-500 uppercase tracking-wider">
|
||||||
|
{stageDisplayName}
|
||||||
|
</div>
|
||||||
|
{hasPassedState && (
|
||||||
|
<span className={`inline-flex items-center gap-1 px-1.5 h-5 rounded text-[10.5px] border ${
|
||||||
|
passed
|
||||||
|
? 'bg-emerald-50 text-emerald-700 border-emerald-200'
|
||||||
|
: 'bg-red-50 text-red-700 border-red-200'
|
||||||
|
}`}>
|
||||||
|
<i className={passed ? 'ri-checkbox-circle-fill' : 'ri-close-circle-fill'} />
|
||||||
|
{passed ? '通过' : '未通过'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border border-slate-200 bg-white/80 overflow-hidden">
|
||||||
|
{/* {renderStageInfoRow('阶段类型', stageDisplayName)} */}
|
||||||
|
{/* {hasPassedState && renderStageInfoRow('结果', passed ? '通过' : '未通过', { valueClassName: passed ? 'text-emerald-700' : 'text-red-700' })} */}
|
||||||
|
{stageReason && renderStageInfoRow('原因', stageReason)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<article className="bg-white border border-slate-200 rounded-lg shadow-sm overflow-hidden">
|
||||||
|
<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 gap-3 flex-wrap">
|
||||||
|
<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>
|
||||||
|
<span className={`inline-flex items-center gap-1 px-1.5 h-5 rounded text-[10.5px] border ${riskMeta.cls}`}>
|
||||||
|
<i className="ri-focus-3-line" />{riskMeta.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>
|
||||||
|
<div className="flex items-center gap-3 text-[11px] text-slate-500">
|
||||||
|
{reviewPoint.score != null && <span>得分 <span className="font-mono text-slate-700">{reviewPoint.finalScore ?? reviewPoint.machineScore ?? reviewPoint.score}/{reviewPoint.score}</span></span>}
|
||||||
|
{confidencePct && <span>置信度 <span className="font-mono text-slate-700">{confidencePct}</span></span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{Object.keys(reviewPoint.content || {}).length > 0 && (
|
||||||
|
<section className="px-4 py-2">
|
||||||
|
<div className="text-[11px] font-medium text-slate-400 uppercase tracking-wider mb-2">
|
||||||
|
命中字段 <span className="font-mono normal-case text-[10.5px]">{Object.keys(reviewPoint.content).length}</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{Object.entries(reviewPoint.content).map(([key, value]) => renderFieldCard(key, getLeauditFieldText(value)))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{missingItems.length > 0 && (
|
||||||
|
<section className="px-4 py-2">
|
||||||
|
<div className="text-[11px] font-medium text-slate-400 uppercase tracking-wider mb-2">
|
||||||
|
缺失项 <span className="font-mono normal-case text-[10.5px]">{missingItems.length}</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{missingItems.map(item => (
|
||||||
|
<div key={item} className="w-full border border-red-200 rounded-md bg-red-50/60">
|
||||||
|
<div className="p-2.5 flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0 text-[12px] text-slate-700 leading-relaxed break-words">{item}</div>
|
||||||
|
<span className="text-[10.5px] text-red-600 shrink-0">缺失</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{stages.length > 0 && (
|
||||||
|
<section className="px-4 py-2">
|
||||||
|
<div className="text-[11px] font-medium text-slate-400 uppercase tracking-wider mb-2">
|
||||||
|
阶段结果 <span className="font-mono normal-case text-[10.5px]">{stages.length}</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{stages.map((stage, index) => renderStageContent(stage, index))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isWarning && (
|
||||||
|
<section className="px-4 py-2">
|
||||||
|
<div className="text-[11px] font-medium text-slate-400 uppercase tracking-wider mb-2 flex items-center gap-1">
|
||||||
|
<i className={isPass ? 'ri-shield-check-line text-emerald-500 text-[12px]' : 'ri-close-circle-line text-red-500 text-[12px]'} />
|
||||||
|
{isPass ? '校验结果' : '问题说明'}
|
||||||
|
</div>
|
||||||
|
<div className={`p-3 rounded-md border text-[12.5px] leading-relaxed ${isPass ? 'bg-emerald-50/60 border-emerald-200 text-slate-700' : 'bg-red-50/60 border-red-200 text-slate-700'}`}>
|
||||||
|
{summaryText}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{legalBasisList.length > 0 && (
|
||||||
|
<section className="px-4 py-2">
|
||||||
|
<div className="text-[11px] font-medium text-slate-400 uppercase tracking-wider mb-2">法律依据</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{legalBasisList.map((item, index) => (
|
||||||
|
<span key={`${item}-${index}`} className="bg-[#e6f4ff] border border-[#91caff] rounded-full px-2 py-0.5 text-xs text-[#0958d9]">
|
||||||
|
{item}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{reviewPoint.postAction === 'manual' && (
|
||||||
|
<section className="px-4 py-2">
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{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>
|
||||||
|
|
||||||
|
<CorporateInfoModal
|
||||||
|
visible={corporateModalVisible}
|
||||||
|
onClose={handleCloseCorporateModal}
|
||||||
|
companyName={corporateCompanyName}
|
||||||
|
businessInfo={corporateBusinessInfo}
|
||||||
|
dishonestyInfo={corporateDishonestyInfo}
|
||||||
|
businessLoading={corporateLoading}
|
||||||
|
dishonestyLoading={corporateLoading}
|
||||||
|
businessError={corporateError}
|
||||||
|
dishonestyError={corporateError}
|
||||||
|
updatedAt={corporateUpdatedAt}
|
||||||
|
onForceRefresh={handleCorporateForceRefresh}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LegacyReviewPointDetailCard({ reviewPoint, onReviewPointSelect, onStatusChange, fileFormat }: ReviewPointDetailCardProps) {
|
||||||
|
const [manualNote, setManualNote] = useState(
|
||||||
|
() => reviewPoint.editAuditStatusMessage || normalizeActionContent(reviewPoint.actionContent) || reviewPoint.suggestion || ''
|
||||||
|
);
|
||||||
|
|
||||||
// reviewPoint 切换时重置默认值
|
// reviewPoint 切换时重置默认值
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setManualNote(resolveManualNote());
|
setManualNote(reviewPoint.editAuditStatusMessage || normalizeActionContent(reviewPoint.actionContent) || reviewPoint.suggestion || '');
|
||||||
}, [reviewPoint.id, reviewPoint.editAuditStatusMessage, reviewPoint.actionContent, reviewPoint.suggestion]);
|
}, [reviewPoint.id, reviewPoint.editAuditStatusMessage, reviewPoint.actionContent, reviewPoint.suggestion]);
|
||||||
|
|
||||||
const otherRules = filterOtherRule(reviewPoint);
|
const otherRules = filterOtherRule(reviewPoint);
|
||||||
@@ -657,7 +1231,7 @@ export function ReviewPointDetailCard({ reviewPoint, onReviewPointSelect, onStat
|
|||||||
if (rule.type === 'ai') {
|
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 <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} />;
|
return null;
|
||||||
})}
|
})}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -727,3 +1301,17 @@ export function ReviewPointDetailCard({ reviewPoint, onReviewPointSelect, onStat
|
|||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ReviewPointDetailCard(props: ReviewPointDetailCardProps) {
|
||||||
|
if (props.detailMode === 'leaudit') {
|
||||||
|
return (
|
||||||
|
<LeauditReviewPointDetailCard
|
||||||
|
reviewPoint={props.reviewPoint}
|
||||||
|
onReviewPointSelect={props.onReviewPointSelect}
|
||||||
|
onStatusChange={props.onStatusChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <LegacyReviewPointDetailCard {...props} />;
|
||||||
|
}
|
||||||
|
|||||||
+278
-148
@@ -45,8 +45,7 @@ import { PdfPreviewTest } from "~/components/reviews/previewComponents/PdfPrevie
|
|||||||
import { DocxPreviewTest } from "~/components/reviews/previewComponents/DocxPreviewTest";
|
import { DocxPreviewTest } from "~/components/reviews/previewComponents/DocxPreviewTest";
|
||||||
import { ComparePreview } from "~/components/reviews/previewComponents/ComparePreview";
|
import { ComparePreview } from "~/components/reviews/previewComponents/ComparePreview";
|
||||||
|
|
||||||
import { type ReviewPoint } from '~/components/reviews';
|
import { type ReviewPoint, type PdfBboxHighlight } from '~/components/reviews';
|
||||||
import { messageService } from "~/components/ui/MessageModal";
|
|
||||||
import { loadingBarService } from "~/components/ui/LoadingBar";
|
import { loadingBarService } from "~/components/ui/LoadingBar";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -155,6 +154,8 @@ interface ReviewData {
|
|||||||
aiAnalysis: AnalysisData;
|
aiAnalysis: AnalysisData;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PreviewKind = 'pdf' | 'docx';
|
||||||
|
|
||||||
type PreviewDocument = {
|
type PreviewDocument = {
|
||||||
path?: string;
|
path?: string;
|
||||||
attachments?: Array<{
|
attachments?: Array<{
|
||||||
@@ -163,10 +164,14 @@ type PreviewDocument = {
|
|||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface DefaultPreviewTarget {
|
||||||
|
page?: number;
|
||||||
|
highlightValue?: string;
|
||||||
|
bboxHighlight?: PdfBboxHighlight;
|
||||||
|
}
|
||||||
|
|
||||||
function resolvePreviewPath(document: PreviewDocument | null | undefined): string {
|
function resolvePreviewPath(document: PreviewDocument | null | undefined): string {
|
||||||
if (document?.path) {
|
if (document?.path) return document.path;
|
||||||
return document.path;
|
|
||||||
}
|
|
||||||
|
|
||||||
const primaryAttachment = Array.isArray(document?.attachments)
|
const primaryAttachment = Array.isArray(document?.attachments)
|
||||||
? document.attachments.find((item) => item?.fileRole === 'primary' && item?.ossUrl)
|
? document.attachments.find((item) => item?.fileRole === 'primary' && item?.ossUrl)
|
||||||
@@ -181,37 +186,124 @@ function resolvePreviewExtension(document: PreviewDocument | null | undefined):
|
|||||||
return typeof suffix === 'string' ? suffix.toLowerCase() : '';
|
return typeof suffix === 'string' ? suffix.toLowerCase() : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildReviewData(document: any, reviewPoints: ReviewPoint[], statistics: Statistics, reviewInfo: ReviewInfo): ReviewData | null {
|
function isValidQuad(value: unknown): value is [number, number, number, number] {
|
||||||
if (!document) {
|
return Array.isArray(value) && value.length === 4 && value.every(item => typeof item === 'number' && Number.isFinite(item));
|
||||||
return null;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const mockData = getMockReviewData();
|
function hasNonZeroQuad(value: [number, number, number, number]): boolean {
|
||||||
const typeValue = document.type ?? document.type_id;
|
return value.some(item => item !== 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getReviewPointContentText(value: unknown): string | undefined {
|
||||||
|
if (value == null) return undefined;
|
||||||
|
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
||||||
|
const text = String(value).trim();
|
||||||
|
return text || undefined;
|
||||||
|
}
|
||||||
|
if (typeof value === 'object' && value && 'value' in value) {
|
||||||
|
return getReviewPointContentText((value as { value?: unknown }).value);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getReviewPointFieldPage(point: ReviewPoint, fieldKey: string, rawValue: unknown): number | undefined {
|
||||||
|
const contentPage = point.contentPage?.[fieldKey];
|
||||||
|
const normalizedContentPage = Number(contentPage);
|
||||||
|
if (Number.isFinite(normalizedContentPage) && normalizedContentPage > 0) return normalizedContentPage;
|
||||||
|
|
||||||
|
const inlinePage = typeof rawValue === 'object' && rawValue && 'page' in rawValue
|
||||||
|
? Number((rawValue as { page?: unknown }).page)
|
||||||
|
: NaN;
|
||||||
|
if (Number.isFinite(inlinePage) && inlinePage > 0) return inlinePage;
|
||||||
|
|
||||||
|
const pageNum = point.fieldPositions?.[fieldKey]?.page_num;
|
||||||
|
if (typeof pageNum === 'number' && Number.isFinite(pageNum) && pageNum >= 0) return pageNum + 1;
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getReviewPointFieldBbox(point: ReviewPoint, fieldKey: string, page?: number): PdfBboxHighlight | undefined {
|
||||||
|
const fieldPosition = point.fieldPositions?.[fieldKey];
|
||||||
|
if (!fieldPosition || !isValidQuad(fieldPosition.bbox) || !isValidQuad(fieldPosition.page_box)) return undefined;
|
||||||
|
if (!hasNonZeroQuad(fieldPosition.bbox) || !hasNonZeroQuad(fieldPosition.page_box)) return undefined;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
fileInfo: {
|
fieldKey,
|
||||||
fileName: document.name || "未知文件名",
|
bbox: [...fieldPosition.bbox],
|
||||||
path: document.path || "未知路径",
|
pageBox: [...fieldPosition.page_box],
|
||||||
contractNumber: document.documentNumber || document.document_number || "未知编号",
|
pageNum: fieldPosition.page_num,
|
||||||
fileSize: document.size ? formatFileSize(document.size) : document.file_size ? formatFileSize(document.file_size) : "未知大小",
|
page,
|
||||||
fileFormat: document.fileType ? document.fileType.toUpperCase() : "未知格式",
|
confidence: fieldPosition.confidence,
|
||||||
pageCount: document.pageCount || document.page_count || 0,
|
matchMethod: fieldPosition.match_method,
|
||||||
uploadTime: document.uploadTime || document.created_at || "未知时间",
|
|
||||||
uploadUser: document.uploadUser || "未知用户",
|
|
||||||
auditStatus: document.auditStatus || 0,
|
|
||||||
legalBasis: document.legalBasis || {},
|
|
||||||
fileType: typeValue !== undefined && typeValue !== null ? String(typeValue) : ""
|
|
||||||
},
|
|
||||||
contractInfo: mockData.contractInfo,
|
|
||||||
reviewInfo,
|
|
||||||
statistics,
|
|
||||||
fileContent: mockData.fileContent,
|
|
||||||
reviewPoints,
|
|
||||||
aiAnalysis: mockData.aiAnalysis,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveDefaultPreviewTarget(point: ReviewPoint, previewKind: PreviewKind): DefaultPreviewTarget {
|
||||||
|
let firstPageCandidate: DefaultPreviewTarget | undefined;
|
||||||
|
let firstPageWithBboxCandidate: DefaultPreviewTarget | undefined;
|
||||||
|
let firstPageWithTextCandidate: DefaultPreviewTarget | undefined;
|
||||||
|
|
||||||
|
for (const [fieldKey, rawValue] of Object.entries(point.content || {})) {
|
||||||
|
const page = getReviewPointFieldPage(point, fieldKey, rawValue);
|
||||||
|
if (!page) continue;
|
||||||
|
|
||||||
|
const highlightValue = getReviewPointContentText(rawValue);
|
||||||
|
const bboxHighlight = getReviewPointFieldBbox(point, fieldKey, page);
|
||||||
|
const candidate: DefaultPreviewTarget = { page, highlightValue, bboxHighlight };
|
||||||
|
|
||||||
|
if (!firstPageCandidate) firstPageCandidate = candidate;
|
||||||
|
if (!firstPageWithBboxCandidate && bboxHighlight) firstPageWithBboxCandidate = candidate;
|
||||||
|
if (!firstPageWithTextCandidate && highlightValue) firstPageWithTextCandidate = candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (previewKind === 'pdf') {
|
||||||
|
return firstPageWithBboxCandidate || firstPageCandidate || {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return firstPageWithTextCandidate || firstPageCandidate || {};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReviewsTestLoaderSuccess {
|
||||||
|
previousRoute: string;
|
||||||
|
document: any;
|
||||||
|
reviewPoints: ReviewPoint[];
|
||||||
|
reviewInfo: ReviewInfo;
|
||||||
|
statistics: Statistics;
|
||||||
|
comparison_document: any;
|
||||||
|
userInfo: { sub: string; nick_name: string } | null;
|
||||||
|
frontendJWT: string | null;
|
||||||
|
flowType: 'legacy' | 'leaudit';
|
||||||
|
detailMode: 'legacy' | 'leaudit';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReviewsTestLoaderError {
|
||||||
|
result: false;
|
||||||
|
message: string;
|
||||||
|
previousRoute: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReviewsTestLoaderData = ReviewsTestLoaderSuccess | ReviewsTestLoaderError;
|
||||||
|
|
||||||
|
const EMPTY_STATISTICS: Statistics = {
|
||||||
|
total: 0,
|
||||||
|
success: 0,
|
||||||
|
warning: 0,
|
||||||
|
error: 0,
|
||||||
|
notApplicable: 0,
|
||||||
|
score: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const EMPTY_REVIEW_INFO: ReviewInfo = {
|
||||||
|
reviewTime: '',
|
||||||
|
reviewModel: '',
|
||||||
|
ruleGroup: '',
|
||||||
|
result: '',
|
||||||
|
issueCount: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
function isReviewsTestLoaderError(data: ReviewsTestLoaderData): data is ReviewsTestLoaderError {
|
||||||
|
return 'result' in data && data.result === false;
|
||||||
|
}
|
||||||
|
|
||||||
export const meta: MetaFunction = () => {
|
export const meta: MetaFunction = () => {
|
||||||
return [
|
return [
|
||||||
@@ -233,49 +325,53 @@ export const handle = {
|
|||||||
noPadding: true
|
noPadding: true
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function loader({ request }: LoaderFunctionArgs) {
|
export async function loader({ request }: LoaderFunctionArgs): Promise<Response> {
|
||||||
try {
|
try {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const id = url.searchParams.get('id') || undefined;
|
const id = url.searchParams.get('id') || '';
|
||||||
const previousRoute = url.searchParams.get('previousRoute') || '';
|
const previousRoute = url.searchParams.get('previousRoute') || '';
|
||||||
// console.log("[Reviews Loader] 开始加载,id:", id, "previousRoute:", previousRoute);
|
|
||||||
|
|
||||||
if (!id) {
|
if (!id) {
|
||||||
console.error("[Reviews Loader] 文件ID不能为空");
|
return Response.json({ result: false, message: '文件ID不能为空', previousRoute });
|
||||||
return Response.json({ result: false, message: '文件ID不能为空' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取用户会话信息
|
|
||||||
const { getUserSession } = await import("~/api/login/auth.server");
|
const { getUserSession } = await import("~/api/login/auth.server");
|
||||||
const { userInfo, frontendJWT } = await getUserSession(request);
|
const { userInfo, frontendJWT } = await getUserSession(request);
|
||||||
// reviewsTest 正式链路统一只走新后端聚合接口,避免旧统一接口 404 噪音。
|
|
||||||
const reviewData = await getReviewPoints_fromApi(id, request);
|
const reviewData = await getReviewPoints_fromApi(id, request);
|
||||||
|
|
||||||
if ('error' in reviewData && reviewData.error) {
|
if ('error' in reviewData && reviewData.error) {
|
||||||
console.error("[Reviews Loader] 获取评查点数据错误:", reviewData.error);
|
return Response.json({
|
||||||
return Response.json({ result: false, message: reviewData.error });
|
result: false,
|
||||||
|
message: reviewData.error,
|
||||||
|
previousRoute,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return Response.json({
|
return Response.json({
|
||||||
previousRoute: previousRoute,
|
previousRoute,
|
||||||
document: reviewData.document,
|
document: reviewData.document,
|
||||||
reviewPoints: reviewData.data,
|
reviewPoints: reviewData.data,
|
||||||
reviewInfo: reviewData.reviewInfo,
|
reviewInfo: reviewData.reviewInfo,
|
||||||
statistics: reviewData.stats,
|
statistics: { ...reviewData.stats, notApplicable: reviewData.stats?.notApplicable ?? 0 },
|
||||||
comparison_document: reviewData.comparison_document,
|
comparison_document: reviewData.comparison_document,
|
||||||
userInfo,
|
userInfo:
|
||||||
frontendJWT,
|
userInfo?.sub && userInfo?.nick_name
|
||||||
flowType: 'legacy',
|
? { sub: userInfo.sub, nick_name: userInfo.nick_name }
|
||||||
scoredResults: null,
|
: null,
|
||||||
scoredSummary: null
|
frontendJWT: frontendJWT ?? null,
|
||||||
|
flowType: 'leaudit',
|
||||||
|
detailMode: 'leaudit',
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Reviews Loader] 获取评查数据失败:', error);
|
console.error('[reviewsTest loader] Failed to load review data:', error);
|
||||||
console.error('[Reviews Loader] 错误堆栈:', error instanceof Error ? error.stack : '无堆栈信息');
|
return Response.json({
|
||||||
return Response.json({ result: false, message: `获取评查数据失败: ${error instanceof Error ? error.message : '未知错误'}` });
|
result: false,
|
||||||
|
message: `获取评查数据失败: ${error instanceof Error ? error.message : '未知错误'}`,
|
||||||
|
previousRoute: '',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 添加 action 函数处理需要用户认证的操作
|
|
||||||
export async function action({ request }: ActionFunctionArgs) {
|
export async function action({ request }: ActionFunctionArgs) {
|
||||||
try {
|
try {
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
@@ -346,36 +442,27 @@ export async function action({ request }: ActionFunctionArgs) {
|
|||||||
|
|
||||||
export default function ReviewDetails() {
|
export default function ReviewDetails() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const loaderData = useLoaderData<typeof loader>();
|
const loaderData = useLoaderData<ReviewsTestLoaderData>();
|
||||||
const normalizedLoaderData =
|
|
||||||
loaderData &&
|
|
||||||
typeof loaderData === 'object' &&
|
|
||||||
'reviewPoints' in loaderData &&
|
|
||||||
loaderData.reviewPoints &&
|
|
||||||
typeof loaderData.reviewPoints === 'object' &&
|
|
||||||
'data' in loaderData.reviewPoints &&
|
|
||||||
'document' in loaderData.reviewPoints
|
|
||||||
? {
|
|
||||||
...loaderData,
|
|
||||||
document: (loaderData.reviewPoints as any).document,
|
|
||||||
reviewPoints: (loaderData.reviewPoints as any).data,
|
|
||||||
reviewInfo: (loaderData.reviewPoints as any).reviewInfo,
|
|
||||||
statistics: (loaderData.reviewPoints as any).stats,
|
|
||||||
comparison_document: (loaderData.reviewPoints as any).comparison_document,
|
|
||||||
scoring_proposals: (loaderData.reviewPoints as any).scoring_proposals ?? [],
|
|
||||||
}
|
|
||||||
: loaderData;
|
|
||||||
|
|
||||||
const fetcher = useFetcher();
|
const fetcher = useFetcher();
|
||||||
const { document, reviewPoints, statistics, reviewInfo, comparison_document, frontendJWT } = normalizedLoaderData as any;
|
const isLoaderError = isReviewsTestLoaderError(loaderData);
|
||||||
const fallbackReviewData = buildReviewData(document, reviewPoints, statistics, reviewInfo);
|
const successLoaderData = isLoaderError ? null : loaderData;
|
||||||
|
const document = successLoaderData?.document ?? null;
|
||||||
|
const reviewPoints = successLoaderData?.reviewPoints ?? [];
|
||||||
|
const statistics = successLoaderData?.statistics ?? EMPTY_STATISTICS;
|
||||||
|
const reviewInfo = successLoaderData?.reviewInfo ?? EMPTY_REVIEW_INFO;
|
||||||
|
const comparison_document = successLoaderData?.comparison_document ?? null;
|
||||||
|
const detailMode = successLoaderData?.detailMode ?? 'legacy';
|
||||||
|
const currentUserInfo = successLoaderData?.userInfo ?? null;
|
||||||
|
const frontendJWT = successLoaderData?.frontendJWT ?? null;
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(false); // 已经通过loader加载了数据,不需要再显示加载状态
|
const [isLoading, setIsLoading] = useState(false); // 已经通过loader加载了数据,不需要再显示加载状态
|
||||||
const [rightActiveTab, setRightActiveTab] = useState<'result' | 'fields' | 'info'>('result');
|
const [rightActiveTab, setRightActiveTab] = useState<'result' | 'fields' | 'info'>('result');
|
||||||
const [reviewData, setReviewData] = useState<ReviewData | null>(fallbackReviewData);
|
const [reviewData, setReviewData] = useState<ReviewData | null>(null);
|
||||||
const [activeReviewPointResultId, setActiveReviewPointResultId] = useState<string | number | null>(null);
|
const [activeReviewPointResultId, setActiveReviewPointResultId] = useState<string | number | null>(null);
|
||||||
const [targetPage, setTargetPage] = useState<number | undefined>(undefined);
|
const [targetPage, setTargetPage] = useState<number | undefined>(undefined);
|
||||||
const [charPositions, setCharPositions] = useState<Array<{ box: number[][], char: string, score: number }> | undefined>(undefined);
|
const [charPositions, setCharPositions] = useState<Array<{ box: number[][], char: string, score: number }> | undefined>(undefined);
|
||||||
|
const [bboxHighlight, setBboxHighlight] = useState<PdfBboxHighlight | undefined>(undefined);
|
||||||
const [highlightValue, setHighlightValue] = useState<string | undefined>(undefined);
|
const [highlightValue, setHighlightValue] = useState<string | undefined>(undefined);
|
||||||
const [pendingUpdate, setPendingUpdate] = useState<{
|
const [pendingUpdate, setPendingUpdate] = useState<{
|
||||||
reviewPointResultId: string;
|
reviewPointResultId: string;
|
||||||
@@ -416,32 +503,14 @@ export default function ReviewDetails() {
|
|||||||
// console.log('评查信息:', reviewInfo);
|
// console.log('评查信息:', reviewInfo);
|
||||||
// console.log('比对文档:', comparison_document);
|
// console.log('比对文档:', comparison_document);
|
||||||
// console.log('用户信息:', loaderData.userInfo);
|
// console.log('用户信息:', loaderData.userInfo);
|
||||||
// console.log('JWT Token (前20位):', frontendJWT?.substring(0, 20) + '...');
|
|
||||||
// console.groupEnd();
|
// console.groupEnd();
|
||||||
// }
|
// }
|
||||||
// }, [loaderData, document, reviewPoints, statistics, reviewInfo, comparison_document, frontendJWT]);
|
// }, [loaderData, document, reviewPoints, statistics, reviewInfo, comparison_document]);
|
||||||
|
|
||||||
// loader 数据加载出错
|
// loader 数据加载出错
|
||||||
useEffect(()=>{
|
useEffect(()=>{
|
||||||
loadingBarService.hide();
|
loadingBarService.hide();
|
||||||
// console.log('[Reviews Component] useEffect检查loaderData:', {
|
},[loaderData]);
|
||||||
// hasResultKey: Object.keys(loaderData).find(key => key === 'result'),
|
|
||||||
// resultValue: loaderData.result,
|
|
||||||
// willNavigateBack: Object.keys(loaderData).find(key => key === 'result') && !loaderData.result
|
|
||||||
// });
|
|
||||||
if(Object.keys(loaderData).find(key => key === 'result') && !loaderData.result){
|
|
||||||
messageService.show({
|
|
||||||
title: '错误',
|
|
||||||
message: loaderData.message,
|
|
||||||
type: 'error',
|
|
||||||
confirmText: '确定',
|
|
||||||
cancelText: '',
|
|
||||||
onConfirm: () => {
|
|
||||||
navigate(-1);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},[loaderData, navigate]);
|
|
||||||
|
|
||||||
|
|
||||||
// 当文档 ID 变化时,清空高亮相关的状态
|
// 当文档 ID 变化时,清空高亮相关的状态
|
||||||
@@ -450,65 +519,86 @@ export default function ReviewDetails() {
|
|||||||
setActiveReviewPointResultId(null);
|
setActiveReviewPointResultId(null);
|
||||||
setTargetPage(undefined);
|
setTargetPage(undefined);
|
||||||
setCharPositions(undefined);
|
setCharPositions(undefined);
|
||||||
|
setBboxHighlight(undefined);
|
||||||
setHighlightValue(undefined);
|
setHighlightValue(undefined);
|
||||||
}
|
}
|
||||||
}, [document?.id]);
|
}, [document?.id]);
|
||||||
|
|
||||||
// 使用 loader 数据同步本地评查页状态,避免首屏空白。
|
// 模拟获取评查数据
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setReviewData(buildReviewData(document, reviewPoints, statistics, reviewInfo));
|
if (!document) return;
|
||||||
|
|
||||||
|
// 构建文件信息对象
|
||||||
|
const fileInfo = {
|
||||||
|
fileName: document.name || "未知文件名",
|
||||||
|
path: document.path || "未知路径",
|
||||||
|
contractNumber: document.documentNumber || document.document_number || "未知编号",
|
||||||
|
fileSize: document.size ? formatFileSize(document.size) : document.file_size ? formatFileSize(document.file_size) : "未知大小",
|
||||||
|
// 文件格式类型
|
||||||
|
fileFormat: document.fileType ? document.fileType.toUpperCase() : "未知格式",
|
||||||
|
pageCount: document.pageCount || document.page_count || 0,
|
||||||
|
uploadTime: document.uploadTime || document.created_at || "未知时间",
|
||||||
|
uploadUser: document.uploadUser || "未知用户",
|
||||||
|
auditStatus: document.auditStatus || 0,
|
||||||
|
legalBasis: document.legalBasis || {},
|
||||||
|
// 文件类型(1:合同,2:卷宗。。。)
|
||||||
|
fileType: document.type || document.type_id ? document.type_id.toString() : ''
|
||||||
|
};
|
||||||
|
|
||||||
|
// 创建包含真实文档数据的评查数据对象
|
||||||
|
const reviewDataObj: ReviewData = {
|
||||||
|
// 使用真实文件信息
|
||||||
|
fileInfo: fileInfo,
|
||||||
|
// 其他字段暂时使用默认值
|
||||||
|
contractInfo: getMockReviewData().contractInfo,
|
||||||
|
reviewInfo: reviewInfo,
|
||||||
|
statistics: statistics,
|
||||||
|
fileContent: getMockReviewData().fileContent,
|
||||||
|
reviewPoints: reviewPoints,
|
||||||
|
aiAnalysis: getMockReviewData().aiAnalysis,
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
setReviewData(reviewDataObj);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}, [document, reviewPoints, statistics, reviewInfo]);
|
}, [document, reviewPoints, statistics, reviewInfo]);
|
||||||
|
|
||||||
const effectiveReviewData = reviewData ?? fallbackReviewData;
|
|
||||||
|
|
||||||
const handleTabChange = (tabKey: 'result' | 'fields' | 'info') => {
|
const handleTabChange = (tabKey: 'result' | 'fields' | 'info') => {
|
||||||
setRightActiveTab(tabKey);
|
setRightActiveTab(tabKey);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 从左栏选择评查点
|
// 从左栏选择评查点
|
||||||
const handleRuleSelect = (id: string | number) => {
|
const handleRuleSelect = (id: string | number) => {
|
||||||
setActiveReviewPointResultId(id);
|
|
||||||
setRightActiveTab('result');
|
setRightActiveTab('result');
|
||||||
|
|
||||||
// 查找评查点并尝试跳转到其页面
|
const point = reviewData?.reviewPoints.find(p => p.id === id);
|
||||||
const point = effectiveReviewData?.reviewPoints.find(p => p.id === id);
|
|
||||||
if (point) {
|
if (point) {
|
||||||
console.log('跳转到评查点页面:', point);
|
console.log('选择的评查点:', point);
|
||||||
const page = getFirstPageFromPoint(point);
|
const previewKind: PreviewKind = previewExtension === 'docx' ? 'docx' : 'pdf';
|
||||||
if (page) setTargetPage(page);
|
const defaultTarget = resolveDefaultPreviewTarget(point, previewKind);
|
||||||
else setTargetPage(undefined);
|
|
||||||
setCharPositions(undefined);
|
handleReviewPointSelect(
|
||||||
setHighlightValue(undefined);
|
id,
|
||||||
|
defaultTarget.page,
|
||||||
|
undefined,
|
||||||
|
defaultTarget.highlightValue,
|
||||||
|
previewKind === 'pdf' ? defaultTarget.bboxHighlight : undefined,
|
||||||
|
);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleReviewPointSelect(id);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 从评查点中提取第一个有效页码
|
|
||||||
const getFirstPageFromPoint = (point: ReviewPoint): number | undefined => {
|
|
||||||
if (point.content) {
|
|
||||||
for (const data of Object.values(point.content)) {
|
|
||||||
const page = (data as any)?.page;
|
|
||||||
if (page && Number(page) > 0) return Number(page);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (point.contentPage) {
|
|
||||||
for (const page of Object.values(point.contentPage)) {
|
|
||||||
if (page && Number(page) > 0) return Number(page);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 下载文件
|
|
||||||
const handleDownloadFile = async () => {
|
const handleDownloadFile = async () => {
|
||||||
try {
|
try {
|
||||||
const downloadUrl = `/api/pdf-proxy?path=${encodeURIComponent(document?.path || '')}`;
|
const downloadUrl = `/api/pdf-proxy?path=${encodeURIComponent(previewPath)}`;
|
||||||
const response = await axios.get(downloadUrl, { responseType: 'blob' });
|
const response = await axios.get(downloadUrl, { responseType: 'blob' });
|
||||||
const blobUrl = URL.createObjectURL(response.data);
|
const blobUrl = URL.createObjectURL(response.data);
|
||||||
const a = window.document.createElement('a');
|
const a = window.document.createElement('a');
|
||||||
a.style.display = 'none';
|
a.style.display = 'none';
|
||||||
a.href = blobUrl;
|
a.href = blobUrl;
|
||||||
a.download = decodeURIComponent(document?.path?.split('/').pop() || 'document');
|
a.download = decodeURIComponent(previewPath.split('/').pop() || 'document');
|
||||||
window.document.body.appendChild(a);
|
window.document.body.appendChild(a);
|
||||||
a.click();
|
a.click();
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -521,17 +611,19 @@ export default function ReviewDetails() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleReviewPointSelect = (reviewPointId: string | number, page?: number, charPos?: Array<{ box: number[][], char: string, score: number }>, value?: string) => {
|
const handleReviewPointSelect = (reviewPointId: string | number, page?: number, charPos?: Array<{ box: number[][], char: string, score: number }>, value?: string, nextBboxHighlight?: PdfBboxHighlight) => {
|
||||||
// 如果点击的是相同的评查点,但有page参数,先重置targetPage以确保useEffect能够触发
|
// 如果点击的是相同的评查点,但有page参数,先重置targetPage以确保useEffect能够触发
|
||||||
if (reviewPointId === activeReviewPointResultId && page) {
|
if (reviewPointId === activeReviewPointResultId && page) {
|
||||||
setTargetPage(undefined);
|
setTargetPage(undefined);
|
||||||
setCharPositions(undefined);
|
setCharPositions(undefined);
|
||||||
|
setBboxHighlight(undefined);
|
||||||
setHighlightValue(undefined);
|
setHighlightValue(undefined);
|
||||||
// 使用setTimeout确保状态更新后再设置新的targetPage、charPositions和highlightValue
|
// 使用setTimeout确保状态更新后再设置新的targetPage、charPositions和highlightValue
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setActiveReviewPointResultId(reviewPointId);
|
setActiveReviewPointResultId(reviewPointId);
|
||||||
setTargetPage(page);
|
setTargetPage(page);
|
||||||
setCharPositions(charPos);
|
setCharPositions(charPos);
|
||||||
|
setBboxHighlight(nextBboxHighlight);
|
||||||
setHighlightValue(value);
|
setHighlightValue(value);
|
||||||
}, 0);
|
}, 0);
|
||||||
} else {
|
} else {
|
||||||
@@ -539,10 +631,16 @@ export default function ReviewDetails() {
|
|||||||
setActiveReviewPointResultId(reviewPointId);
|
setActiveReviewPointResultId(reviewPointId);
|
||||||
setTargetPage(page);
|
setTargetPage(page);
|
||||||
setCharPositions(charPos);
|
setCharPositions(charPos);
|
||||||
|
setBboxHighlight(nextBboxHighlight);
|
||||||
setHighlightValue(value);
|
setHighlightValue(value);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 处理AI建议替换(保留空实现,右栏详情卡片中DOCX替换需要)
|
||||||
|
const handleAiSuggestionReplace = (_searchText: string, _replaceText: string, _pageNumber: number) => {
|
||||||
|
// PDF 文件不支持替换,暂不实现
|
||||||
|
};
|
||||||
|
|
||||||
// 刷新评审数据
|
// 刷新评审数据
|
||||||
// async function refreshReviewData(documentId: string) {
|
// async function refreshReviewData(documentId: string) {
|
||||||
// // 设置加载状态
|
// // 设置加载状态
|
||||||
@@ -779,7 +877,7 @@ export default function ReviewDetails() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 获取当前激活的评查点对象
|
// 获取当前激活的评查点对象
|
||||||
const activeReviewPoint = effectiveReviewData?.reviewPoints.find(p => p.id === activeReviewPointResultId) || null;
|
const activeReviewPoint = reviewData?.reviewPoints.find(p => p.id === activeReviewPointResultId) || null;
|
||||||
|
|
||||||
// ── 模板上传相关函数 ──
|
// ── 模板上传相关函数 ──
|
||||||
const handleOpenReuploadModal = () => {
|
const handleOpenReuploadModal = () => {
|
||||||
@@ -821,7 +919,7 @@ export default function ReviewDetails() {
|
|||||||
selectedTemplateFiles[0],
|
selectedTemplateFiles[0],
|
||||||
(document as any).id,
|
(document as any).id,
|
||||||
(comparison_document as any)?.comparisonId,
|
(comparison_document as any)?.comparisonId,
|
||||||
frontendJWT || undefined
|
frontendJWT || undefined,
|
||||||
);
|
);
|
||||||
if (uploadResult.error) throw new Error(uploadResult.error);
|
if (uploadResult.error) throw new Error(uploadResult.error);
|
||||||
toastService.success('模板文件上传成功,即将返回上一页...');
|
toastService.success('模板文件上传成功,即将返回上一页...');
|
||||||
@@ -833,14 +931,6 @@ export default function ReviewDetails() {
|
|||||||
} finally { setIsUploading(false); }
|
} finally { setIsUploading(false); }
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatFileSize = (bytes: number) => {
|
|
||||||
if (bytes === 0) return '0 Bytes';
|
|
||||||
const k = 1024;
|
|
||||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
||||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── 结构比对全页面视图 ──
|
// ── 结构比对全页面视图 ──
|
||||||
if (showComparison) {
|
if (showComparison) {
|
||||||
return (
|
return (
|
||||||
@@ -849,7 +939,7 @@ export default function ReviewDetails() {
|
|||||||
<button type="button" className="flex items-center gap-1 text-slate-600 hover:text-slate-900 text-[12.5px]" onClick={() => setShowComparison(false)}>
|
<button type="button" className="flex items-center gap-1 text-slate-600 hover:text-slate-900 text-[12.5px]" onClick={() => setShowComparison(false)}>
|
||||||
<i className="ri-arrow-left-line" /> 返回
|
<i className="ri-arrow-left-line" /> 返回
|
||||||
</button>
|
</button>
|
||||||
<span className="font-medium text-sm text-slate-800 truncate">{effectiveReviewData?.fileInfo?.fileName}</span>
|
<span className="font-medium text-sm text-slate-800 truncate">{reviewData?.fileInfo?.fileName}</span>
|
||||||
</header>
|
</header>
|
||||||
<div className="flex-1 min-h-0 overflow-auto">
|
<div className="flex-1 min-h-0 overflow-auto">
|
||||||
<Comparison comparison_document={comparison_document} />
|
<Comparison comparison_document={comparison_document} />
|
||||||
@@ -858,6 +948,44 @@ export default function ReviewDetails() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isLoaderError) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-screen bg-slate-50 px-6">
|
||||||
|
<div className="w-full max-w-xl rounded-xl border border-slate-200 bg-white shadow-sm p-8">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-amber-50 text-amber-600 flex items-center justify-center shrink-0">
|
||||||
|
<i className="ri-error-warning-line text-xl" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<h1 className="text-lg font-semibold text-slate-900">评查详情暂时无法打开</h1>
|
||||||
|
<p className="mt-2 text-sm text-slate-600 leading-6 break-words">
|
||||||
|
{loaderData.message || '文档不存在、当前账号无权限访问,或评查数据尚未准备完成。'}
|
||||||
|
</p>
|
||||||
|
<div className="mt-5 flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center gap-2 rounded-md bg-[#00684a] px-4 py-2 text-sm font-medium text-white hover:bg-[#00543c]"
|
||||||
|
onClick={() => navigate(getReturnUrl())}
|
||||||
|
>
|
||||||
|
<i className="ri-arrow-left-line" />
|
||||||
|
返回列表
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center gap-2 rounded-md border border-slate-200 px-4 py-2 text-sm text-slate-600 hover:bg-slate-50"
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
>
|
||||||
|
<i className="ri-refresh-line" />
|
||||||
|
重新加载
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-screen overflow-hidden">
|
<div className="flex flex-col h-screen overflow-hidden">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
@@ -865,14 +993,14 @@ export default function ReviewDetails() {
|
|||||||
<div className="loading-spinner"></div>
|
<div className="loading-spinner"></div>
|
||||||
<span className="ml-3">加载中...</span>
|
<span className="ml-3">加载中...</span>
|
||||||
</div>
|
</div>
|
||||||
) : effectiveReviewData ? (
|
) : reviewData ? (
|
||||||
<main className="flex-1 min-h-0 grid grid-cols-[22%,1fr,30%] p-2">
|
<main className="flex-1 min-h-0 grid grid-cols-[22%,1fr,30%] p-2">
|
||||||
{/* 左栏:规则目录 */}
|
{/* 左栏:规则目录 */}
|
||||||
<RulesDirectory
|
<RulesDirectory
|
||||||
reviewPoints={effectiveReviewData.reviewPoints}
|
reviewPoints={reviewData.reviewPoints}
|
||||||
statistics={effectiveReviewData.statistics}
|
statistics={reviewData.statistics}
|
||||||
activeReviewPointResultId={activeReviewPointResultId}
|
activeReviewPointResultId={activeReviewPointResultId}
|
||||||
fileName={effectiveReviewData.fileInfo.fileName}
|
fileName={reviewData.fileInfo.fileName}
|
||||||
onRuleSelect={handleRuleSelect}
|
onRuleSelect={handleRuleSelect}
|
||||||
onBack={() => navigate(getReturnUrl())}
|
onBack={() => navigate(getReturnUrl())}
|
||||||
/>
|
/>
|
||||||
@@ -886,18 +1014,19 @@ export default function ReviewDetails() {
|
|||||||
targetPage={targetPage}
|
targetPage={targetPage}
|
||||||
charPositions={charPositions}
|
charPositions={charPositions}
|
||||||
activeReviewPointResultId={activeReviewPointResultId}
|
activeReviewPointResultId={activeReviewPointResultId}
|
||||||
reviewPoints={effectiveReviewData.reviewPoints}
|
reviewPoints={reviewData.reviewPoints}
|
||||||
highlightValue={highlightValue}
|
highlightValue={highlightValue}
|
||||||
aiSuggestionReplace={aiSuggestionReplace}
|
aiSuggestionReplace={aiSuggestionReplace}
|
||||||
userInfo={(normalizedLoaderData as any)?.userInfo}
|
userInfo={currentUserInfo || undefined}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<PdfPreviewTest
|
<PdfPreviewTest
|
||||||
filePath={previewPath}
|
filePath={previewPath}
|
||||||
targetPage={targetPage}
|
targetPage={targetPage}
|
||||||
charPositions={charPositions}
|
charPositions={charPositions}
|
||||||
|
bboxHighlight={bboxHighlight}
|
||||||
activeReviewPointResultId={activeReviewPointResultId}
|
activeReviewPointResultId={activeReviewPointResultId}
|
||||||
reviewPoints={effectiveReviewData.reviewPoints}
|
reviewPoints={reviewData.reviewPoints}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
@@ -907,15 +1036,16 @@ export default function ReviewDetails() {
|
|||||||
activeTab={rightActiveTab}
|
activeTab={rightActiveTab}
|
||||||
onTabChange={handleTabChange}
|
onTabChange={handleTabChange}
|
||||||
activeReviewPoint={activeReviewPoint}
|
activeReviewPoint={activeReviewPoint}
|
||||||
reviewPoints={effectiveReviewData.reviewPoints}
|
reviewPoints={reviewData.reviewPoints}
|
||||||
fileInfo={effectiveReviewData.fileInfo}
|
detailMode={detailMode}
|
||||||
reviewInfo={effectiveReviewData.reviewInfo}
|
fileInfo={reviewData.fileInfo}
|
||||||
|
reviewInfo={reviewData.reviewInfo}
|
||||||
onReviewPointSelect={handleReviewPointSelect}
|
onReviewPointSelect={handleReviewPointSelect}
|
||||||
onStatusChange={handleReviewPointStatusChange}
|
onStatusChange={handleReviewPointStatusChange}
|
||||||
onConfirmResults={handleConfirmResults}
|
onConfirmResults={handleConfirmResults}
|
||||||
onDownload={handleDownloadFile}
|
onDownload={handleDownloadFile}
|
||||||
auditStatus={document?.auditStatus}
|
auditStatus={document?.auditStatus}
|
||||||
fileFormat={effectiveReviewData.fileInfo.fileFormat}
|
fileFormat={reviewData.fileInfo.fileFormat}
|
||||||
onUploadTemplate={handleOpenReuploadModal}
|
onUploadTemplate={handleOpenReuploadModal}
|
||||||
onComparison={() => setShowComparison(true)}
|
onComparison={() => setShowComparison(true)}
|
||||||
showComparisonButton={showComparisonButton}
|
showComparisonButton={showComparisonButton}
|
||||||
@@ -939,7 +1069,7 @@ export default function ReviewDetails() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-h-0">
|
<div className="flex-1 min-h-0">
|
||||||
<ComparePreview
|
<ComparePreview
|
||||||
doc1Path={document?.path || ''}
|
doc1Path={previewPath}
|
||||||
doc2Path={comparison_document?.template_contract_path || ''}
|
doc2Path={comparison_document?.template_contract_path || ''}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user