feat: 初步完成评查详情页面的work文档和pdf文档的加载的页面三栏设计的重构。
This commit is contained in:
@@ -1149,13 +1149,16 @@ export async function getReviewPoints_fromApi(fileId: string, request: Request)
|
|||||||
|
|
||||||
// 成功响应
|
// 成功响应
|
||||||
if (response.data) {
|
if (response.data) {
|
||||||
|
// console.log('✅ [getReviewPoints_fromApi] API调用成功,返回数据结构:', JSON.stringify(response.data, null, 2))
|
||||||
|
// console.log('✅ [getReviewPoints_fromApi] API调用成功,返回数据结构:', JSON.stringify(response.data))
|
||||||
// console.log('✅ [getReviewPoints_fromApi] API调用成功,返回数据结构:', JSON.stringify({
|
// console.log('✅ [getReviewPoints_fromApi] API调用成功,返回数据结构:', JSON.stringify({
|
||||||
// 评查点数量: response.data.data?.length || 0,
|
// 评查点数量: response.data.data?.length || 0,
|
||||||
// // 统计信息: response.data.stats,
|
// 评查点数量: response.data.data
|
||||||
// // 评查信息: response.data.reviewInfo,
|
// 统计信息: response.data.stats,
|
||||||
|
// 评查信息: response.data.reviewInfo,
|
||||||
// 有文档数据: response.data.document,
|
// 有文档数据: response.data.document,
|
||||||
// // 有比对数据: !!response.data.comparison_document,
|
// 有比对数据: !!response.data.comparison_document,
|
||||||
// // 评分提案数量: response.data.scoring_proposals?.length || 0
|
// 评分提案数量: response.data.scoring_proposals?.length || 0
|
||||||
// }));
|
// }));
|
||||||
|
|
||||||
// 返回数据格式与原方法完全一致
|
// 返回数据格式与原方法完全一致
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ interface LayoutProps {
|
|||||||
// 添加一个接口表示路由handle可能包含的属性
|
// 添加一个接口表示路由handle可能包含的属性
|
||||||
interface RouteHandle {
|
interface RouteHandle {
|
||||||
hideBreadcrumb?: boolean;
|
hideBreadcrumb?: boolean;
|
||||||
|
collapseSidebar?: boolean;
|
||||||
|
noPadding?: boolean;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,6 +44,11 @@ export function Layout({ children, userRole = 'developer' as UserRole, frontendJ
|
|||||||
match.handle && match.handle.hideBreadcrumb === true
|
match.handle && match.handle.hideBreadcrumb === true
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 检查当前路由是否要求无 padding
|
||||||
|
const shouldNoPadding = matches.some(match =>
|
||||||
|
match.handle && match.handle.noPadding === true
|
||||||
|
);
|
||||||
|
|
||||||
// 从 localStorage 读取用户信息和 JWT 作为备用方案
|
// 从 localStorage 读取用户信息和 JWT 作为备用方案
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
@@ -80,16 +87,23 @@ export function Layout({ children, userRole = 'developer' as UserRole, frontendJ
|
|||||||
// 检查是否为移动端
|
// 检查是否为移动端
|
||||||
const isMobile = window.innerWidth <= 768;
|
const isMobile = window.innerWidth <= 768;
|
||||||
|
|
||||||
// 从localStorage获取侧边栏状态
|
// 检查当前路由是否要求收缩侧边栏
|
||||||
const savedState = localStorage.getItem('sidebarCollapsed');
|
const shouldCollapse = matches.some(match =>
|
||||||
|
match.handle && match.handle.collapseSidebar === true
|
||||||
|
);
|
||||||
|
|
||||||
// 移动端默认收起,桌面端使用保存的状态
|
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
setSidebarCollapsed(true);
|
setSidebarCollapsed(true);
|
||||||
} else if (savedState) {
|
} else if (shouldCollapse) {
|
||||||
|
setSidebarCollapsed(true);
|
||||||
|
} else {
|
||||||
|
// 从localStorage获取侧边栏状态
|
||||||
|
const savedState = localStorage.getItem('sidebarCollapsed');
|
||||||
|
if (savedState) {
|
||||||
setSidebarCollapsed(savedState === 'true');
|
setSidebarCollapsed(savedState === 'true');
|
||||||
}
|
}
|
||||||
}, []);
|
}
|
||||||
|
}, [location.pathname]);
|
||||||
|
|
||||||
const toggleSidebar = () => {
|
const toggleSidebar = () => {
|
||||||
const newState = !sidebarCollapsed;
|
const newState = !sidebarCollapsed;
|
||||||
@@ -135,7 +149,7 @@ export function Layout({ children, userRole = 'developer' as UserRole, frontendJ
|
|||||||
))}
|
))}
|
||||||
</div> */}
|
</div> */}
|
||||||
|
|
||||||
<div className="content-container">
|
<div className={`content-container${shouldNoPadding ? ' !p-0' : ''}`}>
|
||||||
{!shouldHideBreadcrumb && <Breadcrumb />}
|
{!shouldHideBreadcrumb && <Breadcrumb />}
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -118,6 +118,11 @@ export interface ReviewPoint {
|
|||||||
};
|
};
|
||||||
postAction?: string;
|
postAction?: string;
|
||||||
actionContent?: string;
|
actionContent?: string;
|
||||||
|
score?: number;
|
||||||
|
machineScore?: number;
|
||||||
|
finalScore?: number | null;
|
||||||
|
failMessage?: string;
|
||||||
|
passMessage?: string;
|
||||||
evaluationConfig?: {
|
evaluationConfig?: {
|
||||||
rules?: Array<{
|
rules?: Array<{
|
||||||
type: string;
|
type: string;
|
||||||
|
|||||||
@@ -10,3 +10,9 @@ export type { ReviewPoint } 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';
|
||||||
|
|
||||||
|
// 新三栏组件
|
||||||
|
export { RulesDirectory } from './leftColumn/RulesDirectory';
|
||||||
|
export { DetailPanel } from './rightColumn/DetailPanel';
|
||||||
|
export { ReviewPointDetailCard } from './rightColumn/ReviewPointDetailCard';
|
||||||
|
export { FileInfoPanel } from './rightColumn/FileInfoPanel';
|
||||||
@@ -0,0 +1,302 @@
|
|||||||
|
/**
|
||||||
|
* 左栏 · 规则目录
|
||||||
|
* 显示文件名、分数进度条、搜索、评查点分组列表
|
||||||
|
*/
|
||||||
|
import { useState, useMemo } from 'react';
|
||||||
|
import type { ReviewPoint } from '../ReviewPointsList';
|
||||||
|
|
||||||
|
interface Statistics {
|
||||||
|
total: number;
|
||||||
|
success: number;
|
||||||
|
warning: number;
|
||||||
|
error: number;
|
||||||
|
notApplicable: number;
|
||||||
|
score: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RulesDirectoryProps {
|
||||||
|
reviewPoints: ReviewPoint[];
|
||||||
|
statistics: Statistics;
|
||||||
|
activeReviewPointResultId: string | null;
|
||||||
|
fileName: string;
|
||||||
|
onRuleSelect: (id: string) => void;
|
||||||
|
onBack: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type PointStatus = 'pass' | 'warn' | 'fail' | 'skipped';
|
||||||
|
|
||||||
|
function classifyPoint(p: ReviewPoint): PointStatus {
|
||||||
|
if (p.status === 'notApplicable' || p.status === 'not_applicable') return 'skipped';
|
||||||
|
if (p.result === true || (p.result === undefined && p.status === 'success')) return 'pass';
|
||||||
|
if (p.result === false) {
|
||||||
|
if (p.status === 'error') return 'fail';
|
||||||
|
if (p.status === 'warning' || p.status === 'info') return 'warn';
|
||||||
|
}
|
||||||
|
return 'pass';
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_ICON: Record<PointStatus, { icon: string; color: string }> = {
|
||||||
|
pass: { icon: 'ri-checkbox-circle-fill', color: 'text-emerald-500' },
|
||||||
|
warn: { icon: 'ri-lightbulb-flash-fill', color: 'text-amber-500' },
|
||||||
|
fail: { icon: 'ri-close-circle-fill', color: 'text-red-500' },
|
||||||
|
skipped: { icon: 'ri-forbid-2-line', color: 'text-slate-400' },
|
||||||
|
};
|
||||||
|
|
||||||
|
function RuleListItem({
|
||||||
|
point,
|
||||||
|
isActive,
|
||||||
|
showCategory,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
point: ReviewPoint;
|
||||||
|
isActive: boolean;
|
||||||
|
showCategory?: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
}) {
|
||||||
|
const cls = classifyPoint(point);
|
||||||
|
const s = STATUS_ICON[cls];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
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'
|
||||||
|
}`}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
{isActive && (
|
||||||
|
<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">
|
||||||
|
<i className={`${s.icon} ${s.color} shrink-0 text-[14px]`} />
|
||||||
|
<span
|
||||||
|
className={`text-[12.5px] text-slate-800 truncate flex-1 ${
|
||||||
|
isActive ? 'font-semibold' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{point.pointName}
|
||||||
|
</span>
|
||||||
|
{showCategory && (
|
||||||
|
<span className="shrink-0 text-[10px] text-slate-400">
|
||||||
|
{point.groupName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RulesDirectory({
|
||||||
|
reviewPoints,
|
||||||
|
statistics,
|
||||||
|
activeReviewPointResultId,
|
||||||
|
fileName,
|
||||||
|
onRuleSelect,
|
||||||
|
onBack,
|
||||||
|
}: RulesDirectoryProps) {
|
||||||
|
const [searchText, setSearchText] = useState('');
|
||||||
|
const [passOpen, setPassOpen] = useState(true);
|
||||||
|
const [openCategories, setOpenCategories] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const q = searchText.toLowerCase();
|
||||||
|
|
||||||
|
const matchSearch = (p: ReviewPoint) =>
|
||||||
|
!q ||
|
||||||
|
(p.pointName?.toLowerCase().includes(q)) ||
|
||||||
|
(p.pointCode?.toLowerCase().includes(q)) ||
|
||||||
|
(p.groupName?.toLowerCase().includes(q));
|
||||||
|
|
||||||
|
const { needAttention, passed, passedByGroup } = useMemo(() => {
|
||||||
|
const filtered = reviewPoints.filter(matchSearch);
|
||||||
|
const needAttention = filtered.filter(
|
||||||
|
(p) => classifyPoint(p) !== 'pass' && classifyPoint(p) !== 'skipped'
|
||||||
|
);
|
||||||
|
const passed = filtered.filter((p) => classifyPoint(p) === 'pass');
|
||||||
|
|
||||||
|
const passedByGroup: Record<string, ReviewPoint[]> = {};
|
||||||
|
passed.forEach((p) => {
|
||||||
|
const key = p.groupName || '未分组';
|
||||||
|
(passedByGroup[key] = passedByGroup[key] || []).push(p);
|
||||||
|
});
|
||||||
|
|
||||||
|
return { needAttention, passed, passedByGroup };
|
||||||
|
}, [reviewPoints, searchText]);
|
||||||
|
|
||||||
|
// 计算进度条百分比
|
||||||
|
const total = statistics.total || reviewPoints.length;
|
||||||
|
const passPct = total > 0 ? (statistics.success / total) * 100 : 0;
|
||||||
|
const warnPct = total > 0 ? (statistics.warning / total) * 100 : 0;
|
||||||
|
const errPct = total > 0 ? (statistics.error / total) * 100 : 0;
|
||||||
|
const naPct =
|
||||||
|
total > 0
|
||||||
|
? ((statistics.notApplicable || 0) / total) * 100
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const attentionCount = needAttention.length;
|
||||||
|
const passedCount = passed.length;
|
||||||
|
|
||||||
|
const toggleCategory = (cat: string) => {
|
||||||
|
setOpenCategories((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(cat)) next.delete(cat);
|
||||||
|
else next.add(cat);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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="flex items-center gap-1.5 text-[11px] text-slate-500">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-5 h-5 grid place-items-center rounded hover:bg-slate-100 text-slate-400 shrink-0"
|
||||||
|
title="返回"
|
||||||
|
onClick={onBack}
|
||||||
|
>
|
||||||
|
<i className="ri-arrow-left-line" />
|
||||||
|
</button>
|
||||||
|
<i className="ri-file-text-line text-slate-400 shrink-0" />
|
||||||
|
<span className="flex-1 break-all">{fileName}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 分数 + 进度条 */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-[22px] font-semibold text-slate-900 tabular-nums leading-none">
|
||||||
|
{Math.round(statistics.score)}
|
||||||
|
</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 h-full">
|
||||||
|
<div
|
||||||
|
className="bg-emerald-500"
|
||||||
|
style={{ width: `${passPct}%` }}
|
||||||
|
/>
|
||||||
|
<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 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">
|
||||||
|
<i className="ri-focus-3-line" />{' '}
|
||||||
|
<span className="font-mono">{attentionCount}</span> 项需关注
|
||||||
|
</span>
|
||||||
|
<span className="text-slate-400">
|
||||||
|
<span className="font-mono text-slate-500">{passedCount}</span> /{' '}
|
||||||
|
{total} 已通过
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 搜索框 */}
|
||||||
|
<div className="shrink-0 p-2.5 border-b border-slate-100">
|
||||||
|
<div className="relative">
|
||||||
|
<i className="ri-search-line absolute left-2.5 top-1/2 -translate-y-1/2 text-slate-400 text-[14px]" />
|
||||||
|
<input
|
||||||
|
placeholder="搜索规则"
|
||||||
|
className="w-full h-8 pl-8 pr-2 bg-slate-50 border border-slate-200 rounded-md text-[12.5px] focus:outline-none focus:bg-white focus:border-[#00684a] focus:ring-2 focus:ring-[#00684a]/15"
|
||||||
|
value={searchText}
|
||||||
|
onChange={(e) => setSearchText(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 评查点列表 */}
|
||||||
|
<div className="flex-1 min-h-0 overflow-y-auto scroll-slim py-1">
|
||||||
|
{/* 需关注 */}
|
||||||
|
{needAttention.length > 0 ? (
|
||||||
|
<div>
|
||||||
|
{needAttention.map((p) => (
|
||||||
|
<RuleListItem
|
||||||
|
key={p.id}
|
||||||
|
point={p}
|
||||||
|
isActive={p.id === activeReviewPointResultId}
|
||||||
|
showCategory
|
||||||
|
onClick={() => onRuleSelect(p.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</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 && (
|
||||||
|
<div className="border-t border-slate-200 mt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-2 bg-slate-50/60 hover:bg-slate-100 text-left"
|
||||||
|
onClick={() => setPassOpen(!passOpen)}
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
className={`ri-arrow-right-s-line text-slate-400 text-[12px] transition-transform ${
|
||||||
|
passOpen ? 'rotate-90' : ''
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<i className="ri-checkbox-circle-fill text-emerald-500 text-[14px]" />
|
||||||
|
<span className="text-[12px] text-slate-600">已通过</span>
|
||||||
|
<span className="ml-auto font-mono text-[11px] text-slate-400">
|
||||||
|
{passed.length}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{passOpen &&
|
||||||
|
Object.entries(passedByGroup).map(([cat, points]) => {
|
||||||
|
const catOpen = openCategories.has(cat);
|
||||||
|
return (
|
||||||
|
<div key={cat}>
|
||||||
|
<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"
|
||||||
|
onClick={() => toggleCategory(cat)}
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
className={`ri-arrow-right-s-line text-slate-400 text-[11px] transition-transform ${
|
||||||
|
catOpen ? 'rotate-90' : ''
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<span className="text-[10px] font-medium text-slate-500 uppercase tracking-wider">
|
||||||
|
{cat}
|
||||||
|
</span>
|
||||||
|
<span className="ml-auto font-mono text-[10px] text-slate-400">
|
||||||
|
{points.length}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{catOpen &&
|
||||||
|
points.map((p) => (
|
||||||
|
<RuleListItem
|
||||||
|
key={p.id}
|
||||||
|
point={p}
|
||||||
|
isActive={p.id === activeReviewPointResultId}
|
||||||
|
onClick={() => onRuleSelect(p.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
/**
|
||||||
|
* DOCX 预览组件(设计稿 7c-简化C-极简 · 中栏实现)
|
||||||
|
*
|
||||||
|
* 使用 CollaboraViewer 渲染 DOCX 文件。
|
||||||
|
* 工具栏与 PdfPreviewTest 一致,额外增加清除高亮、返回顶部按钮。
|
||||||
|
*/
|
||||||
|
import { useState, useEffect, useMemo, useRef } from 'react';
|
||||||
|
import { CollaboraViewer } from '~/components/collabora/CollaboraViewer';
|
||||||
|
import type { CollaboraViewerHandle } from '~/components/collabora/types';
|
||||||
|
import { customGotoPage } from '~/components/collabora/lib';
|
||||||
|
import { toastService } from '~/components/ui/Toast';
|
||||||
|
import type { ReviewPoint } from '../ReviewPointsList';
|
||||||
|
|
||||||
|
interface DocxPreviewTestProps {
|
||||||
|
filePath: string;
|
||||||
|
targetPage?: number;
|
||||||
|
charPositions?: Array<{ box: number[][]; char: string; score: number }>;
|
||||||
|
activeReviewPointResultId?: string | null;
|
||||||
|
reviewPoints?: ReviewPoint[];
|
||||||
|
highlightValue?: string;
|
||||||
|
aiSuggestionReplace?: {
|
||||||
|
searchText: string;
|
||||||
|
replaceText: string;
|
||||||
|
pageNumber: number;
|
||||||
|
silentReplace?: boolean;
|
||||||
|
};
|
||||||
|
userInfo?: { sub: string; nick_name: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DocxPreviewTest({
|
||||||
|
filePath,
|
||||||
|
targetPage,
|
||||||
|
activeReviewPointResultId,
|
||||||
|
reviewPoints,
|
||||||
|
highlightValue,
|
||||||
|
aiSuggestionReplace,
|
||||||
|
userInfo,
|
||||||
|
}: DocxPreviewTestProps) {
|
||||||
|
const collaboraRef = useRef<CollaboraViewerHandle>(null);
|
||||||
|
|
||||||
|
const [pageInputValue, setPageInputValue] = useState('');
|
||||||
|
const [isClearingHighlights, setIsClearingHighlights] = useState(false);
|
||||||
|
const [isScrollingToTop, setIsScrollingToTop] = useState(false);
|
||||||
|
|
||||||
|
// 当前激活的评查点
|
||||||
|
const activePoint = useMemo<ReviewPoint | undefined>(
|
||||||
|
() => reviewPoints?.find(p => p.id === activeReviewPointResultId),
|
||||||
|
[reviewPoints, activeReviewPointResultId],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 当前高亮标签
|
||||||
|
const highlightLabel = useMemo(() => {
|
||||||
|
if (!activePoint) return null;
|
||||||
|
const code = activePoint.pointCode || activePoint.id;
|
||||||
|
const name = activePoint.pointName || activePoint.title || '';
|
||||||
|
return `${code}${name ? ' · ' + name : ''}`;
|
||||||
|
}, [activePoint]);
|
||||||
|
|
||||||
|
// ── 页码跳转 ──
|
||||||
|
const handlePageInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setPageInputValue(e.target.value.replace(/\D/g, ''));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePageJump = async () => {
|
||||||
|
if (!pageInputValue) return;
|
||||||
|
const targetPageNum = parseInt(pageInputValue, 10);
|
||||||
|
const iframeWindow = collaboraRef.current?.getIframeWindow?.();
|
||||||
|
if (!iframeWindow) {
|
||||||
|
toastService.warning('文档尚未加载完成,请稍候...');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (targetPageNum > 0) {
|
||||||
|
try {
|
||||||
|
await customGotoPage(iframeWindow, targetPageNum);
|
||||||
|
setPageInputValue('');
|
||||||
|
} catch (error) {
|
||||||
|
toastService.error(`跳转失败: ${error instanceof Error ? error.message : '未知错误'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── 清除高亮 ──
|
||||||
|
const handleClearAllHighlights = async () => {
|
||||||
|
if (!collaboraRef.current?.isReady) {
|
||||||
|
toastService.warning('文档尚未加载完成,请稍候...');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsClearingHighlights(true);
|
||||||
|
try {
|
||||||
|
await collaboraRef.current.clearAllHighlights();
|
||||||
|
toastService.success('已清除所有高亮');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[DocxPreviewTest] 清除高亮失败:', error);
|
||||||
|
toastService.error('清除高亮失败');
|
||||||
|
} finally {
|
||||||
|
setTimeout(() => setIsClearingHighlights(false), 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── 返回顶部 ──
|
||||||
|
const handleScrollToTop = async () => {
|
||||||
|
setIsScrollingToTop(true);
|
||||||
|
try {
|
||||||
|
await collaboraRef.current?.unoCommands.scrollToTop();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[DocxPreviewTest] 返回顶部失败:', error);
|
||||||
|
toastService.error('返回顶部失败');
|
||||||
|
} finally {
|
||||||
|
setTimeout(() => setIsScrollingToTop(false), 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── 跳转到高亮页 ──
|
||||||
|
const jumpToHighlight = () => {
|
||||||
|
if (!activePoint) return;
|
||||||
|
// 触发 targetPage 重新跳转(由父组件控制)
|
||||||
|
toastService.info('已定位到当前评查点所在页');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="flex flex-col flex-1 min-h-0 w-full h-full bg-slate-100 border border-slate-200 rounded">
|
||||||
|
{/* ═══ 顶部工具栏 ═══ */}
|
||||||
|
<div className="shrink-0 h-11 px-4 flex items-center justify-between bg-white border-b border-slate-200 text-[12.5px] text-slate-600">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* 页码跳转 */}
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const iframeWindow = collaboraRef.current?.getIframeWindow?.();
|
||||||
|
if (!iframeWindow) return;
|
||||||
|
// 上一页:跳转到当前页-1
|
||||||
|
customGotoPage(iframeWindow, -1).catch(() => {});
|
||||||
|
}}
|
||||||
|
className="w-5 h-7 grid place-items-center rounded hover:bg-slate-100 text-slate-400"
|
||||||
|
title="上一页"
|
||||||
|
>
|
||||||
|
<i className="ri-arrow-left-s-line" />
|
||||||
|
</button>
|
||||||
|
<span className="font-mono tabular-nums">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={pageInputValue}
|
||||||
|
onChange={handlePageInputChange}
|
||||||
|
onFocus={e => e.currentTarget.select()}
|
||||||
|
onBlur={handlePageJump}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter') handlePageJump(); }}
|
||||||
|
className="w-8 h-6 text-center bg-slate-50 border border-slate-200 rounded text-[12px] font-mono outline-none focus:border-[#00684a]"
|
||||||
|
placeholder="-"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const iframeWindow = collaboraRef.current?.getIframeWindow?.();
|
||||||
|
if (!iframeWindow) return;
|
||||||
|
customGotoPage(iframeWindow, 99999).catch(() => {});
|
||||||
|
}}
|
||||||
|
className="w-5 h-7 grid place-items-center rounded hover:bg-slate-100 text-slate-400"
|
||||||
|
title="下一页"
|
||||||
|
>
|
||||||
|
<i className="ri-arrow-right-s-line" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span className="mx-0 text-slate-300">|</span>
|
||||||
|
|
||||||
|
{/* 返回顶部 */}
|
||||||
|
<button
|
||||||
|
onClick={handleScrollToTop}
|
||||||
|
disabled={isScrollingToTop}
|
||||||
|
className={`w-5 h-7 grid place-items-center rounded hover:bg-slate-100 ${isScrollingToTop ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||||
|
title="返回顶部"
|
||||||
|
>
|
||||||
|
{isScrollingToTop ? <i className="ri-loader-4-line text-[12px] animate-spin" /> : <i className="ri-arrow-up-double-line" />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 清除高亮 */}
|
||||||
|
<button
|
||||||
|
onClick={handleClearAllHighlights}
|
||||||
|
disabled={isClearingHighlights}
|
||||||
|
className={`w-5 h-7 grid place-items-center rounded hover:bg-slate-100 ${isClearingHighlights ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||||
|
title="清除高亮"
|
||||||
|
>
|
||||||
|
{isClearingHighlights ? <i className="ri-loader-4-line text-[12px] animate-spin" /> : <i className="ri-eraser-line" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 右侧:当前高亮标签 */}
|
||||||
|
<div className="flex items-center gap-2 text-[11.5px] min-w-0">
|
||||||
|
{highlightLabel && (
|
||||||
|
<>
|
||||||
|
<span className="text-slate-400 shrink-0">当前高亮:</span>
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-amber-50 text-amber-800 border border-amber-200 max-w-[220px]"
|
||||||
|
title={highlightLabel}
|
||||||
|
>
|
||||||
|
<i className="ri-focus-3-line shrink-0" />
|
||||||
|
<span className="truncate">{highlightLabel}</span>
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={jumpToHighlight}
|
||||||
|
className="w-7 h-7 grid place-items-center rounded hover:bg-slate-100 shrink-0"
|
||||||
|
title="跳转到高亮页"
|
||||||
|
>
|
||||||
|
<i className="ri-crosshair-line" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ═══ Collabora 文档区域 ═══ */}
|
||||||
|
<div className="flex-1 min-h-0">
|
||||||
|
<CollaboraViewer
|
||||||
|
ref={collaboraRef}
|
||||||
|
fileId={filePath}
|
||||||
|
mode="edit"
|
||||||
|
userId={userInfo?.sub || 'unknown'}
|
||||||
|
userName={userInfo?.nick_name || ''}
|
||||||
|
targetPage={targetPage}
|
||||||
|
highlightText={highlightValue}
|
||||||
|
aiSuggestionReplace={aiSuggestionReplace}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -352,32 +352,31 @@ export function PdfPreviewTest({
|
|||||||
file={fileUrl}
|
file={fileUrl}
|
||||||
onLoadSuccess={onDocumentLoadSuccess}
|
onLoadSuccess={onDocumentLoadSuccess}
|
||||||
onLoadError={onDocumentLoadError}
|
onLoadError={onDocumentLoadError}
|
||||||
className="flex flex-col min-h-0 w-full"
|
className="flex flex-col min-h-0 w-full h-full"
|
||||||
loading={<div className="flex-1 grid place-items-center text-slate-400 text-sm">PDF 加载中…</div>}
|
loading={<div className="flex-1 grid place-items-center text-slate-400 text-sm">PDF 加载中…</div>}
|
||||||
error={<div className="flex-1 grid place-items-center text-red-500">PDF 文档加载失败</div>}
|
error={<div className="flex-1 grid place-items-center text-red-500">PDF 文档加载失败</div>}
|
||||||
noData={<div className="flex-1 grid place-items-center text-slate-400">无数据</div>}
|
noData={<div className="flex-1 grid place-items-center text-slate-400">无数据</div>}
|
||||||
>
|
>
|
||||||
<section
|
<section
|
||||||
className="flex flex-col min-h-0 bg-slate-100 border border-slate-200 rounded"
|
className="flex flex-col flex-1 min-h-0 w-full bg-slate-100 border border-slate-200 rounded"
|
||||||
style={{ height: 'calc(100vh - 120px)' }}
|
|
||||||
>
|
>
|
||||||
{/* ═════ 顶部工具栏 ═════ */}
|
{/* ═════ 顶部工具栏 ═════ */}
|
||||||
<div className="shrink-0 h-11 px-4 flex items-center justify-between bg-white border-b border-slate-200 text-[12.5px] text-slate-600">
|
<div className="shrink-0 h-11 px-4 flex items-center justify-between bg-white border-b border-slate-200 text-[12.5px] text-slate-600">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowThumbs(s => !s)}
|
onClick={() => setShowThumbs(s => !s)}
|
||||||
className={`w-7 h-7 grid place-items-center rounded hover:bg-slate-100 ${
|
className={`w-5 h-7 grid place-items-center rounded hover:bg-slate-100 ${
|
||||||
showThumbs ? 'text-primary' : 'text-slate-400'
|
showThumbs ? 'text-primary' : 'text-slate-400'
|
||||||
}`}
|
}`}
|
||||||
title="显示/隐藏页面缩略图"
|
title="显示/隐藏页面缩略图"
|
||||||
>
|
>
|
||||||
<i className="ri-layout-masonry-line"></i>
|
<i className="ri-layout-masonry-line"></i>
|
||||||
</button>
|
</button>
|
||||||
<span className="mx-0.5 text-slate-300">|</span>
|
<span className="mx-0 text-slate-300">|</span>
|
||||||
<button
|
<button
|
||||||
onClick={goPrev}
|
onClick={goPrev}
|
||||||
disabled={currentPage <= 1}
|
disabled={currentPage <= 1}
|
||||||
className="w-7 h-7 grid place-items-center rounded hover:bg-slate-100 disabled:opacity-40 disabled:cursor-not-allowed"
|
className="w-5 h-7 grid place-items-center rounded hover:bg-slate-100 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
title="上一页"
|
title="上一页"
|
||||||
>
|
>
|
||||||
<i className="ri-arrow-left-s-line"></i>
|
<i className="ri-arrow-left-s-line"></i>
|
||||||
@@ -392,23 +391,23 @@ export function PdfPreviewTest({
|
|||||||
onKeyDown={e => {
|
onKeyDown={e => {
|
||||||
if (e.key === 'Enter') handlePageJump();
|
if (e.key === 'Enter') handlePageJump();
|
||||||
}}
|
}}
|
||||||
className="w-9 h-6 text-center bg-slate-50 border border-slate-200 rounded text-[12px] font-mono outline-none focus:border-primary"
|
className="w-5 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>
|
||||||
<button
|
<button
|
||||||
onClick={goNext}
|
onClick={goNext}
|
||||||
disabled={!numPages || currentPage >= numPages}
|
disabled={!numPages || currentPage >= numPages}
|
||||||
className="w-7 h-7 grid place-items-center rounded hover:bg-slate-100 disabled:opacity-40 disabled:cursor-not-allowed"
|
className="w-5 h-7 grid place-items-center rounded hover:bg-slate-100 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
title="下一页"
|
title="下一页"
|
||||||
>
|
>
|
||||||
<i className="ri-arrow-right-s-line"></i>
|
<i className="ri-arrow-right-s-line"></i>
|
||||||
</button>
|
</button>
|
||||||
<span className="mx-2 text-slate-300">|</span>
|
<span className="mx-0 text-slate-300">|</span>
|
||||||
<button
|
<button
|
||||||
onClick={zoomOut}
|
onClick={zoomOut}
|
||||||
disabled={zoomLevel <= 50}
|
disabled={zoomLevel <= 50}
|
||||||
className="w-7 h-7 grid place-items-center rounded hover:bg-slate-100 disabled:opacity-40"
|
className="w-5 h-7 grid place-items-center rounded hover:bg-slate-100 disabled:opacity-40"
|
||||||
title="缩小"
|
title="缩小"
|
||||||
>
|
>
|
||||||
<i className="ri-zoom-out-line"></i>
|
<i className="ri-zoom-out-line"></i>
|
||||||
@@ -417,7 +416,7 @@ export function PdfPreviewTest({
|
|||||||
<button
|
<button
|
||||||
onClick={zoomIn}
|
onClick={zoomIn}
|
||||||
disabled={zoomLevel >= 200}
|
disabled={zoomLevel >= 200}
|
||||||
className="w-7 h-7 grid place-items-center rounded hover:bg-slate-100 disabled:opacity-40"
|
className="w-5 h-7 grid place-items-center rounded hover:bg-slate-100 disabled:opacity-40"
|
||||||
title="放大"
|
title="放大"
|
||||||
>
|
>
|
||||||
<i className="ri-zoom-in-line"></i>
|
<i className="ri-zoom-in-line"></i>
|
||||||
@@ -511,9 +510,9 @@ export function PdfPreviewTest({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const frameCls = isCur
|
const frameCls = isCur
|
||||||
? 'ring-2 ring-primary shadow-md'
|
? 'ring-2 ring-[#00684a] shadow-md'
|
||||||
: effThumbMode === 'all' && isRulePage
|
: effThumbMode === 'all' && isRulePage
|
||||||
? 'ring-1 ring-primary/40 shadow-sm'
|
? 'ring-1 ring-[#00684a]/40 shadow-sm'
|
||||||
: 'border border-slate-200 group-hover:border-slate-400 shadow-sm';
|
: 'border border-slate-200 group-hover:border-slate-400 shadow-sm';
|
||||||
|
|
||||||
let fieldsLabel: React.ReactNode = null;
|
let fieldsLabel: React.ReactNode = null;
|
||||||
@@ -553,7 +552,7 @@ export function PdfPreviewTest({
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={`text-center text-[10.5px] mt-1 ${
|
className={`text-center text-[10.5px] mt-1 ${
|
||||||
isCur ? 'text-primary font-semibold' : 'text-slate-500 group-hover:text-slate-700'
|
isCur ? 'text-[#00684a] font-semibold' : 'text-slate-500 group-hover:text-slate-700'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{p}
|
{p}
|
||||||
@@ -570,15 +569,16 @@ export function PdfPreviewTest({
|
|||||||
{/* ── 视口(单页) ── */}
|
{/* ── 视口(单页) ── */}
|
||||||
<div
|
<div
|
||||||
ref={viewportRef}
|
ref={viewportRef}
|
||||||
className="flex-1 min-h-0 min-w-0 overflow-auto p-4 text-center"
|
className="flex-1 min-h-0 min-w-0 overflow-auto p-4"
|
||||||
|
style={{ display: 'flex', justifyContent: 'center', alignItems: 'flex-start' }}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="pdf-main-canvas"
|
className="pdf-main-canvas"
|
||||||
style={{
|
style={{
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
display: 'inline-block',
|
display: 'inline-block',
|
||||||
margin: '0 auto',
|
|
||||||
textAlign: 'left',
|
textAlign: 'left',
|
||||||
|
flexShrink: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{numPages !== null && (
|
{numPages !== null && (
|
||||||
|
|||||||
@@ -0,0 +1,222 @@
|
|||||||
|
/**
|
||||||
|
* 右栏 · 详情面板
|
||||||
|
* 包含 3 个选项卡(评查结果、抽取字段、文件信息)+ 底部操作栏
|
||||||
|
*/
|
||||||
|
import { useState } from 'react';
|
||||||
|
import type { ReviewPoint, CharPosition } from '../ReviewPointsList';
|
||||||
|
import { ReviewPointDetailCard } from './ReviewPointDetailCard';
|
||||||
|
import { FileInfoPanel } from './FileInfoPanel';
|
||||||
|
|
||||||
|
type TabKey = 'result' | 'fields' | 'info';
|
||||||
|
|
||||||
|
interface FileInfoData {
|
||||||
|
fileName: string;
|
||||||
|
contractNumber: string;
|
||||||
|
fileSize: string;
|
||||||
|
fileFormat: string;
|
||||||
|
pageCount: number;
|
||||||
|
uploadTime: string;
|
||||||
|
uploadUser: string;
|
||||||
|
fileType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReviewInfoData {
|
||||||
|
reviewTime: string;
|
||||||
|
reviewModel: string;
|
||||||
|
ruleGroup: string;
|
||||||
|
result: string;
|
||||||
|
issueCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DetailPanelProps {
|
||||||
|
activeTab: TabKey;
|
||||||
|
onTabChange: (tab: TabKey) => void;
|
||||||
|
activeReviewPoint: ReviewPoint | null;
|
||||||
|
reviewPoints: ReviewPoint[];
|
||||||
|
fileInfo: FileInfoData;
|
||||||
|
reviewInfo: ReviewInfoData;
|
||||||
|
onReviewPointSelect: (id: string, page?: number, charPositions?: CharPosition[], value?: string) => void;
|
||||||
|
onStatusChange: (id: string, editAuditStatusId: string | number, status: string, message: string) => void;
|
||||||
|
onConfirmResults: () => void;
|
||||||
|
onDownload: () => void;
|
||||||
|
auditStatus?: number;
|
||||||
|
fileFormat?: string;
|
||||||
|
onUploadTemplate?: () => void;
|
||||||
|
onComparison?: () => void;
|
||||||
|
showComparisonButton?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ExtractedFieldsPanel({ reviewPoints, onFieldClick }: { reviewPoints: ReviewPoint[]; onFieldClick: (page: number) => void }) {
|
||||||
|
const fields: Array<{ key: string; value: string; page?: number; pointName: string }> = [];
|
||||||
|
|
||||||
|
reviewPoints.forEach((p) => {
|
||||||
|
if (p.content) {
|
||||||
|
Object.entries(p.content).forEach(([key, data]) => {
|
||||||
|
const val = (data as any)?.value;
|
||||||
|
const page = (data as any)?.page;
|
||||||
|
const text = typeof val === 'object' ? (val as any)?.text || JSON.stringify(val) : String(val || '');
|
||||||
|
fields.push({ key, value: text, page: page ? Number(page) : undefined, pointName: p.pointName });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-3">
|
||||||
|
<div className="text-[11px] font-medium text-slate-400 uppercase tracking-wider mb-2">
|
||||||
|
抽取字段 <span className="font-mono normal-case text-[10.5px]">{fields.length}</span>
|
||||||
|
</div>
|
||||||
|
{fields.length === 0 ? (
|
||||||
|
<div className="text-center py-6 text-[12px] text-slate-400">暂无抽取字段</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{fields.map((f, i) => (
|
||||||
|
<button
|
||||||
|
key={`${f.key}-${i}`}
|
||||||
|
type="button"
|
||||||
|
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'}`}
|
||||||
|
onClick={() => f.page && onFieldClick(f.page)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="text-[11px] text-slate-500 truncate font-medium">{f.key}</span>
|
||||||
|
{f.page && <span className="text-[10.5px] text-slate-400 shrink-0">P{f.page}</span>}
|
||||||
|
</div>
|
||||||
|
{f.value && <div className="text-[12px] text-slate-700 mt-1 leading-relaxed line-clamp-2">{f.value}</div>}
|
||||||
|
<div className="text-[10px] text-slate-400 mt-0.5">{f.pointName}</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const TABS: Array<{ key: TabKey; label: string }> = [
|
||||||
|
{ key: 'result', label: '评查结果' },
|
||||||
|
{ key: 'fields', label: '抽取字段' },
|
||||||
|
{ key: 'info', label: '文件信息' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function DetailPanel({
|
||||||
|
activeTab,
|
||||||
|
onTabChange,
|
||||||
|
activeReviewPoint,
|
||||||
|
reviewPoints,
|
||||||
|
fileInfo,
|
||||||
|
reviewInfo,
|
||||||
|
onReviewPointSelect,
|
||||||
|
onStatusChange,
|
||||||
|
onConfirmResults,
|
||||||
|
onDownload,
|
||||||
|
auditStatus,
|
||||||
|
fileFormat,
|
||||||
|
onUploadTemplate,
|
||||||
|
onComparison,
|
||||||
|
showComparisonButton,
|
||||||
|
}: DetailPanelProps) {
|
||||||
|
return (
|
||||||
|
<aside className="border-l border-slate-200 bg-white flex flex-col min-h-0 min-w-0 overflow-hidden">
|
||||||
|
{/* Tabs */}
|
||||||
|
<nav className="shrink-0 h-11 px-3 flex items-stretch gap-4 border-b border-slate-200 text-[12.5px]">
|
||||||
|
{TABS.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.key}
|
||||||
|
type="button"
|
||||||
|
className={`h-full flex items-center ${
|
||||||
|
activeTab === tab.key
|
||||||
|
? 'text-slate-900 font-medium border-b-2 border-[#00684a] -mb-[1px]'
|
||||||
|
: 'text-slate-500 hover:text-slate-800'
|
||||||
|
}`}
|
||||||
|
onClick={() => onTabChange(tab.key)}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
{tab.key === 'fields' && (
|
||||||
|
<span className="ml-1 font-mono text-[11px] text-slate-400">{reviewPoints.length}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<div className="flex-1" />
|
||||||
|
{showComparisonButton && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="h-full flex items-center text-slate-500 hover:text-slate-800 hover:bg-slate-50 px-2 transition"
|
||||||
|
title="上传模板"
|
||||||
|
onClick={onUploadTemplate}
|
||||||
|
>
|
||||||
|
<i className="ri-upload-cloud-line text-[15px]" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="h-full flex items-center text-slate-500 hover:text-slate-800 hover:bg-slate-50 px-2 transition"
|
||||||
|
title="结构比对"
|
||||||
|
onClick={onComparison}
|
||||||
|
>
|
||||||
|
<i className="ri-flip-horizontal-line text-[15px]" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 min-h-0 overflow-y-auto scroll-slim">
|
||||||
|
{activeTab === 'result' && (
|
||||||
|
activeReviewPoint ? (
|
||||||
|
<div className="p-3">
|
||||||
|
<ReviewPointDetailCard
|
||||||
|
reviewPoint={activeReviewPoint}
|
||||||
|
onReviewPointSelect={onReviewPointSelect}
|
||||||
|
onStatusChange={onStatusChange}
|
||||||
|
fileFormat={fileFormat}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full text-slate-400 text-[13px]">
|
||||||
|
<i className="ri-cursor-line text-3xl mb-2" />
|
||||||
|
<span>点击左侧评查点查看详情</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'fields' && (
|
||||||
|
<ExtractedFieldsPanel
|
||||||
|
reviewPoints={reviewPoints}
|
||||||
|
onFieldClick={(page) => {
|
||||||
|
// 通过 activeReviewPoint 的 id 跳转页面
|
||||||
|
if (activeReviewPoint) {
|
||||||
|
onReviewPointSelect(activeReviewPoint.id, page);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'info' && (
|
||||||
|
<FileInfoPanel fileInfo={fileInfo} reviewInfo={reviewInfo} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom action bar */}
|
||||||
|
<div className="shrink-0 border-t border-slate-200 p-2.5 bg-slate-50/60 flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="h-9 w-9 rounded-md text-slate-500 hover:bg-slate-200 grid place-items-center"
|
||||||
|
title="下载文档"
|
||||||
|
onClick={onDownload}
|
||||||
|
>
|
||||||
|
<i className="ri-download-line text-[16px]" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`flex-1 h-9 rounded-md text-white text-[12.5px] font-medium flex items-center justify-center gap-1.5 shadow-sm ${
|
||||||
|
auditStatus === 1
|
||||||
|
? 'bg-slate-300 cursor-not-allowed'
|
||||||
|
: 'bg-[#00684a] hover:bg-[#005a3f]'
|
||||||
|
}`}
|
||||||
|
onClick={auditStatus === 1 ? undefined : onConfirmResults}
|
||||||
|
disabled={auditStatus === 1}
|
||||||
|
>
|
||||||
|
<i className="ri-checkbox-circle-line" /> 确认评查结果
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
/**
|
||||||
|
* 右栏 · 文件信息选项卡
|
||||||
|
* 以简洁的 key-value 网格展示文件基本属性和评查信息
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface FileInfoData {
|
||||||
|
fileName: string;
|
||||||
|
contractNumber: string;
|
||||||
|
fileSize: string;
|
||||||
|
fileFormat: string;
|
||||||
|
pageCount: number;
|
||||||
|
uploadTime: string;
|
||||||
|
uploadUser: string;
|
||||||
|
fileType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReviewInfoData {
|
||||||
|
reviewTime: string;
|
||||||
|
reviewModel: string;
|
||||||
|
ruleGroup: string;
|
||||||
|
result: string;
|
||||||
|
issueCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FileInfoPanelProps {
|
||||||
|
fileInfo: FileInfoData;
|
||||||
|
reviewInfo: ReviewInfoData;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-[80px,1fr] gap-2 text-[12.5px] py-1.5 border-b border-slate-100 last:border-b-0">
|
||||||
|
<span className="text-slate-400">{label}</span>
|
||||||
|
<span className="text-slate-700 break-all">{value || '—'}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FileInfoPanel({ fileInfo, reviewInfo }: FileInfoPanelProps) {
|
||||||
|
return (
|
||||||
|
<div className="p-4 space-y-4">
|
||||||
|
{/* 文件基本信息 */}
|
||||||
|
<section>
|
||||||
|
<div className="text-[11px] font-medium text-slate-400 uppercase tracking-wider mb-2 flex items-center gap-1">
|
||||||
|
<i className="ri-file-info-line text-blue-500 text-[12px]" /> 文件基本信息
|
||||||
|
</div>
|
||||||
|
<div className="bg-slate-50/60 border border-slate-200 rounded-md p-3">
|
||||||
|
<Row label="文件名称" value={fileInfo.fileName} />
|
||||||
|
<Row label={fileInfo.fileType !== '1' ? '卷宗编号' : '合同编号'} value={fileInfo.contractNumber} />
|
||||||
|
<Row label="文件大小" value={fileInfo.fileSize} />
|
||||||
|
<Row label="文件格式" value={fileInfo.fileFormat} />
|
||||||
|
<Row label="页数" value={`${fileInfo.pageCount} 页`} />
|
||||||
|
<Row label="上传时间" value={fileInfo.uploadTime} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 评查信息 */}
|
||||||
|
<section>
|
||||||
|
<div className="text-[11px] font-medium text-slate-400 uppercase tracking-wider mb-2 flex items-center gap-1">
|
||||||
|
<i className="ri-search-eye-line text-purple-500 text-[12px]" /> 评查信息
|
||||||
|
</div>
|
||||||
|
<div className="bg-slate-50/60 border border-slate-200 rounded-md p-3">
|
||||||
|
<Row label="评查时间" value={reviewInfo.reviewTime} />
|
||||||
|
<Row label="评查模型" value={reviewInfo.reviewModel} />
|
||||||
|
<Row label="规则组" value={reviewInfo.ruleGroup} />
|
||||||
|
<Row label="问题数量" value={`${reviewInfo.issueCount} 个`} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,577 @@
|
|||||||
|
/**
|
||||||
|
* 右栏 · 评查点详情卡片
|
||||||
|
* 展示单个评查点的完整详情,复用 renderConsistencyRule/renderOtherRule/renderModelRule 渲染逻辑
|
||||||
|
*/
|
||||||
|
import { useState, useEffect, useRef, useMemo } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { toastService } from '~/components/ui/Toast';
|
||||||
|
import type { ReviewPoint, CharPosition } from '../ReviewPointsList';
|
||||||
|
|
||||||
|
interface ReviewPointDetailCardProps {
|
||||||
|
reviewPoint: ReviewPoint;
|
||||||
|
onReviewPointSelect: (id: string, page?: number, charPositions?: CharPosition[], value?: string) => void;
|
||||||
|
onStatusChange: (id: string, editAuditStatusId: string | number, status: string, message: string) => void;
|
||||||
|
fileFormat?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 比较方法映射 ──
|
||||||
|
const compareMethodMap: Record<string, string> = {
|
||||||
|
exact: '精确匹配',
|
||||||
|
contains: '包含关系',
|
||||||
|
semantic: '大模型语义匹配',
|
||||||
|
};
|
||||||
|
const getCompareMethodText = (method?: string): string => {
|
||||||
|
if (!method) return '相等';
|
||||||
|
const text = compareMethodMap[method] || method;
|
||||||
|
return typeof text === 'string' ? text : String(text);
|
||||||
|
};
|
||||||
|
const ruleTypeMap: Record<string, string> = {
|
||||||
|
exists: '有无判断', format: '格式判断', logic: '逻辑判断', regex: '正则表达式',
|
||||||
|
};
|
||||||
|
const getRuleTypeText = (type?: string): string => {
|
||||||
|
if (!type) return '';
|
||||||
|
return ruleTypeMap[type] || type;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Tooltip 系统 ──
|
||||||
|
let activeTooltip = { show: false, content: null as React.ReactNode, position: { top: 0, left: 0 }, ready: false };
|
||||||
|
function TooltipPortal() {
|
||||||
|
const [tooltip, setTooltip] = useState(activeTooltip);
|
||||||
|
useEffect(() => {
|
||||||
|
const update = () => setTooltip({ ...activeTooltip });
|
||||||
|
window.addEventListener('tooltip-update', update);
|
||||||
|
return () => window.removeEventListener('tooltip-update', update);
|
||||||
|
}, []);
|
||||||
|
if (!tooltip.show || !tooltip.content) return null;
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
className="fixed bg-white shadow-lg rounded-md p-1 border border-gray-200 z-[9999]"
|
||||||
|
style={{
|
||||||
|
top: `${tooltip.position.top}px`, left: `${tooltip.position.left}px`,
|
||||||
|
transform: 'translate(-100%, -50%)', opacity: tooltip.ready ? 1 : 0,
|
||||||
|
visibility: tooltip.ready ? 'visible' : 'hidden', transition: 'opacity 0.15s ease-out',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tooltip.content}
|
||||||
|
<div className="absolute top-1/2 right-0 transform translate-x-1/2 -translate-y-1/2 rotate-45 w-2 h-2 bg-white border-t border-r border-gray-200" />
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function showTooltip(content: React.ReactNode, position: { top: number; left: number }) {
|
||||||
|
activeTooltip = { show: true, content, position, ready: false };
|
||||||
|
window.dispatchEvent(new Event('tooltip-update'));
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const el = document.querySelector('.fixed.bg-white.shadow-lg.rounded-md') as HTMLElement;
|
||||||
|
if (el) {
|
||||||
|
const r = el.getBoundingClientRect();
|
||||||
|
let t = position.top, l = position.left;
|
||||||
|
if (l - r.width < 0) l = r.width + 10;
|
||||||
|
if (t - r.height / 2 < 0) t = r.height / 2 + 10;
|
||||||
|
if (t + r.height / 2 > window.innerHeight) t = window.innerHeight - r.height / 2 - 10;
|
||||||
|
activeTooltip.position = { top: t, left: l };
|
||||||
|
activeTooltip.ready = true;
|
||||||
|
} else {
|
||||||
|
activeTooltip.ready = true;
|
||||||
|
}
|
||||||
|
window.dispatchEvent(new Event('tooltip-update'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function hideTooltip() {
|
||||||
|
activeTooltip.show = false;
|
||||||
|
activeTooltip.ready = false;
|
||||||
|
window.dispatchEvent(new Event('tooltip-update'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ReactTableTooltip ──
|
||||||
|
function renderReactTable(text: string) {
|
||||||
|
const rows = text.split('\n').map(r => r.split('\t'));
|
||||||
|
return (
|
||||||
|
<table className="border-collapse text-[11px] w-full">
|
||||||
|
<tbody>
|
||||||
|
{rows.map((row, i) => (
|
||||||
|
<tr key={i} className={i === 0 ? 'font-semibold bg-slate-100' : ''}>
|
||||||
|
{row.map((cell, j) => <td key={j} className="border border-slate-200 px-2 py-1">{cell}</td>)}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function renderMarkdownTable(text: string) {
|
||||||
|
const lines = text.split('\n').filter(l => l.trim());
|
||||||
|
const rows: string[][] = [];
|
||||||
|
for (const line of lines) {
|
||||||
|
if (/^\s*\|[-\s:]+\|/.test(line)) continue;
|
||||||
|
const cells = line.split('|').map(c => c.trim()).filter((_, i, a) => i > 0 && i < a.length);
|
||||||
|
if (cells.length > 0) rows.push(cells);
|
||||||
|
}
|
||||||
|
if (rows.length < 1) return <span>{text}</span>;
|
||||||
|
return (
|
||||||
|
<table className="border-collapse text-[11px] w-full">
|
||||||
|
<tbody>
|
||||||
|
{rows.map((row, i) => (
|
||||||
|
<tr key={i} className={i === 0 ? 'font-semibold bg-slate-100' : ''}>
|
||||||
|
{row.map((cell, j) => <td key={j} className="border border-slate-200 px-2 py-1">{cell}</td>)}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function renderPipeTable(text: string) {
|
||||||
|
const lines = text.split('\n').filter(l => l.includes('|'));
|
||||||
|
const rows = lines.map(l => l.split('|').map(c => c.trim()).filter(Boolean));
|
||||||
|
return (
|
||||||
|
<table className="border-collapse text-[11px] w-full">
|
||||||
|
<tbody>
|
||||||
|
{rows.map((row, i) => (
|
||||||
|
<tr key={i} className={i === 0 ? 'font-semibold bg-slate-100' : ''}>
|
||||||
|
{row.map((cell, j) => <td key={j} className="border border-slate-200 px-2 py-1">{cell}</td>)}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const ReactTableTooltip = ({ content }: { content: string }) => {
|
||||||
|
const [showTip, setShowTip] = useState(false);
|
||||||
|
const [rendered, setRendered] = useState<React.ReactNode>(null);
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const isTabTable = content.includes('\t') && content.includes('\n');
|
||||||
|
const isMdTable = content.includes('|') && /\|[-\s:]+\|/.test(content);
|
||||||
|
const isPipeTable = !isMdTable && content.includes('|') && content.includes('\n') && content.split('\n').filter(l => l.includes('|')).length >= 2;
|
||||||
|
const isTableLike = isTabTable || isMdTable || isPipeTable;
|
||||||
|
useEffect(() => {
|
||||||
|
const check = () => { if (ref.current) setShowTip(isTableLike || ref.current.scrollHeight > ref.current.clientHeight); };
|
||||||
|
if (isMdTable) setRendered(renderMarkdownTable(content));
|
||||||
|
else if (isPipeTable) setRendered(renderPipeTable(content));
|
||||||
|
else if (isTabTable) setRendered(renderReactTable(content));
|
||||||
|
else setRendered(content);
|
||||||
|
requestAnimationFrame(check);
|
||||||
|
window.addEventListener('resize', check);
|
||||||
|
return () => window.removeEventListener('resize', check);
|
||||||
|
}, [content, isTableLike]);
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<div ref={ref} className="text-xs text-gray-700 line-clamp-2 select-text" onMouseEnter={() => showTip && showTooltip(<div className="p-2 max-w-md max-h-64 overflow-auto">{rendered}</div>, { top: ref.current?.getBoundingClientRect().top || 0, left: ref.current?.getBoundingClientRect().left || 0 })} onMouseLeave={hideTooltip}>{rendered}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── filterOtherRule ──
|
||||||
|
interface MergedRule {
|
||||||
|
fieldKey: string;
|
||||||
|
fieldValue: {
|
||||||
|
type: Record<string, { res: boolean; page?: number | string; value?: string; char_positions?: CharPosition[] }>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterOtherRule(reviewPoint: ReviewPoint): MergedRule[] {
|
||||||
|
interface RuleFieldValue { page?: number | string; value?: string; char_positions?: CharPosition[]; type: Record<string, boolean> }
|
||||||
|
const allRule: Array<{ fieldKey: string; fieldValue: RuleFieldValue }> = [];
|
||||||
|
|
||||||
|
for (const rule of reviewPoint.evaluatedPointResultsLog?.rules || []) {
|
||||||
|
if ((rule.config as any).res !== reviewPoint.result) continue;
|
||||||
|
|
||||||
|
if (rule.type === 'exists') {
|
||||||
|
const config = rule.config as { res: boolean; fields: Record<string, { page: number; value: string; char_positions?: CharPosition[] }>; logic?: string };
|
||||||
|
if (config.res) {
|
||||||
|
Object.entries(config.fields).forEach(([key, fv]) => {
|
||||||
|
if (fv.value && fv.value.trim() !== '') allRule.push({ fieldKey: key, fieldValue: { ...fv, type: { exists: true } } });
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
Object.entries(config.fields).forEach(([key, fv]) => {
|
||||||
|
const empty = !fv.value || fv.value.trim() === '';
|
||||||
|
allRule.push({ fieldKey: key, fieldValue: { ...fv, type: { exists: !empty } } });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (rule.type === 'format') {
|
||||||
|
const config = rule.config as { res: boolean; field: Record<string, { page: string | number; value: string; char_positions?: CharPosition[] }>; formatType?: string; parameters?: string };
|
||||||
|
if (config.field) {
|
||||||
|
const entries = Object.entries(config.field);
|
||||||
|
if (entries.length > 0) { const [key, fv] = entries[0]; allRule.push({ fieldKey: key, fieldValue: { ...fv, type: { format: config.res } } }); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (rule.type === 'logic') {
|
||||||
|
const config = rule.config as { logic: string; res: boolean; conditions: Array<{ field: Record<string, { page: number | string; value: string; char_positions?: CharPosition[] }>; value: string; operator: string; res: boolean }> };
|
||||||
|
if (config.conditions && Array.isArray(config.conditions)) {
|
||||||
|
config.conditions.forEach(cond => {
|
||||||
|
const entries = Object.entries(cond.field);
|
||||||
|
if (entries.length > 0) { const [key, fv] = entries[0]; allRule.push({ fieldKey: key, fieldValue: { ...fv, type: { logic: cond.res } } }); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (rule.type === 'regex') {
|
||||||
|
const config = rule.config as { res: boolean; field: Record<string, { page: number | string; value: string; char_positions?: CharPosition[] }>; pattern?: string };
|
||||||
|
if (config.field) {
|
||||||
|
const entries = Object.entries(config.field);
|
||||||
|
if (entries.length > 0) { const [key, fv] = entries[0]; allRule.push({ fieldKey: key, fieldValue: { ...fv, type: { regex: config.res } } }); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldKeyMap: Record<string, MergedRule> = {};
|
||||||
|
allRule.forEach(item => {
|
||||||
|
const typeKey = Object.keys(item.fieldValue.type)[0];
|
||||||
|
const typeValue = item.fieldValue.type[typeKey];
|
||||||
|
if (!fieldKeyMap[item.fieldKey]) fieldKeyMap[item.fieldKey] = { fieldKey: item.fieldKey, fieldValue: { type: {} } };
|
||||||
|
fieldKeyMap[item.fieldKey].fieldValue.type[typeKey] = { res: typeValue, page: item.fieldValue.page, value: item.fieldValue.value, char_positions: item.fieldValue.char_positions };
|
||||||
|
});
|
||||||
|
return Object.values(fieldKeyMap);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── renderOtherRule ──
|
||||||
|
function RenderOtherRule({ rule, reviewPoint, onReviewPointSelect }: { rule: MergedRule; reviewPoint: ReviewPoint; onReviewPointSelect: (id: string, page?: number, charPos?: CharPosition[], value?: string) => void }) {
|
||||||
|
const fieldKey = rule.fieldKey;
|
||||||
|
const fieldValue = rule.fieldValue;
|
||||||
|
const hasFailure = Object.values(fieldValue.type || {}).some(item => item.res === false);
|
||||||
|
const overallResult = !hasFailure;
|
||||||
|
const failedEntry = Object.entries(fieldValue.type || {}).find(([, item]) => item.res === false);
|
||||||
|
const mainEntry = failedEntry || Object.entries(fieldValue.type || {})[0];
|
||||||
|
if (!mainEntry) return null;
|
||||||
|
const [, mainVal] = mainEntry;
|
||||||
|
|
||||||
|
const tooltipContent = (
|
||||||
|
<div className="flex flex-row gap-2 overflow-x-auto max-h-[300px]">
|
||||||
|
{Object.entries(fieldValue.type || {}).map(([tk, tv]) => (
|
||||||
|
<div key={tk} className="rounded-md flex flex-row items-center">
|
||||||
|
<div className="text-xs text-gray-600 pl-1 whitespace-nowrap">{getRuleTypeText(tk)}:</div>
|
||||||
|
<div className="p-1 text-xs rounded-full min-w-[50px] text-center">{tv.res ? '通过' : '不通过'}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={`border border-gray rounded-md overflow-hidden mb-2 ${overallResult ? 'bg-[rgba(246,255,237,1)]' : 'bg-[rgba(255,251,230,1)]'} flex w-full text-left hover:shadow-[0_0_10px_rgba(0,0,0,0.1)] ${overallResult ? 'hover:bg-[rgba(0,128,0,0.1)]' : 'hover:bg-[rgba(255,255,0,0.1)]'}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (mainVal.page && typeof onReviewPointSelect === 'function') onReviewPointSelect(reviewPoint.id, Number(mainVal.page), mainVal.char_positions, mainVal.value);
|
||||||
|
else if (reviewPoint.contentPage && reviewPoint.contentPage[fieldKey]) onReviewPointSelect(reviewPoint.id, Number(reviewPoint.contentPage[fieldKey]), mainVal.char_positions, mainVal.value);
|
||||||
|
else toastService.error(`没有找到${fieldKey}对应的索引内容`);
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<div className="p-1 flex-1">
|
||||||
|
<div className="text-xs text-gray-500 mb-1">
|
||||||
|
{fieldKey}
|
||||||
|
{!mainVal.page && !(reviewPoint.contentPage && reviewPoint.contentPage[fieldKey]) && <i className="ri-information-line text-red-500 text-xs ml-1" title="没有找到对应的文书内容" />}
|
||||||
|
{mainVal.res === false && !mainVal.value && <span className="ml-2 text-xs text-yellow-500">缺失</span>}
|
||||||
|
</div>
|
||||||
|
{mainVal.value && <ReactTableTooltip content={mainVal.value} />}
|
||||||
|
</div>
|
||||||
|
<div className="w-8 flex items-center justify-center rounded-r-md" onMouseEnter={(e) => { const r = e.currentTarget.getBoundingClientRect(); showTooltip(tooltipContent, { top: r.top + r.height / 2, left: r.left }); }} onMouseLeave={hideTooltip}>
|
||||||
|
{overallResult ? <i className="ri-check-line text-success text-base" /> : <i className="ri-alert-line text-warning text-base" />}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── renderConsistencyRule ──
|
||||||
|
type ChainItem = { field: string; data: { key: string; page: number; value: string; char_positions?: CharPosition[] }; res: boolean; compareMethod?: string };
|
||||||
|
|
||||||
|
function RenderConsistencyRule({ rule, reviewPoint, onReviewPointSelect }: { rule: Record<string, unknown>; reviewPoint: ReviewPoint; onReviewPointSelect: (id: string, page?: number, charPos?: CharPosition[], value?: string) => void }) {
|
||||||
|
if (reviewPoint.result !== (rule.res as boolean)) return null;
|
||||||
|
const config = rule.config as { logic?: string; pairs?: Array<{ sourceField: Record<string, { page: number; value: string; char_positions?: CharPosition[] }>; targetField: Record<string, { page: number; value: string; char_positions?: CharPosition[] }>; res: boolean; compareMethod?: string }>; selectedFields?: string[] } | undefined;
|
||||||
|
if (!config || !config.pairs || !Array.isArray(config.pairs) || config.pairs.length === 0) return null;
|
||||||
|
const pairs = config.pairs;
|
||||||
|
|
||||||
|
const chains = useMemo(() => {
|
||||||
|
type CI = ChainItem;
|
||||||
|
const result: Array<Array<CI>> = [];
|
||||||
|
const visited = new Set<string>();
|
||||||
|
const fieldMap = new Map<string, Array<{ targetField: string; data: { source: { key: string; page: number; value: string; char_positions?: CharPosition[] }; target: { key: string; page: number; value: string; char_positions?: CharPosition[] } }; res: boolean; compareMethod?: string }>>();
|
||||||
|
pairs.forEach(pair => {
|
||||||
|
const sKey = Object.keys(pair.sourceField)[0], tKey = Object.keys(pair.targetField)[0];
|
||||||
|
if (!fieldMap.has(sKey)) fieldMap.set(sKey, []);
|
||||||
|
fieldMap.get(sKey)?.push({ targetField: tKey, data: { source: { key: sKey, ...pair.sourceField[sKey] }, target: { key: tKey, ...pair.targetField[tKey] } }, res: pair.res, compareMethod: pair.compareMethod });
|
||||||
|
});
|
||||||
|
const starts = new Set<string>();
|
||||||
|
for (const [key] of fieldMap.entries()) { let isT = false; for (const p of pairs) { if (Object.keys(p.targetField)[0] === key) { isT = true; break; } } if (!isT) starts.add(key); }
|
||||||
|
for (const sp of starts) {
|
||||||
|
if (visited.has(sp)) continue;
|
||||||
|
const chain: CI[] = [];
|
||||||
|
let cur = sp;
|
||||||
|
while (fieldMap.has(cur)) {
|
||||||
|
const targets = fieldMap.get(cur);
|
||||||
|
if (!targets || targets.length === 0) break;
|
||||||
|
let next: typeof targets[0] | null = null;
|
||||||
|
for (const t of targets) { if (!visited.has(t.targetField)) { next = t; break; } }
|
||||||
|
if (!next) break;
|
||||||
|
if (chain.length === 0) chain.push({ field: cur, data: next.data.source, res: next.res, compareMethod: next.compareMethod });
|
||||||
|
chain.push({ field: next.targetField, data: next.data.target, res: next.res, compareMethod: next.compareMethod });
|
||||||
|
visited.add(cur); visited.add(next.targetField);
|
||||||
|
cur = next.targetField;
|
||||||
|
}
|
||||||
|
if (chain.length > 0) result.push(chain);
|
||||||
|
}
|
||||||
|
// isolated pairs
|
||||||
|
for (const pair of pairs) {
|
||||||
|
const sKey = Object.keys(pair.sourceField)[0], tKey = Object.keys(pair.targetField)[0];
|
||||||
|
if (!visited.has(sKey) || !visited.has(tKey)) {
|
||||||
|
result.push([
|
||||||
|
{ field: sKey, data: { key: sKey, ...pair.sourceField[sKey] }, res: pair.res },
|
||||||
|
{ field: tKey, data: { key: tKey, ...pair.targetField[tKey] }, res: pair.res },
|
||||||
|
]);
|
||||||
|
visited.add(sKey); visited.add(tKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}, [pairs]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-3">
|
||||||
|
<div className="comparison-group">
|
||||||
|
{chains.map((chain: ChainItem[], ci: number) => {
|
||||||
|
const res = chain[1]?.res ?? true;
|
||||||
|
const itemCls = res ? 'comparison-item match' : 'comparison-item mismatch';
|
||||||
|
|
||||||
|
if (chain.length > 2) {
|
||||||
|
return (
|
||||||
|
<div key={`chain_${ci}`} className={`${itemCls} border border-gray rounded-md overflow-hidden mb-2 ${res ? 'bg-[rgba(246,255,237,1)]' : 'bg-[rgba(255,251,230,1)]'} hover:shadow-[0_0_10px_rgba(0,0,0,0.1)] cursor-pointer`}
|
||||||
|
onClick={(e) => { e.stopPropagation(); for (const item of chain) { if (item.data.page && typeof onReviewPointSelect === 'function') { onReviewPointSelect(reviewPoint.id, Number(item.data.page), item.data.char_positions); break; } } }}>
|
||||||
|
<div className="comparison-values flex w-full">
|
||||||
|
<div className="value-box p-2 pb-1 flex-1">
|
||||||
|
<div className="value-source text-xs text-gray-500 mb-1">
|
||||||
|
{chain.map((item: ChainItem, idx: number) => (
|
||||||
|
<span key={idx} className="inline-block">
|
||||||
|
{item.field}
|
||||||
|
{idx < chain.length - 1 && <i className="ri-arrow-left-s-line text-xs ml-1 text-primary">{typeof chain[idx + 1]?.compareMethod === 'object' ? '' : getCompareMethodText(chain[idx + 1]?.compareMethod)}<i className="ri-arrow-right-s-line mr-1 text-xs text-primary" /></i>}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
{chain.map((item: ChainItem, idx: number) => (
|
||||||
|
<button key={`item_${idx}`} className="value-content p-1 cursor-text text-xs border-b border-dashed border-gray-200 last:border-b-0 text-left w-full rounded transition-colors"
|
||||||
|
onClick={(e) => { e.stopPropagation(); if (item.data.page) onReviewPointSelect(reviewPoint.id, Number(item.data.page), item.data.char_positions, item.data.value); else if (reviewPoint.contentPage && reviewPoint.contentPage[item.field]) onReviewPointSelect(reviewPoint.id, Number(reviewPoint.contentPage[item.field]), item.data.char_positions, item.data.value); else toastService.error(`没有找到${item.field}对应的索引内容`); }}
|
||||||
|
type="button">
|
||||||
|
<div className="flex justify-between w-full">
|
||||||
|
<ReactTableTooltip content={item.data.value?.toString() || ''} />
|
||||||
|
{!item.data.page && !(reviewPoint.contentPage && reviewPoint.contentPage[item.field]) && <i className="ri-information-line text-red-500 text-xs" title="没有找到对应的文书内容" />}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="status-indicator w-8 flex items-center justify-center">{res ? <i className="ri-check-line text-success text-base" /> : <i className="ri-alert-line text-warning text-base" />}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standard pair (2 elements)
|
||||||
|
return (
|
||||||
|
<div key={`pair_${ci}`} className={`${itemCls} border border-gray rounded-md overflow-hidden mb-2 ${res ? 'bg-[rgba(246,255,237,1)]' : 'bg-[rgba(255,251,230,1)]'} flex`}>
|
||||||
|
<button className={`value-box hover:shadow-[0_0_10px_rgba(0,0,0,0.1)] flex-1 p-2 border-r-2 ${res ? 'border-green-200' : 'border-yellow-200'} text-left ${res ? 'hover:bg-[rgba(0,128,0,0.1)]' : 'hover:bg-[rgba(255,255,0,0.1)]'} transition-colors flex flex-col`}
|
||||||
|
onClick={(e) => { e.stopPropagation(); if (chain[0].data.page) onReviewPointSelect(reviewPoint.id, chain[0].data.page, chain[0].data.char_positions, chain[0].data.value); else if (reviewPoint.contentPage && reviewPoint.contentPage[chain[0].field]) onReviewPointSelect(reviewPoint.id, Number(reviewPoint.contentPage[chain[0].field]), chain[0].data.char_positions, chain[0].data.value); else toastService.error(`没有找到${chain[0].field}对应的索引内容`); }}
|
||||||
|
type="button">
|
||||||
|
<div className="value-source text-xs text-gray-500 mb-1">{chain[0].field}</div>
|
||||||
|
<ReactTableTooltip content={chain[0].data.value?.toString() || ''} />
|
||||||
|
</button>
|
||||||
|
<button className={`value-box flex flex-col flex-1 p-2 text-left ${res ? 'hover:bg-[rgba(0,128,0,0.1)]' : 'hover:bg-[rgba(255,255,0,0.1)]'} transition-colors hover:shadow-[0_0_10px_rgba(0,0,0,0.1)]`}
|
||||||
|
onClick={(e) => { e.stopPropagation(); if (chain[1].data.page) onReviewPointSelect(reviewPoint.id, chain[1].data.page, chain[1].data.char_positions, chain[1].data.value); else if (reviewPoint.contentPage && reviewPoint.contentPage[chain[1].field]) onReviewPointSelect(reviewPoint.id, Number(reviewPoint.contentPage[chain[1].field]), chain[1].data.char_positions, chain[1].data.value); else toastService.error(`没有找到${chain[1].field}对应的索引内容`); }}
|
||||||
|
type="button">
|
||||||
|
<div className="value-source text-xs text-gray-500 mb-1">{chain[1].field}</div>
|
||||||
|
<ReactTableTooltip content={chain[1].data.value?.toString() || ''} />
|
||||||
|
</button>
|
||||||
|
<div className="w-8 flex items-center justify-center border-l border-gray-200" onMouseEnter={(e) => { const r = e.currentTarget.getBoundingClientRect(); showTooltip(<div className="flex flex-row gap-2">{chain.slice(1).map((item: ChainItem, i: number) => <div key={i} className="rounded-md flex flex-row items-center"><div className="text-xs text-gray-600 pl-1 whitespace-nowrap">{typeof item.compareMethod === 'object' ? '' : `${getCompareMethodText(item.compareMethod)}:`}</div><div className="p-1 text-xs rounded-full min-w-[50px] text-center">{res ? '通过' : '不通过'}</div></div>)}</div>, { top: r.top + r.height / 2, left: r.left }); }} onMouseLeave={hideTooltip}>
|
||||||
|
{res ? <i className="ri-check-line text-success text-base" /> : <i className="ri-alert-line text-warning text-base" />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── renderModelRule ──
|
||||||
|
function RenderModelRule({ rule, reviewPoint, onReviewPointSelect, fileFormat }: { rule: Record<string, unknown>; reviewPoint: ReviewPoint; onReviewPointSelect: (id: string, page?: number, charPos?: CharPosition[], value?: string) => void; fileFormat?: string }) {
|
||||||
|
const config = rule.config as { model?: string; fields?: Record<string, { page: number | string; value: string; char_positions?: CharPosition[]; res?: boolean }>; ai_suggestion?: { summary?: string; analysis?: { failure_reason?: string; solution_approach?: string; rule_understanding?: string }; suggestions?: Record<string, { reason: string; source: { page: number | null; type: string; field: string | null }; priority: string; confidence: number; suggested_value: string | null }>; generated_at?: string }; message?: string; res?: boolean } | undefined;
|
||||||
|
|
||||||
|
if (config?.res !== reviewPoint.result) return null;
|
||||||
|
if (!config) return null;
|
||||||
|
|
||||||
|
const fieldElements: JSX.Element[] = [];
|
||||||
|
if (config.fields) {
|
||||||
|
Object.entries(config.fields).forEach(([key, value], index) => {
|
||||||
|
const res = value.res !== undefined && value.res !== null ? value.res : value.value.trim() !== '';
|
||||||
|
fieldElements.push(
|
||||||
|
<button key={`field-${index}`} className={`border border-gray w-full rounded-md overflow-hidden mb-2 ${res ? 'bg-[rgba(246,255,237,1)]' : 'bg-[rgba(255,251,230,1)]'} flex hover:shadow-[0_0_10px_rgba(0,0,0,0.1)] ${res ? 'hover:bg-[rgba(0,128,0,0.1)]' : 'hover:bg-[rgba(255,255,0,0.1)]'}`}
|
||||||
|
onClick={(e) => { e.stopPropagation(); if (value.page) onReviewPointSelect(reviewPoint.id, Number(value.page), value.char_positions, value.value); else if (reviewPoint.contentPage && reviewPoint.contentPage[key]) onReviewPointSelect(reviewPoint.id, Number(reviewPoint.contentPage[key]), value.char_positions, value.value); else toastService.error(`没有找到${key}对应的索引内容`); }}
|
||||||
|
type="button">
|
||||||
|
<div className="p-1 flex-1 text-left">
|
||||||
|
<div className="text-xs text-left text-gray-500 mb-1">{key}{!value.page && !(reviewPoint.contentPage && reviewPoint.contentPage[key]) && <i className="ri-information-line text-red-500 text-xs ml-1" />}{!res && !value.value && <span className="ml-2 text-xs text-yellow-500">缺失</span>}</div>
|
||||||
|
{value.value && <ReactTableTooltip content={value.value} />}
|
||||||
|
</div>
|
||||||
|
<div className="w-8 flex items-center justify-center rounded-r-md" onMouseEnter={(e) => { const r = e.currentTarget.getBoundingClientRect(); showTooltip(<div className="flex flex-row gap-2"><div className="rounded-md flex flex-row items-center"><div className="text-xs text-gray-600 pl-1 whitespace-nowrap">大模型判断:</div><div className="p-1 text-xs rounded-full min-w-[50px] text-center">{res ? '通过' : '不通过'}</div></div></div>, { top: r.top + r.height / 2, left: r.left }); }} onMouseLeave={hideTooltip}>
|
||||||
|
{res ? <i className="ri-check-line text-success text-base" /> : <i className="ri-alert-line text-warning text-base" />}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.message) {
|
||||||
|
const msg = typeof config.message === 'object' ? JSON.stringify(config.message) : String(config.message);
|
||||||
|
fieldElements.push(
|
||||||
|
<div key="message" className="mb-3 select-text">
|
||||||
|
<div className="text-[11px] font-medium text-slate-400 uppercase tracking-wider mb-2 flex items-center gap-1">
|
||||||
|
<i className="ri-sparkling-2-fill text-fuchsia-500 text-[12px]" /> AI 评查意见
|
||||||
|
</div>
|
||||||
|
<div className="p-2 bg-blue-50 rounded border border-blue-200 text-xs">
|
||||||
|
<div className="flex flex-row items-center">
|
||||||
|
<p className="text-xs text-gray-600 select-text mb-0">{msg}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{fieldElements}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main Component ──
|
||||||
|
export function ReviewPointDetailCard({ reviewPoint, onReviewPointSelect, onStatusChange, fileFormat }: ReviewPointDetailCardProps) {
|
||||||
|
const [manualNote, setManualNote] = useState(
|
||||||
|
() => reviewPoint.editAuditStatusMessage || reviewPoint.actionContent || reviewPoint.suggestion || ''
|
||||||
|
);
|
||||||
|
|
||||||
|
// reviewPoint 切换时重置默认值
|
||||||
|
useEffect(() => {
|
||||||
|
setManualNote(reviewPoint.editAuditStatusMessage || reviewPoint.actionContent || reviewPoint.suggestion || '');
|
||||||
|
}, [reviewPoint.id, reviewPoint.editAuditStatusMessage, reviewPoint.actionContent, reviewPoint.suggestion]);
|
||||||
|
|
||||||
|
const otherRules = filterOtherRule(reviewPoint);
|
||||||
|
const isPass = reviewPoint.result === true;
|
||||||
|
const isFail = reviewPoint.result === false && reviewPoint.status === 'error';
|
||||||
|
const isWarn = reviewPoint.result === false && (reviewPoint.status === 'warning' || reviewPoint.status === 'info');
|
||||||
|
|
||||||
|
const statusChip = isPass
|
||||||
|
? { cls: 'bg-emerald-50 text-emerald-700 border-emerald-200', icon: 'ri-checkbox-circle-fill', label: '通过' }
|
||||||
|
: isFail
|
||||||
|
? { cls: 'bg-red-50 text-red-700 border-red-200', icon: 'ri-close-circle-fill', label: '不通过' }
|
||||||
|
: isWarn
|
||||||
|
? { cls: 'bg-amber-50 text-amber-700 border-amber-200', icon: 'ri-lightbulb-flash-fill', label: '警告' }
|
||||||
|
: { cls: 'bg-slate-100 text-slate-600 border-slate-200', icon: 'ri-forbid-2-line', label: '未涉及' };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="bg-white border border-slate-200 rounded-lg shadow-sm overflow-hidden">
|
||||||
|
<TooltipPortal />
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<header className="px-4 pt-4 pb-3 border-b border-slate-100">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
{reviewPoint.pointId && (
|
||||||
|
<span className="font-mono text-[11px] px-1.5 py-0.5 rounded bg-slate-100 text-slate-600 shrink-0">
|
||||||
|
#{reviewPoint.pointId}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<h2 className="text-[14.5px] font-semibold text-slate-900 break-all leading-snug">
|
||||||
|
{reviewPoint.pointName}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className={`inline-flex items-center gap-1 px-1.5 h-5 rounded text-[10.5px] border ${statusChip.cls}`}>
|
||||||
|
<i className={statusChip.icon} />{statusChip.label}
|
||||||
|
</span>
|
||||||
|
{ reviewPoint.postAction === 'manual' && (
|
||||||
|
<span className="inline-flex items-center gap-1 px-1.5 h-5 rounded text-[10.5px] bg-slate-50 text-slate-600 border border-slate-200">
|
||||||
|
<i className="ri-user-line" />需人工
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{reviewPoint.score != null && (
|
||||||
|
<span className="text-[11px] text-slate-500 shrink-0">
|
||||||
|
分值 <span className={`font-mono font-medium ${isPass ? 'text-emerald-600' : isFail ? 'text-red-600' : isWarn ? 'text-amber-600' : 'text-slate-500'}`}>{reviewPoint.score}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Rule content */}
|
||||||
|
<section className="px-4 pt-3">
|
||||||
|
{otherRules.map((rule, i) => (
|
||||||
|
<RenderOtherRule key={`other-${i}`} rule={rule} reviewPoint={reviewPoint} onReviewPointSelect={onReviewPointSelect} />
|
||||||
|
))}
|
||||||
|
{reviewPoint.evaluatedPointResultsLog?.rules?.map((rule, i) => {
|
||||||
|
if (rule.type === 'consistency') {
|
||||||
|
return <div key={`rule-${i}`}>{otherRules.length > 0 && <div className="bg-gray-50 rounded border border-gray-200 text-xs mb-3" />}<RenderConsistencyRule rule={rule} reviewPoint={reviewPoint} onReviewPointSelect={onReviewPointSelect} /></div>;
|
||||||
|
}
|
||||||
|
if (rule.type === 'ai') {
|
||||||
|
return <div key={`rule-${i}`}>{otherRules.length > 0 && <div className="bg-gray-50 rounded border border-gray-200 text-xs mb-3" />}<RenderModelRule rule={rule} reviewPoint={reviewPoint} onReviewPointSelect={onReviewPointSelect} fileFormat={fileFormat} /></div>;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Suggestion */}
|
||||||
|
{reviewPoint.suggestion && !isPass && (
|
||||||
|
<section className="px-4 pt-3">
|
||||||
|
<div className="text-[11px] font-medium text-slate-400 uppercase tracking-wider mb-2 flex items-center gap-1">
|
||||||
|
<i className="ri-edit-2-line" /> 修改建议
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-amber-50/50 border border-amber-200 rounded-md text-[12.5px] text-slate-700 leading-relaxed">
|
||||||
|
{reviewPoint.suggestion}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Manual review textarea */}
|
||||||
|
{reviewPoint.postAction === 'manual' && (
|
||||||
|
<section className="px-4 pt-3">
|
||||||
|
<div className="text-[11px] font-medium text-slate-400 uppercase tracking-wider mb-2">审核意见</div>
|
||||||
|
<textarea
|
||||||
|
rows={2}
|
||||||
|
placeholder="请输入审核意见..."
|
||||||
|
className={`w-full p-2 border border-slate-200 rounded-md text-[12.5px] min-h-[56px] focus:outline-none focus:border-[#00684a] focus:ring-2 focus:ring-[#00684a]/15 resize-none placeholder:text-slate-400 ${reviewPoint.editAuditStatus !== 0 ? 'bg-gray-100 cursor-not-allowed' : 'bg-white'}`}
|
||||||
|
value={manualNote}
|
||||||
|
onChange={(e) => setManualNote(e.target.value)}
|
||||||
|
disabled={reviewPoint.editAuditStatus !== 0}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Footer actions — 需人工 + 未审核:通过/不通过 */}
|
||||||
|
{reviewPoint.postAction === 'manual' && reviewPoint.editAuditStatus === 0 && (
|
||||||
|
<footer className="mt-3 px-4 py-3 flex items-center justify-end gap-2 border-t border-slate-100 bg-slate-50/60">
|
||||||
|
<button type="button" className="h-8 px-3 rounded-md text-[12.5px] bg-[#1890ff] text-white hover:bg-blue-600 flex items-center gap-1 font-medium" onClick={() => {
|
||||||
|
if (manualNote.trim() === '') { toastService.error('请输入审核意见'); return; }
|
||||||
|
onStatusChange(reviewPoint.id, reviewPoint.editAuditStatusId || '', 'true', manualNote);
|
||||||
|
}}>
|
||||||
|
<i className="ri-check-line" />通过
|
||||||
|
</button>
|
||||||
|
<button type="button" className="h-8 px-3 rounded-md text-[12.5px] bg-[#f5222d] text-white hover:bg-red-600 flex items-center gap-1 font-medium shadow-sm" onClick={() => {
|
||||||
|
if (manualNote.trim() === '') { toastService.error('请输入审核意见'); return; }
|
||||||
|
onStatusChange(reviewPoint.id, reviewPoint.editAuditStatusId || '', 'false', manualNote);
|
||||||
|
}}>
|
||||||
|
<i className="ri-close-line" />不通过
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 已审核状态:重新审核 */}
|
||||||
|
{reviewPoint.postAction === 'manual' && reviewPoint.editAuditStatus !== 0 && (
|
||||||
|
<footer className="mt-3 px-4 py-3 flex items-center justify-end border-t border-slate-100 bg-slate-50/60">
|
||||||
|
<button type="button" className="h-8 px-3 rounded-md text-[12.5px] bg-purple-600 text-white hover:bg-purple-700 flex items-center gap-1 font-medium" onClick={() => {
|
||||||
|
onStatusChange(reviewPoint.id, reviewPoint.editAuditStatusId || '', 'review', '');
|
||||||
|
}}>
|
||||||
|
<i className="ri-refresh-line" />重新审核
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isPass && reviewPoint.postAction !== 'manual' && (
|
||||||
|
<footer className="px-4 py-3 flex items-center gap-2 border-t border-slate-100 bg-slate-50/60">
|
||||||
|
<span className="text-[11px] text-emerald-700 inline-flex items-center gap-1">
|
||||||
|
<i className="ri-verified-badge-fill" />已自动通过
|
||||||
|
</span>
|
||||||
|
</footer>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
+10
-5
@@ -139,16 +139,21 @@ function isDynamicIdSegment(segment: string): boolean {
|
|||||||
* @returns true 表示允许访问,false 表示拒绝访问
|
* @returns true 表示允许访问,false 表示拒绝访问
|
||||||
*/
|
*/
|
||||||
function isPathAllowed(pathname: string, allowedPaths: string[]): boolean {
|
function isPathAllowed(pathname: string, allowedPaths: string[]): boolean {
|
||||||
// 1. 精确匹配
|
// --- 开发测试 START:reviewsTest 复用 reviews 的权限(测试完后 git checkout app/root.tsx 还原)---
|
||||||
if (allowedPaths.includes(pathname)) {
|
const testPath = pathname.replace(/^\/reviewsTest/, '/reviews');
|
||||||
|
const checkPath = testPath !== pathname ? testPath : pathname;
|
||||||
|
// --- 开发测试 END ---
|
||||||
|
|
||||||
|
// 1. 精确匹配(原版用 pathname,测试期间用 checkPath)
|
||||||
|
if (allowedPaths.includes(checkPath)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 动态路由匹配(只允许看起来像ID的子路径)
|
// 2. 动态路由匹配(只允许看起来像ID的子路径)
|
||||||
for (const allowedPath of allowedPaths) {
|
for (const allowedPath of allowedPaths) {
|
||||||
if (pathname.startsWith(allowedPath + '/')) {
|
if (checkPath.startsWith(allowedPath + '/')) {
|
||||||
// 提取子路径部分(例如:'/documents/123' -> '123')
|
// 提取子路径部分(例如:'/documents/123' -> '123')
|
||||||
const subPath = pathname.substring(allowedPath.length + 1);
|
const subPath = checkPath.substring(allowedPath.length + 1);
|
||||||
|
|
||||||
// 支持多级嵌套路由(例如:/documents/123/edit)
|
// 支持多级嵌套路由(例如:/documents/123/edit)
|
||||||
const segments = subPath.split('/');
|
const segments = subPath.split('/');
|
||||||
@@ -166,7 +171,7 @@ function isPathAllowed(pathname: string, allowedPaths: string[]): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 3. 根路径特殊处理(仅根路径 '/' 对所有已登录用户开放)
|
// 3. 根路径特殊处理(仅根路径 '/' 对所有已登录用户开放)
|
||||||
if (pathname === '/') {
|
if (checkPath === '/') {
|
||||||
return true; // 根路径重定向到首页,始终允许
|
return true; // 根路径重定向到首页,始终允许
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1164,6 +1164,7 @@ export default function DocumentsIndex() {
|
|||||||
{canView && (
|
{canView && (
|
||||||
<Link
|
<Link
|
||||||
to={`/reviews?id=${historyDoc.id}&previousRoute=documents`}
|
to={`/reviews?id=${historyDoc.id}&previousRoute=documents`}
|
||||||
|
// to={`/reviews?id=${historyDoc.id}&previousRoute=documents`}
|
||||||
className="text-xs px-2 py-1 h-7 mr-1 hover:underline"
|
className="text-xs px-2 py-1 h-7 mr-1 hover:underline"
|
||||||
>
|
>
|
||||||
<i className="ri-eye-line"></i>
|
<i className="ri-eye-line"></i>
|
||||||
|
|||||||
+299
-280
@@ -26,33 +26,29 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { type MetaFunction, type LoaderFunctionArgs, type ActionFunctionArgs } from "@remix-run/node";
|
import { type MetaFunction, type LoaderFunctionArgs, type ActionFunctionArgs } from "@remix-run/node";
|
||||||
import { useState, useEffect, useRef, useCallback } from "react";
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
import { useNavigate, useLoaderData, useFetcher } from "@remix-run/react";
|
import { useNavigate, useLoaderData, useFetcher, useRevalidator } from "@remix-run/react";
|
||||||
import type { FilePreviewHandle } from "~/components/reviews/FilePreview";
|
|
||||||
import reviewsStyles from "~/styles/reviews.css?url";
|
import reviewsStyles from "~/styles/reviews.css?url";
|
||||||
import { getReviewPoints, getReviewPoints_fromApi, getUnifiedEvaluationResults, updateReviewResult, confirmReviewResults } from "~/api/evaluation_points/reviews";
|
import { getReviewPoints, getReviewPoints_fromApi, getUnifiedEvaluationResults, updateReviewResult, confirmReviewResults } from "~/api/evaluation_points/reviews";
|
||||||
import { postgrestGet } from "~/api/postgrest-client";
|
import { postgrestGet } from "~/api/postgrest-client";
|
||||||
import { toastService } from "~/components/ui/Toast";
|
import { toastService } from "~/components/ui/Toast";
|
||||||
|
import { Modal } from "~/components/ui/Modal";
|
||||||
|
import { UploadArea, type UploadAreaRef } from "~/components/ui/UploadArea";
|
||||||
|
import { Button } from "~/components/ui/Button";
|
||||||
|
import { uploadContractTemplate } from "~/api/files/files-upload";
|
||||||
|
import { Comparison } from "~/components/reviews/Comparison";
|
||||||
|
import axios from "axios";
|
||||||
|
|
||||||
// 导入评查详情页面组件
|
// 导入新的三栏组件
|
||||||
import {
|
import { RulesDirectory } from "~/components/reviews/leftColumn/RulesDirectory";
|
||||||
FileInfo,
|
import { DetailPanel } from "~/components/reviews/rightColumn/DetailPanel";
|
||||||
ReviewTabs,
|
import { PdfPreviewTest } from "~/components/reviews/previewComponents/PdfPreviewTest";
|
||||||
FilePreview,
|
import { DocxPreviewTest } from "~/components/reviews/previewComponents/DocxPreviewTest";
|
||||||
ReviewPointsList,
|
|
||||||
AIAnalysis,
|
|
||||||
FileDetails,
|
|
||||||
Comparison
|
|
||||||
} from "~/components/reviews";
|
|
||||||
|
|
||||||
// 导入文档对比组件
|
|
||||||
import { ComparePreview } from "~/components/reviews/previewComponents/ComparePreview";
|
import { ComparePreview } from "~/components/reviews/previewComponents/ComparePreview";
|
||||||
|
|
||||||
// 从ReviewPointsList组件中导入ReviewPoint类型
|
|
||||||
import { type ReviewPoint } from '~/components/reviews';
|
import { type ReviewPoint } from '~/components/reviews';
|
||||||
import { messageService } from "~/components/ui/MessageModal";
|
import { messageService } from "~/components/ui/MessageModal";
|
||||||
import { loadingBarService } from "~/components/ui/LoadingBar";
|
import { loadingBarService } from "~/components/ui/LoadingBar";
|
||||||
import { Breadcrumb } from "~/components/layout/Breadcrumb";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 文件信息组件
|
* 文件信息组件
|
||||||
@@ -176,7 +172,9 @@ export function links() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const handle = {
|
export const handle = {
|
||||||
hideBreadcrumb: true
|
hideBreadcrumb: true,
|
||||||
|
collapseSidebar: true,
|
||||||
|
noPadding: true
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function loader({ request }: LoaderFunctionArgs) {
|
export async function loader({ request }: LoaderFunctionArgs) {
|
||||||
@@ -405,11 +403,10 @@ export default function ReviewDetails() {
|
|||||||
const { document, reviewPoints, statistics, reviewInfo, comparison_document, frontendJWT } = loaderData;
|
const { document, reviewPoints, statistics, reviewInfo, comparison_document, frontendJWT } = loaderData;
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(false); // 已经通过loader加载了数据,不需要再显示加载状态
|
const [isLoading, setIsLoading] = useState(false); // 已经通过loader加载了数据,不需要再显示加载状态
|
||||||
const [activeTab, setActiveTab] = useState<string>('preview'); // 'preview', 'analysis', 'fileinfo'
|
const [rightActiveTab, setRightActiveTab] = useState<'result' | 'fields' | 'info'>('result');
|
||||||
const [reviewData, setReviewData] = useState<ReviewData | null>(null);
|
const [reviewData, setReviewData] = useState<ReviewData | null>(null);
|
||||||
const [activeReviewPointResultId, setActiveReviewPointResultId] = useState<string | null>(null);
|
const [activeReviewPointResultId, setActiveReviewPointResultId] = useState<string | null>(null);
|
||||||
const [targetPage, setTargetPage] = useState<number | undefined>(undefined);
|
const [targetPage, setTargetPage] = useState<number | undefined>(undefined);
|
||||||
const [templateTargetPage, setTemplateTargetPage] = 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 [highlightValue, setHighlightValue] = useState<string | undefined>(undefined);
|
const [highlightValue, setHighlightValue] = useState<string | undefined>(undefined);
|
||||||
const [pendingUpdate, setPendingUpdate] = useState<{
|
const [pendingUpdate, setPendingUpdate] = useState<{
|
||||||
@@ -417,69 +414,26 @@ export default function ReviewDetails() {
|
|||||||
newStatus: string;
|
newStatus: string;
|
||||||
message: string;
|
message: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
const [showCompareOverlay, setShowCompareOverlay] = useState(false);
|
||||||
|
|
||||||
|
// 一键替换(DOCX Collabora 使用)
|
||||||
const [aiSuggestionReplace, setAiSuggestionReplace] = useState<{
|
const [aiSuggestionReplace, setAiSuggestionReplace] = useState<{
|
||||||
searchText: string;
|
searchText: string;
|
||||||
replaceText: string;
|
replaceText: string;
|
||||||
pageNumber: number;
|
pageNumber: number;
|
||||||
|
silentReplace?: boolean;
|
||||||
} | undefined>(undefined);
|
} | undefined>(undefined);
|
||||||
|
|
||||||
// FilePreview 组件的 ref,用于在下载前保存文档
|
// 模板上传相关状态
|
||||||
const filePreviewRef = useRef<FilePreviewHandle>(null);
|
const [isReuploadModalOpen, setIsReuploadModalOpen] = useState(false);
|
||||||
|
const [selectedTemplateFiles, setSelectedTemplateFiles] = useState<File[]>([]);
|
||||||
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
const [showComparison, setShowComparison] = useState(false);
|
||||||
|
const uploadAreaRef = useRef<UploadAreaRef>(null);
|
||||||
|
const revalidator = useRevalidator();
|
||||||
|
|
||||||
// CollaboraViewer 组件的 key,用于强制重新加载触发保存
|
// 结构比对按钮显示条件:fileInfo.type 包含 '1'
|
||||||
const [collaboraKey, setCollaboraKey] = useState<number>(0);
|
const showComparisonButton = (document as any)?.type?.toString().includes('1');
|
||||||
|
|
||||||
// 保存文档的回调函数,传递给 ReviewTabs
|
|
||||||
// 通过改变 key 强制重新加载 CollaboraViewer 组件,触发组件卸载时的保存逻辑
|
|
||||||
const handleSaveBeforeDownload = useCallback(async (): Promise<boolean> => {
|
|
||||||
// 检查文件类型是否为 DOCX(需要 Collabora 保存)
|
|
||||||
const fileExtension = document?.path?.split('.').pop()?.toLowerCase();
|
|
||||||
if (fileExtension !== 'docx') {
|
|
||||||
// 非 DOCX 文件不需要保存
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const collaboraRef = filePreviewRef.current?.collaboraViewerRef?.current;
|
|
||||||
if (!collaboraRef?.isReady) {
|
|
||||||
console.log('[Reviews] Collabora 未就绪,跳过保存');
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// console.log('[Reviews] 通过重新加载 CollaboraViewer 保存文档...');
|
|
||||||
|
|
||||||
// 改变 key 触发组件卸载(会执行保存)和重新挂载
|
|
||||||
setCollaboraKey(prev => prev + 1);
|
|
||||||
|
|
||||||
// 等待组件重新加载完成
|
|
||||||
// 先等待组件卸载和重新挂载
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 500));
|
|
||||||
|
|
||||||
// 轮询检查组件是否重新加载完成
|
|
||||||
const maxWaitTime = 30000; // 最大等待30秒
|
|
||||||
const checkInterval = 500; // 每500ms检查一次
|
|
||||||
let waitedTime = 0;
|
|
||||||
|
|
||||||
while (waitedTime < maxWaitTime) {
|
|
||||||
const newCollaboraRef = filePreviewRef.current?.collaboraViewerRef?.current;
|
|
||||||
if (newCollaboraRef?.isReady) {
|
|
||||||
// console.log('[Reviews] CollaboraViewer 重新加载完成');
|
|
||||||
// 额外等待一小段时间确保文档完全就绪
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 500));
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
await new Promise(resolve => setTimeout(resolve, checkInterval));
|
|
||||||
waitedTime += checkInterval;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.warn('[Reviews] 等待 CollaboraViewer 重新加载超时');
|
|
||||||
return true; // 超时也允许下载
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[Reviews] 保存文档失败:', error);
|
|
||||||
toastService.error('保存文档失败,请重试');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}, [document?.path]);
|
|
||||||
|
|
||||||
// 🐛 调试:打印 loader 返回的完整数据到浏览器控制台
|
// 🐛 调试:打印 loader 返回的完整数据到浏览器控制台
|
||||||
// useEffect(() => {
|
// useEffect(() => {
|
||||||
@@ -524,10 +478,8 @@ export default function ReviewDetails() {
|
|||||||
// 当文档 ID 变化时,清空高亮相关的状态
|
// 当文档 ID 变化时,清空高亮相关的状态
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (document?.id) {
|
if (document?.id) {
|
||||||
// console.log('[Reviews] 文档ID变化,清空高亮状态');
|
|
||||||
setActiveReviewPointResultId(null);
|
setActiveReviewPointResultId(null);
|
||||||
setTargetPage(undefined);
|
setTargetPage(undefined);
|
||||||
setTemplateTargetPage(undefined);
|
|
||||||
setCharPositions(undefined);
|
setCharPositions(undefined);
|
||||||
setHighlightValue(undefined);
|
setHighlightValue(undefined);
|
||||||
}
|
}
|
||||||
@@ -572,8 +524,63 @@ export default function ReviewDetails() {
|
|||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}, [document, reviewPoints, statistics, reviewInfo]);
|
}, [document, reviewPoints, statistics, reviewInfo]);
|
||||||
|
|
||||||
const handleTabChange = (tabKey: string) => {
|
const handleTabChange = (tabKey: 'result' | 'fields' | 'info') => {
|
||||||
setActiveTab(tabKey);
|
setRightActiveTab(tabKey);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 从左栏选择评查点
|
||||||
|
const handleRuleSelect = (id: string) => {
|
||||||
|
setActiveReviewPointResultId(id);
|
||||||
|
setRightActiveTab('result');
|
||||||
|
|
||||||
|
// 查找评查点并尝试跳转到其页面
|
||||||
|
const point = reviewData?.reviewPoints.find(p => p.id === id);
|
||||||
|
if (point) {
|
||||||
|
console.log('跳转到评查点页面:', point);
|
||||||
|
const page = getFirstPageFromPoint(point);
|
||||||
|
if (page) setTargetPage(page);
|
||||||
|
else setTargetPage(undefined);
|
||||||
|
setCharPositions(undefined);
|
||||||
|
setHighlightValue(undefined);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 从评查点中提取第一个有效页码
|
||||||
|
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 () => {
|
||||||
|
try {
|
||||||
|
const downloadUrl = `/api/pdf-proxy?path=${encodeURIComponent(document?.path || '')}`;
|
||||||
|
const response = await axios.get(downloadUrl, { responseType: 'blob' });
|
||||||
|
const blobUrl = URL.createObjectURL(response.data);
|
||||||
|
const a = window.document.createElement('a');
|
||||||
|
a.style.display = 'none';
|
||||||
|
a.href = blobUrl;
|
||||||
|
a.download = decodeURIComponent(document?.path?.split('/').pop() || 'document');
|
||||||
|
window.document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
setTimeout(() => {
|
||||||
|
window.document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(blobUrl);
|
||||||
|
}, 100);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('下载文件失败:', error);
|
||||||
|
toastService.error(`下载文件失败: ${error instanceof Error ? error.message : '未知错误'}`);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleReviewPointSelect = (reviewPointId: string, page?: number, charPos?: Array<{ box: number[][], char: string, score: number }>, value?: string) => {
|
const handleReviewPointSelect = (reviewPointId: string, page?: number, charPos?: Array<{ box: number[][], char: string, score: number }>, value?: string) => {
|
||||||
@@ -598,19 +605,9 @@ export default function ReviewDetails() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 处理AI建议替换
|
// 处理AI建议替换(保留空实现,右栏详情卡片中DOCX替换需要)
|
||||||
const handleAiSuggestionReplace = (searchText: string, replaceText: string, pageNumber: number) => {
|
const handleAiSuggestionReplace = (_searchText: string, _replaceText: string, _pageNumber: number) => {
|
||||||
// console.log('[Reviews] AI建议替换:', { searchText, replaceText, pageNumber });
|
// PDF 文件不支持替换,暂不实现
|
||||||
// 设置替换参数,触发 CollaboraViewer 的搜索替换
|
|
||||||
setAiSuggestionReplace({
|
|
||||||
searchText,
|
|
||||||
replaceText,
|
|
||||||
pageNumber
|
|
||||||
});
|
|
||||||
// 短暂延迟后清除参数,以便下次可以重新触发
|
|
||||||
setTimeout(() => {
|
|
||||||
setAiSuggestionReplace(undefined);
|
|
||||||
}, 500);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 刷新评审数据
|
// 刷新评审数据
|
||||||
@@ -841,220 +838,241 @@ export default function ReviewDetails() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 构建自定义面包屑项
|
// 构建返回路径
|
||||||
const getBreadcrumbItems = () => {
|
const getReturnUrl = () => {
|
||||||
const items = [
|
if (loaderData.previousRoute === 'filesUpload') return '/files/upload';
|
||||||
{ title: "评查详情", to: `/reviews?id=${document?.id}` }
|
if (loaderData.previousRoute === 'rulesFiles') return '/rules-files';
|
||||||
];
|
return '/documents/list';
|
||||||
|
|
||||||
// 添加前置路由
|
|
||||||
if (loaderData.previousRoute) {
|
|
||||||
if (loaderData.previousRoute === 'filesUpload') {
|
|
||||||
items.unshift({ title: "文件上传", to: "/files/upload" });
|
|
||||||
} else if (loaderData.previousRoute === 'documents') {
|
|
||||||
items.unshift({ title: "文档列表", to: "/documents/list" });
|
|
||||||
} else if (loaderData.previousRoute === 'rulesFiles') {
|
|
||||||
items.unshift({ title: "评查文件列表", to: "/rules-files" });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return items;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 获取当前激活的评查点对象
|
||||||
|
const activeReviewPoint = reviewData?.reviewPoints.find(p => p.id === activeReviewPointResultId) || null;
|
||||||
|
|
||||||
|
// ── 模板上传相关函数 ──
|
||||||
|
const handleOpenReuploadModal = () => {
|
||||||
|
setIsReuploadModalOpen(true);
|
||||||
|
setSelectedTemplateFiles([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseReuploadModal = () => {
|
||||||
|
setIsReuploadModalOpen(false);
|
||||||
|
setSelectedTemplateFiles([]);
|
||||||
|
if (uploadAreaRef.current) uploadAreaRef.current.resetFileInput();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTemplateFilesSelected = (files: FileList) => {
|
||||||
|
try {
|
||||||
|
if (files.length > 0) {
|
||||||
|
const validFiles: File[] = [];
|
||||||
|
let hasInvalidFiles = false;
|
||||||
|
Array.from(files).forEach(file => {
|
||||||
|
const fileName = file.name.toLowerCase();
|
||||||
|
const isValidType = file.type === 'application/pdf' || fileName.endsWith('.pdf') ||
|
||||||
|
file.type === 'application/msword' || fileName.endsWith('.doc') || fileName.endsWith('.docx') ||
|
||||||
|
file.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
|
||||||
|
if (isValidType) validFiles.push(file);
|
||||||
|
else hasInvalidFiles = true;
|
||||||
|
});
|
||||||
|
if (hasInvalidFiles) toastService.error('只能上传PDF或Word格式的文件');
|
||||||
|
if (validFiles.length > 0) setSelectedTemplateFiles(validFiles);
|
||||||
|
}
|
||||||
|
} catch { toastService.error('文件选择失败,请重试'); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmUpload = async () => {
|
||||||
|
if (selectedTemplateFiles.length === 0) { toastService.error('请先选择要上传的模板文件'); return; }
|
||||||
|
if (!(document as any)?.id) { toastService.error('文档ID不能为空'); return; }
|
||||||
|
try {
|
||||||
|
setIsUploading(true);
|
||||||
|
const uploadResult = await uploadContractTemplate(
|
||||||
|
selectedTemplateFiles[0],
|
||||||
|
(document as any).id,
|
||||||
|
(comparison_document as any)?.comparisonId,
|
||||||
|
frontendJWT || undefined
|
||||||
|
);
|
||||||
|
if (uploadResult.error) throw new Error(uploadResult.error);
|
||||||
|
toastService.success('模板文件上传成功,即将返回上一页...');
|
||||||
|
await new Promise(r => setTimeout(r, 2000));
|
||||||
|
handleCloseReuploadModal();
|
||||||
|
navigate(getReturnUrl());
|
||||||
|
} catch (error) {
|
||||||
|
toastService.error(`上传失败: ${error instanceof Error ? error.message : '未知错误'}`);
|
||||||
|
} 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) {
|
||||||
return (
|
return (
|
||||||
<div className="review-container">
|
<div className="flex flex-col h-screen overflow-hidden">
|
||||||
|
<header className="shrink-0 h-11 px-4 flex items-center gap-3 border-b border-slate-200 bg-white">
|
||||||
|
<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" /> 返回
|
||||||
|
</button>
|
||||||
|
<span className="font-medium text-sm text-slate-800 truncate">{reviewData?.fileInfo?.fileName}</span>
|
||||||
|
</header>
|
||||||
|
<div className="flex-1 min-h-0 overflow-auto">
|
||||||
|
<Comparison comparison_document={comparison_document} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-screen overflow-hidden">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex justify-center items-center p-12">
|
<div className="flex justify-center items-center p-12">
|
||||||
<div className="loading-spinner"></div>
|
<div className="loading-spinner"></div>
|
||||||
<span className="ml-3">加载中...</span>
|
<span className="ml-3">加载中...</span>
|
||||||
</div>
|
</div>
|
||||||
) : reviewData && (
|
) : reviewData ? (
|
||||||
<>
|
<main className="flex-1 min-h-0 grid grid-cols-[22%,1fr,30%] p-2">
|
||||||
{/* 自定义面包屑 */}
|
{/* 左栏:规则目录 */}
|
||||||
<div className="flex justify-between items-center mb-4">
|
<RulesDirectory
|
||||||
{/* <Breadcrumb
|
|
||||||
items={getBreadcrumbItems()}
|
|
||||||
className="items-center flex !mb-0"
|
|
||||||
/> */}
|
|
||||||
|
|
||||||
{/* 在面包屑右侧显示精简版的FileInfo */}
|
|
||||||
<div className=" ml-5 text-left flex-1 flex flex-row flex-wrap items-center">
|
|
||||||
<span className="mr-2 text-xl font-medium">
|
|
||||||
{reviewData.fileInfo.fileName}
|
|
||||||
</span>
|
|
||||||
<div className="text-xs text-gray-500 flex items-center">
|
|
||||||
{/* 合同编号:{reviewData.fileInfo.contractNumber} */}
|
|
||||||
{ reviewData.fileInfo.fileType != "1" ? "卷宗" : "合同" }
|
|
||||||
编号:{reviewData.fileInfo.contractNumber}
|
|
||||||
{reviewData.fileInfo.fileSize && (
|
|
||||||
<span className="text-xs text-gray-500 ml-2">
|
|
||||||
| {reviewData.fileInfo.fileSize} | {reviewData.fileInfo.fileFormat} | {reviewData.fileInfo.pageCount}页
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{reviewData.fileInfo.uploadTime && (
|
|
||||||
<div className="text-xs text-gray-500">
|
|
||||||
| 上传时间:{reviewData.fileInfo.uploadTime}
|
|
||||||
{/* | 上传用户:{reviewData.fileInfo.uploadUser} */}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 文件信息和操作按钮 */}
|
|
||||||
{/* 选项卡 */}
|
|
||||||
<ReviewTabs
|
|
||||||
activeTab={activeTab}
|
|
||||||
onTabChange={handleTabChange}
|
|
||||||
fileInfo={{
|
|
||||||
id: document?.id,
|
|
||||||
previousRoute: loaderData.previousRoute,
|
|
||||||
path: document?.path,
|
|
||||||
auditStatus: document?.auditStatus,
|
|
||||||
type: document?.type || document?.type_id,
|
|
||||||
comparisonId: comparison_document?.id ? Number(comparison_document.id) : undefined
|
|
||||||
}}
|
|
||||||
onConfirmResults={handleConfirmResults}
|
|
||||||
jwtToken={frontendJWT}
|
|
||||||
onSaveBeforeDownload={handleSaveBeforeDownload}
|
|
||||||
>
|
|
||||||
{/* 评查结果选项卡内容 */}
|
|
||||||
{activeTab === 'preview' && (
|
|
||||||
<div className="flex flex-col lg:flex-row space-y-4 lg:space-y-0 lg:space-x-4">
|
|
||||||
{/* {JSON.stringify(document)} */}
|
|
||||||
{/* 左侧:文件预览 */}
|
|
||||||
<div className="w-full lg:w-[65%]">
|
|
||||||
{(() => {
|
|
||||||
// console.log('[Reviews] 准备渲染FilePreview', {
|
|
||||||
// hasDocument: !!document,
|
|
||||||
// documentPath: document?.path,
|
|
||||||
// targetPage,
|
|
||||||
// hasCharPositions: !!charPositions,
|
|
||||||
// charPositionsLength: charPositions?.length
|
|
||||||
// });
|
|
||||||
return (
|
|
||||||
<FilePreview
|
|
||||||
key={`file-preview-${collaboraKey}`}
|
|
||||||
ref={filePreviewRef}
|
|
||||||
fileContent={document}
|
|
||||||
reviewPoints={reviewData.reviewPoints}
|
|
||||||
activeReviewPointResultId={activeReviewPointResultId}
|
|
||||||
targetPage={targetPage}
|
|
||||||
charPositions={charPositions}
|
|
||||||
highlightValue={highlightValue}
|
|
||||||
userInfo={loaderData.userInfo}
|
|
||||||
aiSuggestionReplace={aiSuggestionReplace}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 右侧:评查结果 */}
|
|
||||||
<div className="w-full lg:w-[35%]">
|
|
||||||
{/* {JSON.stringify(reviewData.fileInfo.fileFormat)} */}
|
|
||||||
<ReviewPointsList
|
|
||||||
reviewPoints={reviewData.reviewPoints}
|
reviewPoints={reviewData.reviewPoints}
|
||||||
statistics={reviewData.statistics}
|
statistics={reviewData.statistics}
|
||||||
activeReviewPointResultId={activeReviewPointResultId}
|
activeReviewPointResultId={activeReviewPointResultId}
|
||||||
|
fileName={reviewData.fileInfo.fileName}
|
||||||
|
onRuleSelect={handleRuleSelect}
|
||||||
|
onBack={() => navigate(getReturnUrl())}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 中栏:PDF 预览 */}
|
||||||
|
{/* 中栏:文件预览(根据文件类型切换) */}
|
||||||
|
<section className="flex flex-col min-h-0 bg-slate-100">
|
||||||
|
{document?.path?.split('.').pop()?.toLowerCase() === 'docx' ? (
|
||||||
|
<DocxPreviewTest
|
||||||
|
filePath={document?.path || ''}
|
||||||
|
targetPage={targetPage}
|
||||||
|
charPositions={charPositions}
|
||||||
|
activeReviewPointResultId={activeReviewPointResultId}
|
||||||
|
reviewPoints={reviewData.reviewPoints}
|
||||||
|
highlightValue={highlightValue}
|
||||||
|
aiSuggestionReplace={aiSuggestionReplace}
|
||||||
|
userInfo={(loaderData as any)?.userInfo}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<PdfPreviewTest
|
||||||
|
filePath={document?.path || ''}
|
||||||
|
targetPage={targetPage}
|
||||||
|
charPositions={charPositions}
|
||||||
|
activeReviewPointResultId={activeReviewPointResultId}
|
||||||
|
reviewPoints={reviewData.reviewPoints}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 右栏:详情面板 */}
|
||||||
|
<DetailPanel
|
||||||
|
activeTab={rightActiveTab}
|
||||||
|
onTabChange={handleTabChange}
|
||||||
|
activeReviewPoint={activeReviewPoint}
|
||||||
|
reviewPoints={reviewData.reviewPoints}
|
||||||
|
fileInfo={reviewData.fileInfo}
|
||||||
|
reviewInfo={reviewData.reviewInfo}
|
||||||
onReviewPointSelect={handleReviewPointSelect}
|
onReviewPointSelect={handleReviewPointSelect}
|
||||||
onStatusChange={handleReviewPointStatusChange}
|
onStatusChange={handleReviewPointStatusChange}
|
||||||
|
onConfirmResults={handleConfirmResults}
|
||||||
|
onDownload={handleDownloadFile}
|
||||||
|
auditStatus={document?.auditStatus}
|
||||||
fileFormat={reviewData.fileInfo.fileFormat}
|
fileFormat={reviewData.fileInfo.fileFormat}
|
||||||
onAiSuggestionReplace={handleAiSuggestionReplace}
|
onUploadTemplate={handleOpenReuploadModal}
|
||||||
flowType={reviewData.flowType}
|
onComparison={() => setShowComparison(true)}
|
||||||
scoredResults={reviewData.scoredResults}
|
showComparisonButton={showComparisonButton}
|
||||||
scoredSummary={reviewData.scoredSummary}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</main>
|
||||||
</div>
|
) : null}
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 结构比对选项卡内容 */}
|
{/* 结构比对 overlay */}
|
||||||
{activeTab === 'filecompare' && (
|
{showCompareOverlay && comparison_document && (
|
||||||
<div className="w-full" style={{
|
<div className="fixed inset-0 z-50 bg-black/30 flex items-center justify-center">
|
||||||
height: 'calc(100vh - 120px)',
|
<div className="bg-white rounded-lg w-[95vw] h-[90vh] flex flex-col overflow-hidden">
|
||||||
minHeight: '600px',
|
<div className="flex items-center justify-between px-4 py-2 border-b border-slate-200">
|
||||||
display: 'flex',
|
<span className="text-sm font-medium">结构比对</span>
|
||||||
flexDirection: 'column'
|
<button
|
||||||
}}>
|
type="button"
|
||||||
{/* {JSON.stringify(comparison_document?.template_contract_path)} -----{JSON.stringify(document?.path)} */}
|
className="w-8 h-8 grid place-items-center rounded hover:bg-slate-100 text-slate-500"
|
||||||
|
onClick={() => setShowCompareOverlay(false)}
|
||||||
|
>
|
||||||
|
<i className="ri-close-line text-lg" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-h-0">
|
||||||
<ComparePreview
|
<ComparePreview
|
||||||
doc1Path={document?.path || ''}
|
doc1Path={document?.path || ''}
|
||||||
doc2Path={comparison_document?.template_contract_path || ''}
|
doc2Path={comparison_document?.template_contract_path || ''}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 原来的结构比对选项卡内容(已注释) */}
|
|
||||||
{/* {activeTab === 'filecompare' && (
|
|
||||||
<div className="flex flex-col lg:flex-row space-y-4 lg:space-y-0 lg:space-x-4">
|
|
||||||
<div className={`w-full ${comparison_document.template_contract_path ? 'lg:w-[38%]' : 'lg:w-[56%]'}`}>
|
|
||||||
<FilePreview
|
|
||||||
fileContent={document}
|
|
||||||
reviewPoints={reviewData.reviewPoints}
|
|
||||||
activeReviewPointResultId={activeReviewPointResultId}
|
|
||||||
targetPage={targetPage}
|
|
||||||
charPositions={charPositions}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={`w-full ${comparison_document.template_contract_path ? 'lg:w-[38%]' : 'lg:w-[20%]'}`}>
|
|
||||||
<FilePreview
|
|
||||||
fileContent={comparison_document}
|
|
||||||
reviewPoints={[]}
|
|
||||||
activeReviewPointResultId={activeReviewPointResultId}
|
|
||||||
targetPage={templateTargetPage}
|
|
||||||
isStructuredView={true}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="w-full lg:w-[24%]">
|
|
||||||
<Comparison
|
|
||||||
comparison_document={comparison_document}
|
|
||||||
onPageJump={(sourcePage, templatePage) => {
|
|
||||||
if (sourcePage > 0) {
|
|
||||||
if (sourcePage === targetPage) {
|
|
||||||
setTargetPage(undefined);
|
|
||||||
setTimeout(() => setTargetPage(sourcePage), 0);
|
|
||||||
} else {
|
|
||||||
setTargetPage(sourcePage);
|
|
||||||
}
|
|
||||||
console.log(`跳转到主文件第${sourcePage}页`);
|
|
||||||
}
|
|
||||||
if (templatePage > 0) {
|
|
||||||
if (templatePage === templateTargetPage) {
|
|
||||||
setTemplateTargetPage(undefined);
|
|
||||||
setTimeout(() => setTemplateTargetPage(templatePage), 0);
|
|
||||||
} else {
|
|
||||||
setTemplateTargetPage(templatePage);
|
|
||||||
}
|
|
||||||
console.log(`跳转到模板文件第${templatePage}页`);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)} */}
|
|
||||||
|
|
||||||
{/* AI智能分析选项卡内容 */}
|
|
||||||
{activeTab === 'analysis' && (
|
|
||||||
<AIAnalysis
|
|
||||||
analysisData={reviewData.aiAnalysis}
|
|
||||||
score={reviewData.statistics.score}
|
|
||||||
onConfirmResults={handleConfirmResults}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 文件信息选项卡内容 */}
|
{/* 重新上传模板模态框 */}
|
||||||
{activeTab === 'fileinfo' && (
|
<Modal
|
||||||
<FileDetails
|
isOpen={isReuploadModalOpen}
|
||||||
fileInfo={reviewData.fileInfo}
|
onClose={handleCloseReuploadModal}
|
||||||
contractInfo={reviewData.contractInfo}
|
title="重新上传模板"
|
||||||
reviewInfo={reviewData.reviewInfo}
|
size="medium"
|
||||||
|
footer={
|
||||||
|
<div className="flex justify-end space-x-3">
|
||||||
|
<Button type="default" onClick={handleCloseReuploadModal} disabled={isUploading}>取消</Button>
|
||||||
|
<Button type="primary" onClick={handleConfirmUpload} disabled={selectedTemplateFiles.length === 0 || isUploading} icon={isUploading ? 'ri-loader-4-line animate-spin' : undefined}>
|
||||||
|
{isUploading ? '上传中...' : '确定上传'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="text-sm text-gray-600 mb-4">
|
||||||
|
<p>请选择新的模板文件用于结构比对。</p>
|
||||||
|
<p className="mt-2 text-orange-600"><i className="ri-information-line mr-1" />注意:支持PDF和Word格式的文件,上传后将替换当前的比对模板。</p>
|
||||||
|
</div>
|
||||||
|
<UploadArea
|
||||||
|
ref={uploadAreaRef}
|
||||||
|
onFilesSelected={handleTemplateFilesSelected}
|
||||||
|
accept=".pdf,.docx,application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||||
|
multiple={false}
|
||||||
|
icon="ri-file-text-line"
|
||||||
|
buttonText="选择模板文件"
|
||||||
|
mainText="点击或拖拽文件到此区域"
|
||||||
|
tipText={<span className="text-xs text-gray-500">支持格式:.pdf | .docx</span>}
|
||||||
|
disabled={isUploading}
|
||||||
/>
|
/>
|
||||||
|
{selectedTemplateFiles.length > 0 && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<h4 className="text-sm font-medium text-gray-900 mb-2">已选择的文件:</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{selectedTemplateFiles.map((file, index) => {
|
||||||
|
const fileName = file.name.toLowerCase();
|
||||||
|
const fileIcon = fileName.endsWith('.doc') || fileName.endsWith('.docx') ? 'ri-file-word-2-line' : 'ri-file-pdf-line';
|
||||||
|
const iconColor = fileName.endsWith('.doc') || fileName.endsWith('.docx') ? 'text-blue-600' : 'text-red-500';
|
||||||
|
return (
|
||||||
|
<div key={index} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg border">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<i className={`${fileIcon} ${iconColor} mr-2`} />
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-gray-900">{file.name}</div>
|
||||||
|
<div className="text-xs text-gray-500">{formatFileSize(file.size)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button className="text-gray-400 hover:text-red-500 transition-colors" onClick={() => { setSelectedTemplateFiles(prev => prev.filter((_, i) => i !== index)); if (uploadAreaRef.current) uploadAreaRef.current.resetFileInput(); }} disabled={isUploading}>
|
||||||
|
<i className="ri-close-line" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</ReviewTabs>
|
</div>
|
||||||
</>
|
</Modal>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1095,6 +1113,7 @@ function getMockReviewData(): ReviewData {
|
|||||||
success: 6,
|
success: 6,
|
||||||
warning: 7,
|
warning: 7,
|
||||||
error: 2,
|
error: 2,
|
||||||
|
notApplicable: 0,
|
||||||
score: 75
|
score: 75
|
||||||
},
|
},
|
||||||
fileContent: {
|
fileContent: {
|
||||||
|
|||||||
Reference in New Issue
Block a user