fix: align rules list and review detail flows

This commit is contained in:
wren
2026-05-06 10:35:57 +08:00
parent 99fce169cb
commit 22ef99754c
9 changed files with 257 additions and 172 deletions
+6 -2
View File
@@ -310,10 +310,14 @@ export function Sidebar({ onToggle, collapsed, userRole, frontendJWT = '' }: Sid
if (mainType) params.set('mainType', mainType); if (mainType) params.set('mainType', mainType);
} else if (isContractModule) { } else if (isContractModule) {
params.set('documentType', '合同'); params.set('documentType', '合同');
params.set('mainType', '合同'); if (mainType) {
params.set('mainType', mainType);
}
} else if (effectiveSelectedModuleName.includes('公文')) { } else if (effectiveSelectedModuleName.includes('公文')) {
params.set('documentType', '内部公文'); params.set('documentType', '内部公文');
params.set('mainType', '内部公文'); if (mainType) {
params.set('mainType', mainType);
}
} else if (effectiveSelectedModuleName) { } else if (effectiveSelectedModuleName) {
params.set('documentType', effectiveSelectedModuleName); params.set('documentType', effectiveSelectedModuleName);
params.set('mainType', effectiveSelectedModuleName); params.set('mainType', effectiveSelectedModuleName);
+2 -2
View File
@@ -87,7 +87,7 @@ export interface CharPosition {
* 用于展示单个评查结果 * 用于展示单个评查结果
*/ */
export interface ReviewPoint { export interface ReviewPoint {
id: string; id: string | number;
documentId?: string; documentId?: string;
pointId?: string; pointId?: string;
editAuditStatusId?: string | number; editAuditStatusId?: string | number;
@@ -2888,4 +2888,4 @@ export function ReviewPointsList({
/> />
</div> </div>
); );
} }
@@ -17,9 +17,9 @@ interface Statistics {
interface RulesDirectoryProps { interface RulesDirectoryProps {
reviewPoints: ReviewPoint[]; reviewPoints: ReviewPoint[];
statistics: Statistics; statistics: Statistics;
activeReviewPointResultId: string | null; activeReviewPointResultId: string | number | null;
fileName: string; fileName: string;
onRuleSelect: (id: string) => void; onRuleSelect: (id: string | number) => void;
onBack: () => void; onBack: () => void;
} }
@@ -2,7 +2,6 @@
* 右栏 · 详情面板 * 右栏 · 详情面板
* 包含 3 个选项卡(评查结果、抽取字段、文件信息)+ 底部操作栏 * 包含 3 个选项卡(评查结果、抽取字段、文件信息)+ 底部操作栏
*/ */
import { useState } from 'react';
import type { ReviewPoint, CharPosition } from '../ReviewPointsList'; import type { ReviewPoint, CharPosition } from '../ReviewPointsList';
import { ReviewPointDetailCard } from './ReviewPointDetailCard'; import { ReviewPointDetailCard } from './ReviewPointDetailCard';
import { FileInfoPanel } from './FileInfoPanel'; import { FileInfoPanel } from './FileInfoPanel';
@@ -35,8 +34,8 @@ interface DetailPanelProps {
reviewPoints: ReviewPoint[]; reviewPoints: ReviewPoint[];
fileInfo: FileInfoData; fileInfo: FileInfoData;
reviewInfo: ReviewInfoData; reviewInfo: ReviewInfoData;
onReviewPointSelect: (id: string, page?: number, charPositions?: CharPosition[], value?: string) => void; onReviewPointSelect: (id: string | number, page?: number, charPositions?: CharPosition[], value?: string) => void;
onStatusChange: (id: string, editAuditStatusId: string | number, status: string, message: string) => void; onStatusChange: (id: string | number, editAuditStatusId: string | number, status: string, message: string) => void;
onConfirmResults: () => void; onConfirmResults: () => void;
onDownload: () => void; onDownload: () => void;
auditStatus?: number; auditStatus?: number;
@@ -46,16 +45,36 @@ interface DetailPanelProps {
showComparisonButton?: boolean; showComparisonButton?: boolean;
} }
function ExtractedFieldsPanel({ reviewPoints, onFieldClick }: { reviewPoints: ReviewPoint[]; onFieldClick: (page: number) => void }) { type ExtractedFieldValue = {
const fields: Array<{ key: string; value: string; page?: number; pointName: string }> = []; value?: unknown;
page?: number | string;
};
function ExtractedFieldsPanel({
reviewPoints,
onFieldClick,
}: {
reviewPoints: ReviewPoint[];
onFieldClick: (pointId: string | number, page: number) => void;
}) {
const fields: Array<{ key: string; value: string; page?: number; pointName: string; pointId: string | number }> = [];
reviewPoints.forEach((p) => { reviewPoints.forEach((p) => {
if (p.content) { if (p.content) {
Object.entries(p.content).forEach(([key, data]) => { Object.entries(p.content).forEach(([key, data]) => {
const val = (data as any)?.value; const fieldData = (data && typeof data === 'object' ? data : {}) as ExtractedFieldValue & { text?: string };
const page = (data as any)?.page; const val = fieldData.value;
const text = typeof val === 'object' ? (val as any)?.text || JSON.stringify(val) : String(val || ''); const page = fieldData.page;
fields.push({ key, value: text, page: page ? Number(page) : undefined, pointName: p.pointName }); const text = typeof val === 'object' && val !== null
? ('text' in (val as Record<string, unknown>) ? String((val as Record<string, unknown>).text || '') : JSON.stringify(val))
: String(val || '');
fields.push({
key,
value: text,
page: page ? Number(page) : undefined,
pointName: p.pointName,
pointId: p.id,
});
}); });
} }
}); });
@@ -74,7 +93,7 @@ function ExtractedFieldsPanel({ reviewPoints, onFieldClick }: { reviewPoints: Re
key={`${f.key}-${i}`} key={`${f.key}-${i}`}
type="button" 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'}`} 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)} onClick={() => f.page && onFieldClick(f.pointId, f.page)}
> >
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<span className="text-[11px] text-slate-500 truncate font-medium">{f.key}</span> <span className="text-[11px] text-slate-500 truncate font-medium">{f.key}</span>
@@ -180,11 +199,8 @@ export function DetailPanel({
{activeTab === 'fields' && ( {activeTab === 'fields' && (
<ExtractedFieldsPanel <ExtractedFieldsPanel
reviewPoints={reviewPoints} reviewPoints={reviewPoints}
onFieldClick={(page) => { onFieldClick={(pointId, page) => {
// 通过 activeReviewPoint 的 id 跳转页面 onReviewPointSelect(pointId, page);
if (activeReviewPoint) {
onReviewPointSelect(activeReviewPoint.id, page);
}
}} }}
/> />
)} )}
@@ -9,8 +9,8 @@ import type { ReviewPoint, CharPosition } from '../ReviewPointsList';
interface ReviewPointDetailCardProps { interface ReviewPointDetailCardProps {
reviewPoint: ReviewPoint; reviewPoint: ReviewPoint;
onReviewPointSelect: (id: string, page?: number, charPositions?: CharPosition[], value?: string) => void; onReviewPointSelect: (id: string | number, page?: number, charPositions?: CharPosition[], value?: string) => void;
onStatusChange: (id: string, editAuditStatusId: string | number, status: string, message: string) => void; onStatusChange: (id: string | number, editAuditStatusId: string | number, status: string, message: string) => void;
fileFormat?: string; fileFormat?: string;
} }
@@ -223,7 +223,7 @@ function filterOtherRule(reviewPoint: ReviewPoint): MergedRule[] {
} }
// ── renderOtherRule ── // ── renderOtherRule ──
function RenderOtherRule({ rule, reviewPoint, onReviewPointSelect }: { rule: MergedRule; reviewPoint: ReviewPoint; onReviewPointSelect: (id: string, page?: number, charPos?: CharPosition[], value?: string) => void }) { function RenderOtherRule({ rule, reviewPoint, onReviewPointSelect }: { rule: MergedRule; reviewPoint: ReviewPoint; onReviewPointSelect: (id: string | number, page?: number, charPos?: CharPosition[], value?: string) => void }) {
const fieldKey = rule.fieldKey; const fieldKey = rule.fieldKey;
const fieldValue = rule.fieldValue; const fieldValue = rule.fieldValue;
const hasFailure = Object.values(fieldValue.type || {}).some(item => item.res === false); const hasFailure = Object.values(fieldValue.type || {}).some(item => item.res === false);
@@ -273,7 +273,7 @@ function RenderOtherRule({ rule, reviewPoint, onReviewPointSelect }: { rule: Mer
// ── renderConsistencyRule ── // ── renderConsistencyRule ──
type ChainItem = { field: string; data: { key: string; page: number; value: string; char_positions?: CharPosition[] }; res: boolean; compareMethod?: string }; type ChainItem = { field: string; data: { key: string; page: number; value: string; char_positions?: CharPosition[] }; res: boolean; compareMethod?: string };
function RenderConsistencyRule({ rule, reviewPoint, onReviewPointSelect }: { rule: Record<string, unknown>; reviewPoint: ReviewPoint; onReviewPointSelect: (id: string, page?: number, charPos?: CharPosition[], value?: string) => void }) { function RenderConsistencyRule({ rule, reviewPoint, onReviewPointSelect }: { rule: Record<string, unknown>; reviewPoint: ReviewPoint; onReviewPointSelect: (id: string | number, page?: number, charPos?: CharPosition[], value?: string) => void }) {
if (reviewPoint.result !== (rule.res as boolean)) return null; if (reviewPoint.result !== (rule.res as boolean)) return null;
const config = rule.config as { logic?: string; pairs?: Array<{ sourceField: Record<string, { page: number; value: string; char_positions?: CharPosition[] }>; targetField: Record<string, { page: number; value: string; char_positions?: CharPosition[] }>; res: boolean; compareMethod?: string }>; selectedFields?: string[] } | undefined; const config = rule.config as { logic?: string; pairs?: Array<{ sourceField: Record<string, { page: number; value: string; char_positions?: CharPosition[] }>; targetField: Record<string, { page: number; value: string; char_positions?: CharPosition[] }>; res: boolean; compareMethod?: string }>; selectedFields?: string[] } | undefined;
if (!config || !config.pairs || !Array.isArray(config.pairs) || config.pairs.length === 0) return null; if (!config || !config.pairs || !Array.isArray(config.pairs) || config.pairs.length === 0) return null;
@@ -389,7 +389,7 @@ function RenderConsistencyRule({ rule, reviewPoint, onReviewPointSelect }: { rul
} }
// ── renderModelRule ── // ── 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 }) { function RenderModelRule({ rule, reviewPoint, onReviewPointSelect, fileFormat }: { rule: Record<string, unknown>; reviewPoint: ReviewPoint; onReviewPointSelect: (id: string | number, page?: number, charPos?: CharPosition[], value?: string) => void; fileFormat?: string }) {
const config = rule.config as { model?: string; fields?: Record<string, { page: number | string; value: string; char_positions?: CharPosition[]; res?: boolean }>; ai_suggestion?: { summary?: string; analysis?: { failure_reason?: string; solution_approach?: string; rule_understanding?: string }; suggestions?: Record<string, { reason: string; source: { page: number | null; type: string; field: string | null }; priority: string; confidence: number; suggested_value: string | null }>; generated_at?: string }; message?: string; res?: boolean } | undefined; const config = rule.config as { model?: string; fields?: Record<string, { page: number | string; value: string; char_positions?: CharPosition[]; res?: boolean }>; ai_suggestion?: { summary?: string; analysis?: { failure_reason?: string; solution_approach?: string; rule_understanding?: string }; suggestions?: Record<string, { reason: string; source: { page: number | null; type: string; field: string | null }; priority: string; confidence: number; suggested_value: string | null }>; generated_at?: string }; message?: string; res?: boolean } | undefined;
if (config?.res !== reviewPoint.result) return null; if (config?.res !== reviewPoint.result) return null;
@@ -434,6 +434,91 @@ function RenderModelRule({ rule, reviewPoint, onReviewPointSelect, fileFormat }:
return <>{fieldElements}</>; return <>{fieldElements}</>;
} }
function RenderGenericRule({
rule,
reviewPoint,
onReviewPointSelect,
}: {
rule: Record<string, unknown>;
reviewPoint: ReviewPoint;
onReviewPointSelect: (id: string | number, page?: number, charPos?: CharPosition[], value?: string) => void;
}) {
const config = (rule.config && typeof rule.config === 'object' ? rule.config : {}) as Record<string, unknown>;
const detail = (config.detail && typeof config.detail === 'object' ? config.detail : {}) as Record<string, unknown>;
const fieldNames = Array.isArray(detail.fields)
? detail.fields.map((field) => String(field))
: Array.isArray((config as any).fields)
? (config as any).fields.map((field: unknown) => String(field))
: [];
const reason = [config.reason, detail.reason, reviewPoint.failMessage, reviewPoint.passMessage]
.find((item) => typeof item === 'string' && item.trim()) as string | undefined;
const passed = typeof rule.res === 'boolean' ? rule.res : reviewPoint.result === true;
const checkType = typeof config.check_type === 'string' ? config.check_type : '';
const primitiveType = typeof config.primitive_type === 'string' ? config.primitive_type : '';
const badgeText = checkType || primitiveType || '规则检查';
const jumpToField = (fieldName: string) => {
const fieldData = reviewPoint.content?.[fieldName];
const page = fieldData?.page || reviewPoint.contentPage?.[fieldName];
const normalizedPage = page ? Number(page) : undefined;
if (normalizedPage && Number.isFinite(normalizedPage)) {
onReviewPointSelect(
reviewPoint.id,
normalizedPage,
fieldData?.char_positions,
typeof fieldData?.value === 'string' ? fieldData.value : undefined,
);
return;
}
toastService.info(`${fieldName} 当前没有可定位页码`);
};
return (
<div className={`mb-3 rounded-md border ${passed ? 'border-emerald-200 bg-emerald-50/60' : 'border-amber-200 bg-amber-50/70'} p-3`}>
<div className="flex items-center justify-between gap-2">
<div className="text-[11px] font-medium text-slate-600">{badgeText}</div>
<span className={`inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[10.5px] ${passed ? 'bg-emerald-100 text-emerald-700' : 'bg-amber-100 text-amber-700'}`}>
<i className={passed ? 'ri-checkbox-circle-line' : 'ri-error-warning-line'} />
{passed ? '通过' : '未通过'}
</span>
</div>
{reason && (
<div className="mt-2 text-[12px] leading-5 text-slate-700">
{reason}
</div>
)}
{fieldNames.length > 0 && (
<div className="mt-3 flex flex-wrap gap-2">
{fieldNames.map((fieldName) => {
const fieldData = reviewPoint.content?.[fieldName];
const fieldValue = fieldData?.value;
const displayValue =
typeof fieldValue === 'string'
? fieldValue
: fieldValue == null
? '未抽取到值'
: JSON.stringify(fieldValue);
return (
<button
key={fieldName}
type="button"
className="min-w-0 flex-1 rounded border border-slate-200 bg-white px-2.5 py-2 text-left hover:border-[#00684a] hover:bg-[#f6fffb]"
onClick={() => jumpToField(fieldName)}
>
<div className="text-[11px] font-medium text-slate-500">{fieldName}</div>
<div className="mt-1 text-[12px] leading-5 text-slate-700 break-all">{displayValue}</div>
</button>
);
})}
</div>
)}
</div>
);
}
// ── Main Component ── // ── Main Component ──
export function ReviewPointDetailCard({ reviewPoint, onReviewPointSelect, onStatusChange, fileFormat }: ReviewPointDetailCardProps) { export function ReviewPointDetailCard({ reviewPoint, onReviewPointSelect, onStatusChange, fileFormat }: ReviewPointDetailCardProps) {
const resolveManualNote = () => { const resolveManualNote = () => {
@@ -519,7 +604,7 @@ export function ReviewPointDetailCard({ reviewPoint, onReviewPointSelect, onStat
if (rule.type === 'ai') { if (rule.type === 'ai') {
return <div key={`rule-${i}`}>{otherRules.length > 0 && <div className="bg-gray-50 rounded border border-gray-200 text-xs mb-3" />}<RenderModelRule rule={rule} reviewPoint={reviewPoint} onReviewPointSelect={onReviewPointSelect} fileFormat={fileFormat} /></div>; return <div key={`rule-${i}`}>{otherRules.length > 0 && <div className="bg-gray-50 rounded border border-gray-200 text-xs mb-3" />}<RenderModelRule rule={rule} reviewPoint={reviewPoint} onReviewPointSelect={onReviewPointSelect} fileFormat={fileFormat} /></div>;
} }
return null; return <RenderGenericRule key={`rule-${i}`} rule={rule} reviewPoint={reviewPoint} onReviewPointSelect={onReviewPointSelect} />;
})} })}
</section> </section>
+3 -3
View File
@@ -180,9 +180,9 @@ const configs: Record<string, ApiConfig> = {
// documentUrl: 'http://172.16.0.84:8073/docauditai/', // documentUrl: 'http://172.16.0.84:8073/docauditai/',
// uploadUrl: 'http://172.16.0.84:8073/api/v2/documents', // uploadUrl: 'http://172.16.0.84:8073/api/v2/documents',
collaboraUrl: 'http://172.16.0.58:9980', // 公网访问 reviewsTest 时,iframe 不能再直连内网 Collabora,否则浏览器会拦截。
// collaboraUrl: 'http://nas.7bm.co:9980', collaboraUrl: 'http://nas.7bm.co:9980',
appUrl: 'http://172.16.0.34:51703', appUrl: 'http://nas.7bm.co:5173',
oauth: { oauth: {
serverUrl: 'http://10.79.112.85', // IDaaS服务器地址 serverUrl: 'http://10.79.112.85', // IDaaS服务器地址
+56 -118
View File
@@ -26,10 +26,10 @@
*/ */
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, useCallback, useRef } from "react"; import { useState, useEffect, useRef } from "react";
import { useNavigate, useLoaderData, useFetcher, useRevalidator } from "@remix-run/react"; import { useNavigate, useLoaderData, useFetcher } from "@remix-run/react";
import reviewsStyles from "~/styles/reviews.css?url"; import reviewsStyles from "~/styles/reviews.css?url";
import { getReviewPoints_fromApi, getUnifiedEvaluationResults, updateReviewResult, confirmReviewResults } from "~/api/evaluation_points/reviews"; import { getReviewPoints_fromApi, updateReviewResult, confirmReviewResults } from "~/api/evaluation_points/reviews";
import { toastService } from "~/components/ui/Toast"; import { toastService } from "~/components/ui/Toast";
import { Modal } from "~/components/ui/Modal"; import { Modal } from "~/components/ui/Modal";
import { UploadArea, type UploadAreaRef } from "~/components/ui/UploadArea"; import { UploadArea, type UploadAreaRef } from "~/components/ui/UploadArea";
@@ -155,6 +155,32 @@ interface ReviewData {
aiAnalysis: AnalysisData; aiAnalysis: AnalysisData;
} }
type PreviewDocument = {
path?: string;
attachments?: Array<{
fileRole?: string;
ossUrl?: string;
}>;
};
function resolvePreviewPath(document: PreviewDocument | null | undefined): string {
if (document?.path) {
return document.path;
}
const primaryAttachment = Array.isArray(document?.attachments)
? document.attachments.find((item) => item?.fileRole === 'primary' && item?.ossUrl)
: null;
return primaryAttachment?.ossUrl || '';
}
function resolvePreviewExtension(document: PreviewDocument | null | undefined): string {
const path = resolvePreviewPath(document);
const suffix = path.split('.').pop();
return typeof suffix === 'string' ? suffix.toLowerCase() : '';
}
function buildReviewData(document: any, reviewPoints: ReviewPoint[], statistics: Statistics, reviewInfo: ReviewInfo): ReviewData | null { function buildReviewData(document: any, reviewPoints: ReviewPoint[], statistics: Statistics, reviewInfo: ReviewInfo): ReviewData | null {
if (!document) { if (!document) {
return null; return null;
@@ -222,110 +248,26 @@ export async function loader({ request }: LoaderFunctionArgs) {
// 获取用户会话信息 // 获取用户会话信息
const { getUserSession } = await import("~/api/login/auth.server"); const { getUserSession } = await import("~/api/login/auth.server");
const { userInfo, frontendJWT } = await getUserSession(request); const { userInfo, frontendJWT } = await getUserSession(request);
// reviewsTest 正式链路统一只走新后端聚合接口,避免旧统一接口 404 噪音。
// 🆕 使用新的统一API获取评查点数据 const reviewData = await getReviewPoints_fromApi(id, request);
// 先尝试新的统一评查接口 if ('error' in reviewData && reviewData.error) {
const unifiedData = await getUnifiedEvaluationResults(id, request); console.error("[Reviews Loader] 获取评查点数据错误:", reviewData.error);
return Response.json({ result: false, message: reviewData.error });
// 如果统一接口返回错误或 flow_type 为 legacy,直接走新后端聚合接口
if ('error' in unifiedData || !unifiedData.flow_type) {
console.log("[Reviews Loader] 统一接口不可用,直接尝试 review-points 聚合接口...");
const reviewData = await getReviewPoints_fromApi(id, request);
if ('error' in reviewData && reviewData.error) {
console.error("[Reviews Loader] 获取评查点数据错误:", reviewData.error);
return Response.json({ result: false, message: reviewData.error });
}
if ('document' in reviewData && 'data' in reviewData && 'reviewInfo' in reviewData && 'stats' in reviewData) {
return Response.json({
previousRoute: previousRoute,
document: reviewData.document,
reviewPoints: reviewData.data,
reviewInfo: reviewData.reviewInfo,
statistics: reviewData.stats,
comparison_document: reviewData.comparison_document,
userInfo,
frontendJWT,
flowType: 'legacy',
scoredResults: null,
scoredSummary: null
});
}
} }
// 统一接口成功返回,判断流程类型 return Response.json({
if (unifiedData.flow_type === 'graphrag') { previousRoute: previousRoute,
// 先获取文档基本信息(统一接口不返回文档内容) document: reviewData.document,
const reviewData = await getReviewPoints_fromApi(id, request); reviewPoints: reviewData.data,
reviewInfo: reviewData.reviewInfo,
// 合并已评查的 reviewPoints + 未涉及的评查点 statistics: reviewData.stats,
const existingPoints = ('data' in reviewData && !('error' in reviewData)) ? reviewData.data : []; comparison_document: reviewData.comparison_document,
const notApplicablePoints = (unifiedData.results || []) userInfo,
.filter((r: any) => r.result_type === 'not_applicable') frontendJWT,
.map((r: any) => ({ flowType: 'legacy',
id: `na-${r.evaluation_point_id}`, scoredResults: null,
documentId: id, scoredSummary: null
pointId: r.evaluation_point_id, });
editAuditStatusId: '',
editAuditStatus: '',
editAuditStatusMessage: '',
title: '该评查点未涉及',
pointName: r.name || '',
pointCode: r.code || '',
groupName: '',
status: 'notApplicable',
content: {},
contentPage: {},
suggestion: r.ai_suggestion || '该评查点未涉及',
result: null,
score: r.score || 0,
finalScore: null,
machineScore: 0,
postAction: '',
}));
const allReviewPoints = [...existingPoints, ...notApplicablePoints];
return Response.json({
previousRoute: previousRoute,
document: ('document' in reviewData && !('error' in reviewData)) ? reviewData.document : null,
reviewPoints: allReviewPoints,
reviewInfo: { reviewTime: unifiedData.evaluated_at, reviewModel: 'GraphRAG', ruleGroup: '', result: '', issueCount: unifiedData.summary?.total_points || 0 },
statistics: {
total: unifiedData.summary?.total_points || 0,
success: unifiedData.summary?.passed_count || 0,
warning: unifiedData.summary?.failed_count || 0,
error: 0,
notApplicable: unifiedData.summary?.not_applicable_count || 0,
score: unifiedData.summary?.total_score || 0
},
comparison_document: ('comparison_document' in reviewData && !('error' in reviewData)) ? reviewData.comparison_document : null,
userInfo,
frontendJWT,
flowType: 'graphrag',
scoredResults: unifiedData.results,
scoredSummary: unifiedData.summary
});
} else {
// legacy 流程但统一接口可用,也统一走 review-points 聚合接口
const reviewData = await getReviewPoints_fromApi(id, request);
if ('error' in reviewData && reviewData.error) {
return Response.json({ result: false, message: reviewData.error });
}
return Response.json({
previousRoute: previousRoute,
document: reviewData.document,
reviewPoints: reviewData.data,
reviewInfo: reviewData.reviewInfo,
statistics: reviewData.stats,
comparison_document: reviewData.comparison_document,
userInfo,
frontendJWT,
flowType: 'legacy',
scoredResults: null,
scoredSummary: null
});
}
} catch (error) { } catch (error) {
console.error('[Reviews Loader] 获取评查数据失败:', error); console.error('[Reviews Loader] 获取评查数据失败:', error);
console.error('[Reviews Loader] 错误堆栈:', error instanceof Error ? error.stack : '无堆栈信息'); console.error('[Reviews Loader] 错误堆栈:', error instanceof Error ? error.stack : '无堆栈信息');
@@ -431,7 +373,7 @@ export default function ReviewDetails() {
const [isLoading, setIsLoading] = useState(false); // 已经通过loader加载了数据,不需要再显示加载状态 const [isLoading, setIsLoading] = useState(false); // 已经通过loader加载了数据,不需要再显示加载状态
const [rightActiveTab, setRightActiveTab] = useState<'result' | 'fields' | 'info'>('result'); const [rightActiveTab, setRightActiveTab] = useState<'result' | 'fields' | 'info'>('result');
const [reviewData, setReviewData] = useState<ReviewData | null>(fallbackReviewData); const [reviewData, setReviewData] = useState<ReviewData | null>(fallbackReviewData);
const [activeReviewPointResultId, setActiveReviewPointResultId] = useState<string | null>(null); const [activeReviewPointResultId, setActiveReviewPointResultId] = useState<string | number | null>(null);
const [targetPage, setTargetPage] = useState<number | undefined>(undefined); const [targetPage, setTargetPage] = useState<number | undefined>(undefined);
const [charPositions, setCharPositions] = useState<Array<{ box: number[][], char: string, score: number }> | undefined>(undefined); const [charPositions, setCharPositions] = useState<Array<{ box: number[][], char: string, score: number }> | undefined>(undefined);
const [highlightValue, setHighlightValue] = useState<string | undefined>(undefined); const [highlightValue, setHighlightValue] = useState<string | undefined>(undefined);
@@ -443,7 +385,7 @@ export default function ReviewDetails() {
const [showCompareOverlay, setShowCompareOverlay] = useState(false); const [showCompareOverlay, setShowCompareOverlay] = useState(false);
// 一键替换(DOCX Collabora 使用) // 一键替换(DOCX Collabora 使用)
const [aiSuggestionReplace, setAiSuggestionReplace] = useState<{ const [aiSuggestionReplace] = useState<{
searchText: string; searchText: string;
replaceText: string; replaceText: string;
pageNumber: number; pageNumber: number;
@@ -456,7 +398,8 @@ export default function ReviewDetails() {
const [isUploading, setIsUploading] = useState(false); const [isUploading, setIsUploading] = useState(false);
const [showComparison, setShowComparison] = useState(false); const [showComparison, setShowComparison] = useState(false);
const uploadAreaRef = useRef<UploadAreaRef>(null); const uploadAreaRef = useRef<UploadAreaRef>(null);
const revalidator = useRevalidator(); const previewPath = resolvePreviewPath(document);
const previewExtension = resolvePreviewExtension(document);
// 结构比对按钮显示条件:fileInfo.type 包含 '1' // 结构比对按钮显示条件:fileInfo.type 包含 '1'
const showComparisonButton = (document as any)?.type?.toString().includes('1'); const showComparisonButton = (document as any)?.type?.toString().includes('1');
@@ -524,7 +467,7 @@ export default function ReviewDetails() {
}; };
// 从左栏选择评查点 // 从左栏选择评查点
const handleRuleSelect = (id: string) => { const handleRuleSelect = (id: string | number) => {
setActiveReviewPointResultId(id); setActiveReviewPointResultId(id);
setRightActiveTab('result'); setRightActiveTab('result');
@@ -578,7 +521,7 @@ export default function ReviewDetails() {
} }
}; };
const handleReviewPointSelect = (reviewPointId: string, page?: number, charPos?: Array<{ box: number[][], char: string, score: number }>, value?: string) => { const handleReviewPointSelect = (reviewPointId: string | number, page?: number, charPos?: Array<{ box: number[][], char: string, score: number }>, value?: string) => {
// 如果点击的是相同的评查点,但有page参数,先重置targetPage以确保useEffect能够触发 // 如果点击的是相同的评查点,但有page参数,先重置targetPage以确保useEffect能够触发
if (reviewPointId === activeReviewPointResultId && page) { if (reviewPointId === activeReviewPointResultId && page) {
setTargetPage(undefined); setTargetPage(undefined);
@@ -600,11 +543,6 @@ export default function ReviewDetails() {
} }
}; };
// 处理AI建议替换(保留空实现,右栏详情卡片中DOCX替换需要)
const handleAiSuggestionReplace = (_searchText: string, _replaceText: string, _pageNumber: number) => {
// PDF 文件不支持替换,暂不实现
};
// 刷新评审数据 // 刷新评审数据
// async function refreshReviewData(documentId: string) { // async function refreshReviewData(documentId: string) {
// // 设置加载状态 // // 设置加载状态
@@ -942,9 +880,9 @@ export default function ReviewDetails() {
{/* 中栏:PDF 预览 */} {/* 中栏:PDF 预览 */}
{/* 中栏:文件预览(根据文件类型切换) */} {/* 中栏:文件预览(根据文件类型切换) */}
<section className="flex flex-col min-h-0 bg-slate-100"> <section className="flex flex-col min-h-0 bg-slate-100">
{document?.path?.split('.').pop()?.toLowerCase() === 'docx' ? ( {previewExtension === 'docx' ? (
<DocxPreviewTest <DocxPreviewTest
filePath={document?.path || ''} filePath={previewPath}
targetPage={targetPage} targetPage={targetPage}
charPositions={charPositions} charPositions={charPositions}
activeReviewPointResultId={activeReviewPointResultId} activeReviewPointResultId={activeReviewPointResultId}
@@ -955,7 +893,7 @@ export default function ReviewDetails() {
/> />
) : ( ) : (
<PdfPreviewTest <PdfPreviewTest
filePath={document?.path || ''} filePath={previewPath}
targetPage={targetPage} targetPage={targetPage}
charPositions={charPositions} charPositions={charPositions}
activeReviewPointResultId={activeReviewPointResultId} activeReviewPointResultId={activeReviewPointResultId}
+30 -13
View File
@@ -61,6 +61,16 @@ function unique(values: string[]): string[] {
return Array.from(new Set(values.filter(Boolean))); return Array.from(new Set(values.filter(Boolean)));
} }
function resolveDocumentScope(pack: Pick<RuleYamlPack, 'documentType' | 'mainType' | 'moduleType'>): string {
const values = [pack.documentType, pack.mainType, pack.moduleType].join(' ');
if (values.includes('合同')) return '合同';
if (values.includes('案卷') || values.includes('卷宗') || values.includes('行政处罚') || values.includes('行政许可')) {
return '案卷';
}
if (values.includes('公文')) return '内部公文';
return pack.documentType || pack.mainType || pack.moduleType || '未分类';
}
function riskColor(risk: string): TagColor { function riskColor(risk: string): TagColor {
if (risk === 'high') return 'red'; if (risk === 'high') return 'red';
if (risk === 'medium') return 'orange'; if (risk === 'medium') return 'orange';
@@ -86,28 +96,36 @@ export async function loader({ request }: LoaderFunctionArgs) {
}; };
const packs = await loadRuleConfigPacks(request); const packs = await loadRuleConfigPacks(request);
const documentTypes = unique(packs.map(pack => pack.documentType)); const packScopes = packs.map(pack => ({
const inferredDocumentType = packs.find(pack => pack.mainType === requestedFilters.mainType)?.documentType || ''; pack,
const currentDocumentType = documentTypes.includes(requestedFilters.documentType) scope: resolveDocumentScope(pack),
? requestedFilters.documentType }));
const documentTypes = unique(packScopes.map(item => item.scope));
const requestedDocumentType = requestedFilters.documentType;
const inferredDocumentType = requestedMainType
? packScopes.find(item => item.pack.mainType === requestedMainType)?.scope || ''
: '';
const currentDocumentType = documentTypes.includes(requestedDocumentType)
? requestedDocumentType
: inferredDocumentType || documentTypes[0] || ''; : inferredDocumentType || documentTypes[0] || '';
const scopedDocumentPacks = packScopes
.filter(item => item.scope === currentDocumentType)
.map(item => item.pack);
const scopedFilters = { const scopedFilters = {
...requestedFilters, ...requestedFilters,
documentType: currentDocumentType, documentType: currentDocumentType,
mainType: packs.some(pack => pack.documentType === currentDocumentType && pack.mainType === requestedFilters.mainType) mainType: scopedDocumentPacks.some(pack => pack.mainType === requestedFilters.mainType)
? requestedFilters.mainType ? requestedFilters.mainType
: '', : '',
subtype: packs.some(pack => subtype: scopedDocumentPacks.some(pack =>
pack.documentType === currentDocumentType &&
(!requestedFilters.mainType || pack.mainType === requestedFilters.mainType) && (!requestedFilters.mainType || pack.mainType === requestedFilters.mainType) &&
pack.subtype === requestedFilters.subtype pack.subtype === requestedFilters.subtype
) )
? requestedFilters.subtype ? requestedFilters.subtype
: '' : ''
}; };
const scopedByMainTypePacks = packs.filter(pack => const scopedByMainTypePacks = scopedDocumentPacks.filter(pack =>
pack.documentType === scopedFilters.documentType && !scopedFilters.mainType || pack.mainType === scopedFilters.mainType
(!scopedFilters.mainType || pack.mainType === scopedFilters.mainType)
); );
const subtypeOptions = unique(scopedByMainTypePacks.map(pack => pack.subtype)); const subtypeOptions = unique(scopedByMainTypePacks.map(pack => pack.subtype));
const ruleGroupSourcePacks = scopedFilters.subtype const ruleGroupSourcePacks = scopedFilters.subtype
@@ -122,8 +140,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
...scopedFilters, ...scopedFilters,
ruleGroup: ruleGroupOptions.includes(requestedFilters.ruleGroup) ? requestedFilters.ruleGroup : '' ruleGroup: ruleGroupOptions.includes(requestedFilters.ruleGroup) ? requestedFilters.ruleGroup : ''
}; };
const visiblePacks = packs.filter(pack => const visiblePacks = scopedDocumentPacks.filter(pack =>
pack.documentType === filters.documentType &&
(!filters.mainType || pack.mainType === filters.mainType) && (!filters.mainType || pack.mainType === filters.mainType) &&
(!filters.subtype || pack.subtype === filters.subtype) (!filters.subtype || pack.subtype === filters.subtype)
); );
@@ -196,7 +213,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
pageSize: filters.pageSize, pageSize: filters.pageSize,
options: { options: {
documentTypes, documentTypes,
mainTypes: unique(packs.filter(pack => pack.documentType === filters.documentType).map(pack => pack.mainType)), mainTypes: unique(scopedDocumentPacks.map(pack => pack.mainType)),
subtypes: subtypeOptions, subtypes: subtypeOptions,
ruleGroups: ruleGroupOptions ruleGroups: ruleGroupOptions
} }
+36 -11
View File
@@ -110,6 +110,39 @@ export class WopiService {
return fileId.replace(/\.\./g, '').replace(/^\//, ''); return fileId.replace(/\.\./g, '').replace(/^\//, '');
} }
/**
* 某些后端文件代理不支持 HEAD,这里先尝试 HEAD,遇到 405 再回退到 GET。
*/
private async probeFileMetadata(fileUrl: string, frontendJWT: string) {
const headers = {
'Authorization': `Bearer ${frontendJWT}`,
};
const headResponse = await fetch(fileUrl, {
method: 'HEAD',
headers,
});
if (headResponse.ok) {
return headResponse;
}
if (headResponse.status !== 405) {
throw new Error(`文件探测失败: ${headResponse.status}`);
}
const getResponse = await fetch(fileUrl, {
method: 'GET',
headers,
});
if (!getResponse.ok) {
throw new Error(`文件探测失败: ${getResponse.status}`);
}
return getResponse;
}
/** /**
* CheckFileInfo - 返回文件元数据 * CheckFileInfo - 返回文件元数据
* @param fileId - 文件路径(例如:contracts/test.docx * @param fileId - 文件路径(例如:contracts/test.docx
@@ -123,20 +156,12 @@ export class WopiService {
// 清理文件路径 // 清理文件路径
const sanitizedFileId = this.sanitizeFileId(fileId); const sanitizedFileId = this.sanitizeFileId(fileId);
// 通过 FastAPI 代理获取文件元数据(使用 HEAD 请求) // 通过 FastAPI 代理获取文件元数据
// 注意:当前后端文件路由对 HEAD 返回 405,不能再直接据此判定“文件不存在”。
const fileUrl = `${DOCUMENT_URL}${sanitizedFileId}`; const fileUrl = `${DOCUMENT_URL}${sanitizedFileId}`;
try { try {
const response = await fetch(fileUrl, { const response = await this.probeFileMetadata(fileUrl, tokenData.frontendJWT);
method: 'HEAD',
headers: {
'Authorization': `Bearer ${tokenData.frontendJWT}`,
},
});
if (!response.ok) {
throw new Error(`文件不存在: ${sanitizedFileId}`);
}
const contentLength = response.headers.get('Content-Length'); const contentLength = response.headers.get('Content-Length');
const lastModified = response.headers.get('Last-Modified'); const lastModified = response.headers.get('Last-Modified');