Merge branch 'shiy-login' into PingChuan
This commit is contained in:
@@ -6,6 +6,7 @@ import { useState, useEffect, useRef, forwardRef, useImperativeHandle, ChangeEve
|
||||
import { CollaboraViewer, type CollaboraViewerHandle } from '~/components/collabora/CollaboraViewer';
|
||||
import { requestPageInfo, customGotoPage } from '~/components/collabora/lib';
|
||||
import { PdfPreview } from './previewComponents/PdfPreview';
|
||||
import { PdfPreviewTest } from './previewComponents/PdfPreviewTest';
|
||||
import { toastService } from '../ui/Toast';
|
||||
|
||||
// 直接从ReviewPointsList导入类型,避免循环依赖
|
||||
@@ -74,7 +75,7 @@ export interface FilePreviewHandle {
|
||||
}
|
||||
|
||||
// export function FilePreview({ fileContent, reviewPoints, activeReviewPointResultId, targetPage }: FilePreviewProps) {
|
||||
export const FilePreview = forwardRef<FilePreviewHandle, FilePreviewProps>(function FilePreview({ fileContent, activeReviewPointResultId, targetPage, charPositions, highlightValue, isStructuredView = false, userInfo, aiSuggestionReplace, isTemplate = false }, ref) {
|
||||
export const FilePreview = forwardRef<FilePreviewHandle, FilePreviewProps>(function FilePreview({ fileContent, reviewPoints, activeReviewPointResultId, targetPage, charPositions, highlightValue, isStructuredView = false, userInfo, aiSuggestionReplace, isTemplate = false }, ref) {
|
||||
// 获取文件类型
|
||||
const real_path = fileContent.path || fileContent.template_contract_path || '';
|
||||
const fileExtension = real_path.split('.').pop()?.toLowerCase();
|
||||
@@ -232,10 +233,12 @@ export const FilePreview = forwardRef<FilePreviewHandle, FilePreviewProps>(funct
|
||||
const pageOffset = fileContent.ocrResult?.__meta?.page_offset || fileContent.ocr_result?.__meta?.page_offset || 0;
|
||||
// console.log('pageOffset', pageOffset)
|
||||
return (
|
||||
<PdfPreview
|
||||
<PdfPreviewTest
|
||||
// <PdfPreview
|
||||
filePath={real_path}
|
||||
targetPage={targetPage}
|
||||
charPositions={charPositions}
|
||||
reviewPoints={reviewPoints}
|
||||
isStructuredView={isStructuredView}
|
||||
activeReviewPointResultId={activeReviewPointResultId}
|
||||
pageOffset={pageOffset}
|
||||
|
||||
@@ -0,0 +1,631 @@
|
||||
/**
|
||||
* PDF 预览组件(设计稿 7c-简化C-极简 · 中栏实现)
|
||||
*
|
||||
* 与 PdfPreview.tsx 对齐的 props 签名,可直接替换用于测试。
|
||||
* 变化:
|
||||
* - 单页视口(非连续滚动)
|
||||
* - 顶部工具栏:略缩图开关、上/下页、页码、缩放、当前高亮、全屏
|
||||
* - 左侧 132px 略缩图面板(filtered / all 两种模式),真实 react-pdf 渲染
|
||||
* - 状态徽标由 reviewPoints 聚合得到
|
||||
*/
|
||||
import { useState, useEffect, useMemo, useRef, useCallback } from 'react';
|
||||
import { Document, Page, pdfjs } from 'react-pdf';
|
||||
import { toastService } from '~/components/ui/Toast';
|
||||
import type { ReviewPoint } from '../ReviewPointsList';
|
||||
|
||||
import 'react-pdf/dist/esm/Page/TextLayer.css';
|
||||
import 'react-pdf/dist/esm/Page/AnnotationLayer.css';
|
||||
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = '/pdf.worker.js';
|
||||
|
||||
// ============================================================
|
||||
// 类型
|
||||
// ============================================================
|
||||
type WorstStatus = 'fail' | 'warn' | 'pending' | 'pass';
|
||||
|
||||
interface PageAgg {
|
||||
worst: WorstStatus;
|
||||
count: number;
|
||||
issues: number;
|
||||
}
|
||||
|
||||
interface PdfPreviewProps {
|
||||
filePath: string;
|
||||
targetPage?: number;
|
||||
charPositions?: Array<{ box: number[][]; char: string; score: number }>;
|
||||
isStructuredView?: boolean;
|
||||
activeReviewPointResultId?: string | null;
|
||||
pageOffset?: number;
|
||||
onNumPagesChange?: (numPages: number) => void;
|
||||
onZoomChange?: (zoomLevel: number) => void;
|
||||
|
||||
// 新增(可选):用于派生略缩图徽标和当前高亮标签
|
||||
reviewPoints?: ReviewPoint[];
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// ReviewPoint → 状态映射
|
||||
// ============================================================
|
||||
const STATUS_ORDER: Record<WorstStatus, number> = { fail: 0, warn: 1, pending: 2, pass: 3 };
|
||||
|
||||
const STATUS_BADGE: Record<WorstStatus, { cls: string; ic: string }> = {
|
||||
fail: { cls: 'bg-red-500', ic: 'ri-close-circle-fill' },
|
||||
warn: { cls: 'bg-amber-500', ic: 'ri-lightbulb-flash-fill' },
|
||||
pending: { cls: 'bg-orange-500', ic: 'ri-question-fill' },
|
||||
pass: { cls: 'bg-emerald-500', ic: 'ri-checkbox-circle-fill' },
|
||||
};
|
||||
|
||||
function classifyReviewPoint(p: ReviewPoint): WorstStatus | 'skipped' {
|
||||
const status = p.status;
|
||||
if (status === 'notApplicable' || status === 'not_applicable') return 'skipped';
|
||||
if (p.result === true || (p.result === undefined && status === 'success')) return 'pass';
|
||||
if (p.result === false) {
|
||||
if (status === 'error') return 'fail';
|
||||
if (status === 'warning' || status === 'info') return 'warn';
|
||||
}
|
||||
if (status === 'success') return 'pass';
|
||||
if (status === 'warning' || status === 'info') return 'warn';
|
||||
if (status === 'error') return 'fail';
|
||||
return 'pass';
|
||||
}
|
||||
|
||||
/** 把 ReviewPoint 涉及的所有页号(字段级)抽出 */
|
||||
function getPointPages(p: ReviewPoint): number[] {
|
||||
const set = new Set<number>();
|
||||
const addMaybe = (v: unknown) => {
|
||||
const n = typeof v === 'string' ? parseInt(v, 10) : typeof v === 'number' ? v : NaN;
|
||||
if (Number.isFinite(n) && n > 0) set.add(n);
|
||||
};
|
||||
if (p.contentPage) Object.values(p.contentPage).forEach(addMaybe);
|
||||
if (p.content) Object.values(p.content).forEach(v => addMaybe(v?.page));
|
||||
return [...set].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
/** 当前规则在某页涉及的字段名列表 */
|
||||
function getFieldsOnPage(p: ReviewPoint, page: number): string[] {
|
||||
const fields: string[] = [];
|
||||
const matches = (v: unknown) => {
|
||||
const n = typeof v === 'string' ? parseInt(v, 10) : typeof v === 'number' ? v : NaN;
|
||||
return n === page;
|
||||
};
|
||||
if (p.contentPage) {
|
||||
Object.entries(p.contentPage).forEach(([k, v]) => {
|
||||
if (matches(v)) fields.push(k);
|
||||
});
|
||||
}
|
||||
if (!fields.length && p.content) {
|
||||
Object.entries(p.content).forEach(([k, v]) => {
|
||||
if (matches(v?.page)) fields.push(k);
|
||||
});
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 组件
|
||||
// ============================================================
|
||||
export function PdfPreviewTest({
|
||||
filePath,
|
||||
targetPage,
|
||||
charPositions,
|
||||
isStructuredView = false,
|
||||
activeReviewPointResultId,
|
||||
pageOffset = 0,
|
||||
onNumPagesChange,
|
||||
onZoomChange,
|
||||
reviewPoints,
|
||||
}: PdfPreviewProps) {
|
||||
// ---------- 基础状态 ----------
|
||||
const [numPages, setNumPages] = useState<number | null>(null);
|
||||
const [currentPage, setCurrentPage] = useState<number>(1);
|
||||
const [zoomLevel, setZoomLevel] = useState(100);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
|
||||
// 略缩图面板
|
||||
const [showThumbs, setShowThumbs] = useState(true);
|
||||
const [thumbMode, setThumbMode] = useState<'filtered' | 'all'>('filtered');
|
||||
|
||||
// 坐标校准(保留主视口高亮逻辑)
|
||||
const [coordinateScale, setCoordinateScale] = useState(0.83);
|
||||
const [isScaleAutoCalculated, setIsScaleAutoCalculated] = useState(false);
|
||||
|
||||
// 页码跳转输入
|
||||
const [pageInputValue, setPageInputValue] = useState<string>('');
|
||||
|
||||
const viewportRef = useRef<HTMLDivElement>(null);
|
||||
const thumbsPanelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// ---------- 派生数据 ----------
|
||||
const fileUrl = useMemo(
|
||||
() => `/api/pdf-proxy?path=${encodeURIComponent(filePath)}`,
|
||||
[filePath],
|
||||
);
|
||||
|
||||
const activePoint = useMemo<ReviewPoint | undefined>(
|
||||
() => reviewPoints?.find(p => p.id === activeReviewPointResultId),
|
||||
[reviewPoints, activeReviewPointResultId],
|
||||
);
|
||||
|
||||
// 当前规则涉及的页(字段级,带 pageOffset)
|
||||
const rulePages = useMemo<number[]>(() => {
|
||||
if (!activePoint) return [];
|
||||
return getPointPages(activePoint).map(p => p + pageOffset);
|
||||
}, [activePoint, pageOffset]);
|
||||
|
||||
// 每页状态聚合
|
||||
const pageStatusMap = useMemo<Map<number, PageAgg>>(() => {
|
||||
const m = new Map<number, PageAgg>();
|
||||
if (!reviewPoints?.length) return m;
|
||||
reviewPoints.forEach(p => {
|
||||
const cls = classifyReviewPoint(p);
|
||||
if (cls === 'skipped') return;
|
||||
const pages = getPointPages(p).map(x => x + pageOffset);
|
||||
pages.forEach(pg => {
|
||||
const cur = m.get(pg) || { worst: 'pass', count: 0, issues: 0 };
|
||||
if (STATUS_ORDER[cls] < STATUS_ORDER[cur.worst]) cur.worst = cls;
|
||||
cur.count += 1;
|
||||
if (cls !== 'pass') cur.issues += 1;
|
||||
m.set(pg, cur);
|
||||
});
|
||||
});
|
||||
return m;
|
||||
}, [reviewPoints, pageOffset]);
|
||||
|
||||
// 当前高亮标签(工具栏右侧)
|
||||
const highlightLabel = useMemo(() => {
|
||||
if (!activePoint) return null;
|
||||
const code = activePoint.pointCode || activePoint.id;
|
||||
const name = activePoint.pointName || activePoint.title || '';
|
||||
return `${code}${name ? ' · ' + name : ''}`;
|
||||
}, [activePoint]);
|
||||
|
||||
// ---------- 通知上层 ----------
|
||||
useEffect(() => {
|
||||
if (numPages && onNumPagesChange) onNumPagesChange(numPages);
|
||||
}, [numPages, onNumPagesChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (onZoomChange) onZoomChange(zoomLevel);
|
||||
}, [zoomLevel, onZoomChange]);
|
||||
|
||||
// ---------- targetPage 跳转 ----------
|
||||
useEffect(() => {
|
||||
if (targetPage && numPages) {
|
||||
const next = Math.max(1, Math.min(numPages, targetPage + pageOffset));
|
||||
setCurrentPage(next);
|
||||
}
|
||||
}, [targetPage, numPages, pageOffset, activeReviewPointResultId]);
|
||||
|
||||
// ---------- 切换规则:重置略缩图模式为 filtered ----------
|
||||
useEffect(() => {
|
||||
if (activeReviewPointResultId) setThumbMode('filtered');
|
||||
}, [activeReviewPointResultId]);
|
||||
|
||||
// ---------- 文件路径变化:重置坐标自动计算 ----------
|
||||
useEffect(() => {
|
||||
setIsScaleAutoCalculated(false);
|
||||
}, [filePath]);
|
||||
|
||||
// ---------- 略缩图滚动到当前页 ----------
|
||||
useEffect(() => {
|
||||
const host = thumbsPanelRef.current;
|
||||
if (!host) return;
|
||||
const el = host.querySelector<HTMLElement>(`[data-thumb-page="${currentPage}"]`);
|
||||
if (el) el.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
||||
}, [currentPage, thumbMode, showThumbs]);
|
||||
|
||||
// ---------- PDF 加载 ----------
|
||||
const onDocumentLoadSuccess = useCallback(
|
||||
({ numPages: n }: { numPages: number }) => {
|
||||
setNumPages(n);
|
||||
// 初始页:优先 targetPage,否则第 1 页
|
||||
if (targetPage) {
|
||||
setCurrentPage(Math.max(1, Math.min(n, targetPage + pageOffset)));
|
||||
} else {
|
||||
setCurrentPage(p => Math.max(1, Math.min(n, p)));
|
||||
}
|
||||
},
|
||||
[targetPage, pageOffset],
|
||||
);
|
||||
|
||||
const onDocumentLoadError = useCallback((error: Error) => {
|
||||
console.error('PDF加载错误:', error);
|
||||
setLoadError('PDF文档加载失败:' + (error.message || '未知错误'));
|
||||
}, []);
|
||||
|
||||
// ---------- 主页面加载(自动校准坐标) ----------
|
||||
const onMainPageLoadSuccess = useCallback(
|
||||
(page: any) => {
|
||||
if (isScaleAutoCalculated) return;
|
||||
setTimeout(() => {
|
||||
const pdfOriginalWidthPt = page.view?.[2] || page.originalWidth || page.width;
|
||||
const canvas = viewportRef.current?.querySelector(
|
||||
'.pdf-main-canvas .react-pdf__Page__canvas',
|
||||
) as HTMLCanvasElement | null;
|
||||
if (canvas && pdfOriginalWidthPt) {
|
||||
const canvasDisplayWidth = canvas.offsetWidth;
|
||||
const currentScale = zoomLevel / 100;
|
||||
const autoScale = (canvasDisplayWidth / currentScale) / pdfOriginalWidthPt;
|
||||
setCoordinateScale(autoScale);
|
||||
setIsScaleAutoCalculated(true);
|
||||
}
|
||||
}, 200);
|
||||
},
|
||||
[isScaleAutoCalculated, zoomLevel],
|
||||
);
|
||||
|
||||
// ---------- 翻页 / 缩放 ----------
|
||||
const goPrev = () => setCurrentPage(p => Math.max(1, p - 1));
|
||||
const goNext = () => setCurrentPage(p => Math.min(numPages || p, p + 1));
|
||||
const zoomIn = () => setZoomLevel(z => Math.min(200, z + 10));
|
||||
const zoomOut = () => setZoomLevel(z => Math.max(50, z - 10));
|
||||
|
||||
const jumpToHighlight = () => {
|
||||
if (!activePoint || rulePages.length === 0) {
|
||||
toastService.info('当前规则无关联页');
|
||||
return;
|
||||
}
|
||||
const first = rulePages[0];
|
||||
if (numPages && first >= 1 && first <= numPages) setCurrentPage(first);
|
||||
};
|
||||
|
||||
const handlePageInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setPageInputValue(e.target.value.replace(/\D/g, ''));
|
||||
};
|
||||
const handlePageJump = () => {
|
||||
if (!pageInputValue || !numPages) return;
|
||||
const n = parseInt(pageInputValue, 10);
|
||||
if (n > 0 && n <= numPages) {
|
||||
setCurrentPage(n);
|
||||
setPageInputValue('');
|
||||
} else {
|
||||
toastService.warning(`请输入有效页码 (1-${numPages})`);
|
||||
setPageInputValue('');
|
||||
}
|
||||
};
|
||||
|
||||
// ---------- 高亮矩形(对齐原 PdfPreview 的字符位置) ----------
|
||||
const mainHighlight = useMemo(() => {
|
||||
if (!charPositions?.length || !targetPage) {
|
||||
console.log('[PdfPreviewTest] highlight skipped: no charPositions/targetPage', {
|
||||
hasCharPositions: !!charPositions?.length,
|
||||
targetPage,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
if (currentPage !== targetPage + pageOffset) {
|
||||
console.log('[PdfPreviewTest] highlight skipped: page mismatch', {
|
||||
currentPage,
|
||||
targetPage,
|
||||
pageOffset,
|
||||
expected: targetPage + pageOffset,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const scale = zoomLevel / 100;
|
||||
let minX = Infinity,
|
||||
minY = Infinity,
|
||||
maxX = -Infinity,
|
||||
maxY = -Infinity;
|
||||
const chars: string[] = [];
|
||||
charPositions.forEach(cp => {
|
||||
chars.push(cp.char);
|
||||
cp.box.forEach(pt => {
|
||||
const [x, y] = pt;
|
||||
if (x < minX) minX = x;
|
||||
if (x > maxX) maxX = x;
|
||||
if (y < minY) minY = y;
|
||||
if (y > maxY) maxY = y;
|
||||
});
|
||||
});
|
||||
return {
|
||||
x: minX * coordinateScale * scale,
|
||||
y: minY * coordinateScale * scale,
|
||||
width: (maxX - minX) * coordinateScale * scale,
|
||||
height: (maxY - minY) * coordinateScale * scale,
|
||||
text: chars.join(''),
|
||||
};
|
||||
}, [charPositions, targetPage, currentPage, pageOffset, zoomLevel, coordinateScale]);
|
||||
|
||||
// ---------- 略缩图可见页列表 ----------
|
||||
const effThumbMode: 'filtered' | 'all' =
|
||||
thumbMode === 'filtered' && rulePages.length > 0 ? 'filtered' : 'all';
|
||||
|
||||
const thumbPages = useMemo<number[]>(() => {
|
||||
if (!numPages) return [];
|
||||
if (effThumbMode === 'filtered') return rulePages.filter(p => p >= 1 && p <= numPages);
|
||||
return Array.from({ length: numPages }, (_, i) => i + 1);
|
||||
}, [effThumbMode, rulePages, numPages]);
|
||||
|
||||
// ============================================================
|
||||
// 渲染
|
||||
// ============================================================
|
||||
if (loadError) {
|
||||
return (
|
||||
<div className="w-full h-full grid place-items-center text-red-500 p-4">{loadError}</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Document
|
||||
file={fileUrl}
|
||||
onLoadSuccess={onDocumentLoadSuccess}
|
||||
onLoadError={onDocumentLoadError}
|
||||
className="flex flex-col min-h-0 w-full"
|
||||
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>}
|
||||
noData={<div className="flex-1 grid place-items-center text-slate-400">无数据</div>}
|
||||
>
|
||||
<section
|
||||
className="flex flex-col min-h-0 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="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setShowThumbs(s => !s)}
|
||||
className={`w-7 h-7 grid place-items-center rounded hover:bg-slate-100 ${
|
||||
showThumbs ? 'text-primary' : 'text-slate-400'
|
||||
}`}
|
||||
title="显示/隐藏页面缩略图"
|
||||
>
|
||||
<i className="ri-layout-masonry-line"></i>
|
||||
</button>
|
||||
<span className="mx-0.5 text-slate-300">|</span>
|
||||
<button
|
||||
onClick={goPrev}
|
||||
disabled={currentPage <= 1}
|
||||
className="w-7 h-7 grid place-items-center rounded hover:bg-slate-100 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
title="上一页"
|
||||
>
|
||||
<i className="ri-arrow-left-s-line"></i>
|
||||
</button>
|
||||
<span className="font-mono tabular-nums">
|
||||
<input
|
||||
type="text"
|
||||
value={pageInputValue !== '' ? pageInputValue : currentPage}
|
||||
onChange={handlePageInputChange}
|
||||
onFocus={e => e.currentTarget.select()}
|
||||
onBlur={handlePageJump}
|
||||
onKeyDown={e => {
|
||||
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"
|
||||
/>
|
||||
<span className="text-slate-400"> / {numPages ?? '-'}</span>
|
||||
</span>
|
||||
<button
|
||||
onClick={goNext}
|
||||
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"
|
||||
title="下一页"
|
||||
>
|
||||
<i className="ri-arrow-right-s-line"></i>
|
||||
</button>
|
||||
<span className="mx-2 text-slate-300">|</span>
|
||||
<button
|
||||
onClick={zoomOut}
|
||||
disabled={zoomLevel <= 50}
|
||||
className="w-7 h-7 grid place-items-center rounded hover:bg-slate-100 disabled:opacity-40"
|
||||
title="缩小"
|
||||
>
|
||||
<i className="ri-zoom-out-line"></i>
|
||||
</button>
|
||||
<span className="font-mono min-w-[42px] text-center">{zoomLevel}%</span>
|
||||
<button
|
||||
onClick={zoomIn}
|
||||
disabled={zoomLevel >= 200}
|
||||
className="w-7 h-7 grid place-items-center rounded hover:bg-slate-100 disabled:opacity-40"
|
||||
title="放大"
|
||||
>
|
||||
<i className="ri-zoom-in-line"></i>
|
||||
</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"></i>
|
||||
<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"></i>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ═════ 主体:略缩图 + 视口 ═════ */}
|
||||
<div className="flex-1 min-h-0 flex min-w-0">
|
||||
{/* ── 略缩图面板 ── */}
|
||||
{showThumbs && (
|
||||
<div className="w-[132px] shrink-0 bg-white border-r border-slate-200 flex flex-col min-h-0">
|
||||
{/* 模式切换条 */}
|
||||
<div className="shrink-0 px-2 py-2 border-b border-slate-100">
|
||||
<div className="inline-flex rounded border border-slate-200 bg-slate-50 p-0.5 text-[10.5px] w-full">
|
||||
<button
|
||||
onClick={() => setThumbMode('filtered')}
|
||||
disabled={rulePages.length === 0}
|
||||
className={`flex-1 h-5 rounded font-medium ${
|
||||
effThumbMode === 'filtered'
|
||||
? 'bg-white shadow-sm text-primary'
|
||||
: 'text-slate-500 hover:text-slate-700'
|
||||
} ${rulePages.length === 0 ? 'opacity-40 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
只看相关
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setThumbMode('all')}
|
||||
className={`flex-1 h-5 rounded font-medium ${
|
||||
effThumbMode === 'all'
|
||||
? 'bg-white shadow-sm text-primary'
|
||||
: 'text-slate-500 hover:text-slate-700'
|
||||
}`}
|
||||
>
|
||||
全部 {numPages ?? ''}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 略缩图列表 */}
|
||||
<div
|
||||
ref={thumbsPanelRef}
|
||||
className="flex-1 overflow-y-auto py-2 px-2 space-y-2"
|
||||
>
|
||||
{numPages === null ? (
|
||||
<div className="text-center text-[11px] text-slate-400 py-4">加载中…</div>
|
||||
) : thumbPages.length === 0 ? (
|
||||
<div className="text-center text-[11px] text-slate-400 py-8">
|
||||
<i className="ri-forbid-2-line text-2xl text-slate-300"></i>
|
||||
<div className="mt-1">此规则无关联页面</div>
|
||||
</div>
|
||||
) : (
|
||||
thumbPages.map(p => {
|
||||
const info = pageStatusMap.get(p);
|
||||
const isCur = p === currentPage;
|
||||
const isRulePage = rulePages.includes(p);
|
||||
|
||||
let badge: React.ReactNode = null;
|
||||
if (info) {
|
||||
const b = STATUS_BADGE[info.worst];
|
||||
const num = info.issues > 0 ? info.issues : info.worst === 'pass' ? '' : info.count;
|
||||
badge = (
|
||||
<span
|
||||
className={`absolute top-1 right-1 inline-flex items-center justify-center min-w-[14px] h-[14px] px-0.5 rounded-full text-[9px] font-semibold text-white ${b.cls} shadow ring-1 ring-white`}
|
||||
>
|
||||
{num ? num : <i className={`${b.ic} text-[9px]`}></i>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const frameCls = isCur
|
||||
? 'ring-2 ring-primary shadow-md'
|
||||
: effThumbMode === 'all' && isRulePage
|
||||
? 'ring-1 ring-primary/40 shadow-sm'
|
||||
: 'border border-slate-200 group-hover:border-slate-400 shadow-sm';
|
||||
|
||||
let fieldsLabel: React.ReactNode = null;
|
||||
if (effThumbMode === 'filtered' && activePoint) {
|
||||
const fs = getFieldsOnPage(activePoint, p - pageOffset);
|
||||
const txt = fs.length ? fs.join(' · ') : '规则锚定页';
|
||||
fieldsLabel = (
|
||||
<div
|
||||
className="text-[10px] leading-tight text-slate-500 text-center mt-0.5 line-clamp-2"
|
||||
title={txt}
|
||||
>
|
||||
{txt}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={p}
|
||||
data-thumb-page={p}
|
||||
onClick={() => setCurrentPage(p)}
|
||||
className="block w-full group"
|
||||
title={`第 ${p} 页`}
|
||||
>
|
||||
<div className={`relative rounded overflow-hidden bg-white transition ${frameCls}`}>
|
||||
<div className="w-full bg-gradient-to-b from-white to-slate-50 overflow-hidden">
|
||||
<Page
|
||||
pageNumber={p}
|
||||
width={112}
|
||||
renderTextLayer={false}
|
||||
renderAnnotationLayer={false}
|
||||
loading={<div className="w-full h-[150px] bg-slate-50" />}
|
||||
error={<div className="w-full h-[150px] bg-slate-50" />}
|
||||
/>
|
||||
</div>
|
||||
{badge}
|
||||
</div>
|
||||
<div
|
||||
className={`text-center text-[10.5px] mt-1 ${
|
||||
isCur ? 'text-primary font-semibold' : 'text-slate-500 group-hover:text-slate-700'
|
||||
}`}
|
||||
>
|
||||
{p}
|
||||
</div>
|
||||
{fieldsLabel}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── 视口(单页) ── */}
|
||||
<div
|
||||
ref={viewportRef}
|
||||
className="flex-1 min-h-0 min-w-0 overflow-auto p-4 text-center"
|
||||
>
|
||||
<div
|
||||
className="pdf-main-canvas"
|
||||
style={{
|
||||
position: 'relative',
|
||||
display: 'inline-block',
|
||||
margin: '0 auto',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
{numPages !== null && (
|
||||
<>
|
||||
<Page
|
||||
pageNumber={Math.min(currentPage, numPages)}
|
||||
scale={zoomLevel / 100}
|
||||
devicePixelRatio={typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1}
|
||||
renderTextLayer={false}
|
||||
renderAnnotationLayer={false}
|
||||
onLoadSuccess={onMainPageLoadSuccess}
|
||||
className="border border-slate-200 shadow-lg bg-white"
|
||||
loading={<div className="w-[600px] h-[800px] grid place-items-center text-slate-400">页面加载中…</div>}
|
||||
/>
|
||||
{mainHighlight && (
|
||||
<svg
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 12,
|
||||
}}
|
||||
>
|
||||
<rect
|
||||
x={mainHighlight.x}
|
||||
y={mainHighlight.y}
|
||||
width={mainHighlight.width}
|
||||
height={mainHighlight.height}
|
||||
fill="#00AA00"
|
||||
fillOpacity="0.1"
|
||||
stroke="#00684a"
|
||||
strokeWidth="0.5"
|
||||
rx="2"
|
||||
>
|
||||
<title>{`高亮文本: ${mainHighlight.text}`}</title>
|
||||
</rect>
|
||||
</svg>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</Document>
|
||||
);
|
||||
}
|
||||
+51
-54
@@ -59,6 +59,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
||||
if (userRole && frontendJWT) {
|
||||
const { getUserRoutesByRole } = await import('~/api/auth/user-routes');
|
||||
const routesResult = await getUserRoutesByRole(userRole, frontendJWT, true); // includeHidden=true
|
||||
// console.log('🔍 [Index Loader] 顶级路由paths:', routesResult.data?.map(r => r.path));
|
||||
|
||||
if (routesResult.success && routesResult.data) {
|
||||
// 查找 '/settings' 路由及其子路由
|
||||
@@ -439,7 +440,6 @@ export default function Index() {
|
||||
loaderData.entryModules && loaderData.entryModules.length > 0 ? (
|
||||
<>
|
||||
{loaderData.entryModules.map((module) => {
|
||||
// 判断是否为智慧法务助手,如果是且有交叉评查权限,则在其之前插入交叉评查卡片
|
||||
const isLLMModule = module.name === '智慧法务助手';
|
||||
|
||||
// 🔑 如果是智慧法务助手且用户没有访问权限,则不渲染该模块
|
||||
@@ -448,61 +448,58 @@ export default function Index() {
|
||||
}
|
||||
|
||||
return (
|
||||
<React.Fragment key={module.id}>
|
||||
{/* 在智慧法务助手之前插入交叉评查入口 */}
|
||||
{isLLMModule && loaderData.hasCrossCheckingAccess && (
|
||||
<div
|
||||
className="module-card"
|
||||
onClick={handleEnterCrossChecking}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
handleEnterCrossChecking();
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label="交叉评查"
|
||||
>
|
||||
<img
|
||||
src="/images/icon_cross@2x.png"
|
||||
alt="交叉评查"
|
||||
className="w-12 h-12 mx-1"
|
||||
onError={(e) => {
|
||||
// 如果图片加载失败,使用 icon
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
const parent = (e.target as HTMLImageElement).parentElement;
|
||||
if (parent) {
|
||||
const icon = document.createElement('i');
|
||||
icon.className = 'ri-shuffle-line';
|
||||
icon.style.fontSize = '48px';
|
||||
icon.style.color = 'var(--color-primary)';
|
||||
parent.insertBefore(icon, parent.firstChild);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span className="module-name">交叉评查</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 渲染原有模块 */}
|
||||
<div
|
||||
className="module-card"
|
||||
onClick={() => handleModuleClick(module)}
|
||||
onKeyDown={(e) => handleKeyDown(module, e)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={module.name}
|
||||
>
|
||||
<img
|
||||
src={isLLMModule ? '/images/icon_assistant.png' : getModuleIcon(module)}
|
||||
alt={module.name}
|
||||
className="w-12 h-12 mx-1"
|
||||
/>
|
||||
<span className="module-name">{module.name}</span>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
<div
|
||||
key={module.id}
|
||||
className="module-card"
|
||||
onClick={() => handleModuleClick(module)}
|
||||
onKeyDown={(e) => handleKeyDown(module, e)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={module.name}
|
||||
>
|
||||
<img
|
||||
src={isLLMModule ? '/images/icon_assistant.png' : getModuleIcon(module)}
|
||||
alt={module.name}
|
||||
className="w-12 h-12 mx-1"
|
||||
/>
|
||||
<span className="module-name">{module.name}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 交叉评查入口 - 独立渲染,不依赖智慧法务助手模块 */}
|
||||
{loaderData.hasCrossCheckingAccess && (
|
||||
<div
|
||||
className="module-card"
|
||||
onClick={handleEnterCrossChecking}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
handleEnterCrossChecking();
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label="交叉评查"
|
||||
>
|
||||
<img
|
||||
src="/images/icon_cross@2x.png"
|
||||
alt="交叉评查"
|
||||
className="w-12 h-12 mx-1"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
const parent = (e.target as HTMLImageElement).parentElement;
|
||||
if (parent) {
|
||||
const icon = document.createElement('i');
|
||||
icon.className = 'ri-shuffle-line';
|
||||
icon.style.fontSize = '48px';
|
||||
icon.style.color = 'var(--color-primary)';
|
||||
parent.insertBefore(icon, parent.firstChild);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span className="module-name">交叉评查</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center text-gray-500 py-8">
|
||||
|
||||
+2
-52
@@ -872,42 +872,13 @@ export default function ReviewDetails() {
|
||||
<>
|
||||
{/* 自定义面包屑 */}
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<Breadcrumb
|
||||
{/* <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">
|
||||
{/* 评分tag:优秀、合格、不合格、待评价 */}
|
||||
{/* {(() => {
|
||||
const score = "";
|
||||
let tagText = '待评价';
|
||||
let tagClassName = 'bg-gray-100 text-gray-600 border-gray-300';
|
||||
|
||||
if (score >= 90) {
|
||||
tagText = '优秀';
|
||||
tagClassName = 'bg-green-50 text-green-700 border-green-300';
|
||||
} else if (score >= 60) {
|
||||
tagText = '合格';
|
||||
tagClassName = 'bg-blue-50 text-blue-700 border-blue-300';
|
||||
} else if (score > 0) {
|
||||
tagText = '不合格';
|
||||
tagClassName = 'bg-red-50 text-red-700 border-red-300';
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={`
|
||||
px-3 py-0.5 rounded-full text-xs font-medium border mr-1
|
||||
${tagClassName}
|
||||
`}
|
||||
title="供应商评价"
|
||||
>
|
||||
{tagText}
|
||||
</span>
|
||||
);
|
||||
})()} */}
|
||||
|
||||
<span className="mr-2 text-xl font-medium">
|
||||
{reviewData.fileInfo.fileName}
|
||||
</span>
|
||||
@@ -929,29 +900,8 @@ export default function ReviewDetails() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* <div className="text-xs text-gray-500 flex items-center mb-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> */}
|
||||
|
||||
{/* 文件信息和操作按钮 */}
|
||||
{/* <FileInfo
|
||||
fileInfo={{
|
||||
...reviewData.fileInfo,
|
||||
previousRoute: loaderData.previousRoute
|
||||
}}
|
||||
onConfirmResults={handleConfirmResults}
|
||||
/> */}
|
||||
|
||||
{/* 选项卡 */}
|
||||
<ReviewTabs
|
||||
activeTab={activeTab}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user