fix: align rules list and review detail flows
This commit is contained in:
@@ -310,10 +310,14 @@ export function Sidebar({ onToggle, collapsed, userRole, frontendJWT = '' }: Sid
|
||||
if (mainType) params.set('mainType', mainType);
|
||||
} else if (isContractModule) {
|
||||
params.set('documentType', '合同');
|
||||
params.set('mainType', '合同');
|
||||
if (mainType) {
|
||||
params.set('mainType', mainType);
|
||||
}
|
||||
} else if (effectiveSelectedModuleName.includes('公文')) {
|
||||
params.set('documentType', '内部公文');
|
||||
params.set('mainType', '内部公文');
|
||||
if (mainType) {
|
||||
params.set('mainType', mainType);
|
||||
}
|
||||
} else if (effectiveSelectedModuleName) {
|
||||
params.set('documentType', effectiveSelectedModuleName);
|
||||
params.set('mainType', effectiveSelectedModuleName);
|
||||
|
||||
@@ -87,7 +87,7 @@ export interface CharPosition {
|
||||
* 用于展示单个评查结果
|
||||
*/
|
||||
export interface ReviewPoint {
|
||||
id: string;
|
||||
id: string | number;
|
||||
documentId?: string;
|
||||
pointId?: string;
|
||||
editAuditStatusId?: string | number;
|
||||
@@ -2888,4 +2888,4 @@ export function ReviewPointsList({
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,9 +17,9 @@ interface Statistics {
|
||||
interface RulesDirectoryProps {
|
||||
reviewPoints: ReviewPoint[];
|
||||
statistics: Statistics;
|
||||
activeReviewPointResultId: string | null;
|
||||
activeReviewPointResultId: string | number | null;
|
||||
fileName: string;
|
||||
onRuleSelect: (id: string) => void;
|
||||
onRuleSelect: (id: string | number) => void;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
* 右栏 · 详情面板
|
||||
* 包含 3 个选项卡(评查结果、抽取字段、文件信息)+ 底部操作栏
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import type { ReviewPoint, CharPosition } from '../ReviewPointsList';
|
||||
import { ReviewPointDetailCard } from './ReviewPointDetailCard';
|
||||
import { FileInfoPanel } from './FileInfoPanel';
|
||||
@@ -35,8 +34,8 @@ interface DetailPanelProps {
|
||||
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;
|
||||
onReviewPointSelect: (id: string | number, page?: number, charPositions?: CharPosition[], value?: string) => void;
|
||||
onStatusChange: (id: string | number, editAuditStatusId: string | number, status: string, message: string) => void;
|
||||
onConfirmResults: () => void;
|
||||
onDownload: () => void;
|
||||
auditStatus?: number;
|
||||
@@ -46,16 +45,36 @@ interface DetailPanelProps {
|
||||
showComparisonButton?: boolean;
|
||||
}
|
||||
|
||||
function ExtractedFieldsPanel({ reviewPoints, onFieldClick }: { reviewPoints: ReviewPoint[]; onFieldClick: (page: number) => void }) {
|
||||
const fields: Array<{ key: string; value: string; page?: number; pointName: string }> = [];
|
||||
type ExtractedFieldValue = {
|
||||
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) => {
|
||||
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 });
|
||||
const fieldData = (data && typeof data === 'object' ? data : {}) as ExtractedFieldValue & { text?: string };
|
||||
const val = fieldData.value;
|
||||
const page = fieldData.page;
|
||||
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}`}
|
||||
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)}
|
||||
onClick={() => f.page && onFieldClick(f.pointId, f.page)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-[11px] text-slate-500 truncate font-medium">{f.key}</span>
|
||||
@@ -180,11 +199,8 @@ export function DetailPanel({
|
||||
{activeTab === 'fields' && (
|
||||
<ExtractedFieldsPanel
|
||||
reviewPoints={reviewPoints}
|
||||
onFieldClick={(page) => {
|
||||
// 通过 activeReviewPoint 的 id 跳转页面
|
||||
if (activeReviewPoint) {
|
||||
onReviewPointSelect(activeReviewPoint.id, page);
|
||||
}
|
||||
onFieldClick={(pointId, page) => {
|
||||
onReviewPointSelect(pointId, page);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -9,8 +9,8 @@ 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;
|
||||
onReviewPointSelect: (id: string | number, page?: number, charPositions?: CharPosition[], value?: string) => void;
|
||||
onStatusChange: (id: string | number, editAuditStatusId: string | number, status: string, message: string) => void;
|
||||
fileFormat?: string;
|
||||
}
|
||||
|
||||
@@ -223,7 +223,7 @@ function filterOtherRule(reviewPoint: ReviewPoint): MergedRule[] {
|
||||
}
|
||||
|
||||
// ── 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 fieldValue = rule.fieldValue;
|
||||
const hasFailure = Object.values(fieldValue.type || {}).some(item => item.res === false);
|
||||
@@ -273,7 +273,7 @@ function RenderOtherRule({ rule, reviewPoint, onReviewPointSelect }: { rule: Mer
|
||||
// ── 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 }) {
|
||||
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;
|
||||
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;
|
||||
@@ -389,7 +389,7 @@ function RenderConsistencyRule({ rule, reviewPoint, onReviewPointSelect }: { rul
|
||||
}
|
||||
|
||||
// ── 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;
|
||||
|
||||
if (config?.res !== reviewPoint.result) return null;
|
||||
@@ -434,6 +434,91 @@ function RenderModelRule({ rule, reviewPoint, onReviewPointSelect, fileFormat }:
|
||||
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 ──
|
||||
export function ReviewPointDetailCard({ reviewPoint, onReviewPointSelect, onStatusChange, fileFormat }: ReviewPointDetailCardProps) {
|
||||
const resolveManualNote = () => {
|
||||
@@ -519,7 +604,7 @@ export function ReviewPointDetailCard({ reviewPoint, onReviewPointSelect, onStat
|
||||
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;
|
||||
return <RenderGenericRule key={`rule-${i}`} rule={rule} reviewPoint={reviewPoint} onReviewPointSelect={onReviewPointSelect} />;
|
||||
})}
|
||||
</section>
|
||||
|
||||
|
||||
@@ -180,9 +180,9 @@ const configs: Record<string, ApiConfig> = {
|
||||
// documentUrl: 'http://172.16.0.84:8073/docauditai/',
|
||||
// uploadUrl: 'http://172.16.0.84:8073/api/v2/documents',
|
||||
|
||||
collaboraUrl: 'http://172.16.0.58:9980',
|
||||
// collaboraUrl: 'http://nas.7bm.co:9980',
|
||||
appUrl: 'http://172.16.0.34:51703',
|
||||
// 公网访问 reviewsTest 时,iframe 不能再直连内网 Collabora,否则浏览器会拦截。
|
||||
collaboraUrl: 'http://nas.7bm.co:9980',
|
||||
appUrl: 'http://nas.7bm.co:5173',
|
||||
|
||||
oauth: {
|
||||
serverUrl: 'http://10.79.112.85', // IDaaS服务器地址
|
||||
|
||||
+56
-118
@@ -26,10 +26,10 @@
|
||||
*/
|
||||
|
||||
import { type MetaFunction, type LoaderFunctionArgs, type ActionFunctionArgs } from "@remix-run/node";
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useNavigate, useLoaderData, useFetcher, useRevalidator } from "@remix-run/react";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useNavigate, useLoaderData, useFetcher } from "@remix-run/react";
|
||||
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 { Modal } from "~/components/ui/Modal";
|
||||
import { UploadArea, type UploadAreaRef } from "~/components/ui/UploadArea";
|
||||
@@ -155,6 +155,32 @@ interface ReviewData {
|
||||
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 {
|
||||
if (!document) {
|
||||
return null;
|
||||
@@ -222,110 +248,26 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
||||
// 获取用户会话信息
|
||||
const { getUserSession } = await import("~/api/login/auth.server");
|
||||
const { userInfo, frontendJWT } = await getUserSession(request);
|
||||
|
||||
// 🆕 使用新的统一API获取评查点数据
|
||||
// 先尝试新的统一评查接口
|
||||
const unifiedData = await getUnifiedEvaluationResults(id, request);
|
||||
|
||||
// 如果统一接口返回错误或 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
|
||||
});
|
||||
}
|
||||
// reviewsTest 正式链路统一只走新后端聚合接口,避免旧统一接口 404 噪音。
|
||||
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 (unifiedData.flow_type === 'graphrag') {
|
||||
// 先获取文档基本信息(统一接口不返回文档内容)
|
||||
const reviewData = await getReviewPoints_fromApi(id, request);
|
||||
|
||||
// 合并已评查的 reviewPoints + 未涉及的评查点
|
||||
const existingPoints = ('data' in reviewData && !('error' in reviewData)) ? reviewData.data : [];
|
||||
const notApplicablePoints = (unifiedData.results || [])
|
||||
.filter((r: any) => r.result_type === 'not_applicable')
|
||||
.map((r: any) => ({
|
||||
id: `na-${r.evaluation_point_id}`,
|
||||
documentId: id,
|
||||
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
|
||||
});
|
||||
}
|
||||
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) {
|
||||
console.error('[Reviews Loader] 获取评查数据失败:', error);
|
||||
console.error('[Reviews Loader] 错误堆栈:', error instanceof Error ? error.stack : '无堆栈信息');
|
||||
@@ -431,7 +373,7 @@ export default function ReviewDetails() {
|
||||
const [isLoading, setIsLoading] = useState(false); // 已经通过loader加载了数据,不需要再显示加载状态
|
||||
const [rightActiveTab, setRightActiveTab] = useState<'result' | 'fields' | 'info'>('result');
|
||||
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 [charPositions, setCharPositions] = useState<Array<{ box: number[][], char: string, score: number }> | undefined>(undefined);
|
||||
const [highlightValue, setHighlightValue] = useState<string | undefined>(undefined);
|
||||
@@ -443,7 +385,7 @@ export default function ReviewDetails() {
|
||||
const [showCompareOverlay, setShowCompareOverlay] = useState(false);
|
||||
|
||||
// 一键替换(DOCX Collabora 使用)
|
||||
const [aiSuggestionReplace, setAiSuggestionReplace] = useState<{
|
||||
const [aiSuggestionReplace] = useState<{
|
||||
searchText: string;
|
||||
replaceText: string;
|
||||
pageNumber: number;
|
||||
@@ -456,7 +398,8 @@ export default function ReviewDetails() {
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [showComparison, setShowComparison] = useState(false);
|
||||
const uploadAreaRef = useRef<UploadAreaRef>(null);
|
||||
const revalidator = useRevalidator();
|
||||
const previewPath = resolvePreviewPath(document);
|
||||
const previewExtension = resolvePreviewExtension(document);
|
||||
|
||||
// 结构比对按钮显示条件:fileInfo.type 包含 '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);
|
||||
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能够触发
|
||||
if (reviewPointId === activeReviewPointResultId && page) {
|
||||
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) {
|
||||
// // 设置加载状态
|
||||
@@ -942,9 +880,9 @@ export default function ReviewDetails() {
|
||||
{/* 中栏:PDF 预览 */}
|
||||
{/* 中栏:文件预览(根据文件类型切换) */}
|
||||
<section className="flex flex-col min-h-0 bg-slate-100">
|
||||
{document?.path?.split('.').pop()?.toLowerCase() === 'docx' ? (
|
||||
{previewExtension === 'docx' ? (
|
||||
<DocxPreviewTest
|
||||
filePath={document?.path || ''}
|
||||
filePath={previewPath}
|
||||
targetPage={targetPage}
|
||||
charPositions={charPositions}
|
||||
activeReviewPointResultId={activeReviewPointResultId}
|
||||
@@ -955,7 +893,7 @@ export default function ReviewDetails() {
|
||||
/>
|
||||
) : (
|
||||
<PdfPreviewTest
|
||||
filePath={document?.path || ''}
|
||||
filePath={previewPath}
|
||||
targetPage={targetPage}
|
||||
charPositions={charPositions}
|
||||
activeReviewPointResultId={activeReviewPointResultId}
|
||||
|
||||
@@ -61,6 +61,16 @@ function unique(values: string[]): string[] {
|
||||
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 {
|
||||
if (risk === 'high') return 'red';
|
||||
if (risk === 'medium') return 'orange';
|
||||
@@ -86,28 +96,36 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
||||
};
|
||||
|
||||
const packs = await loadRuleConfigPacks(request);
|
||||
const documentTypes = unique(packs.map(pack => pack.documentType));
|
||||
const inferredDocumentType = packs.find(pack => pack.mainType === requestedFilters.mainType)?.documentType || '';
|
||||
const currentDocumentType = documentTypes.includes(requestedFilters.documentType)
|
||||
? requestedFilters.documentType
|
||||
const packScopes = packs.map(pack => ({
|
||||
pack,
|
||||
scope: resolveDocumentScope(pack),
|
||||
}));
|
||||
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] || '';
|
||||
const scopedDocumentPacks = packScopes
|
||||
.filter(item => item.scope === currentDocumentType)
|
||||
.map(item => item.pack);
|
||||
const scopedFilters = {
|
||||
...requestedFilters,
|
||||
documentType: currentDocumentType,
|
||||
mainType: packs.some(pack => pack.documentType === currentDocumentType && pack.mainType === requestedFilters.mainType)
|
||||
mainType: scopedDocumentPacks.some(pack => pack.mainType === requestedFilters.mainType)
|
||||
? requestedFilters.mainType
|
||||
: '',
|
||||
subtype: packs.some(pack =>
|
||||
pack.documentType === currentDocumentType &&
|
||||
subtype: scopedDocumentPacks.some(pack =>
|
||||
(!requestedFilters.mainType || pack.mainType === requestedFilters.mainType) &&
|
||||
pack.subtype === requestedFilters.subtype
|
||||
)
|
||||
? requestedFilters.subtype
|
||||
: ''
|
||||
};
|
||||
const scopedByMainTypePacks = packs.filter(pack =>
|
||||
pack.documentType === scopedFilters.documentType &&
|
||||
(!scopedFilters.mainType || pack.mainType === scopedFilters.mainType)
|
||||
const scopedByMainTypePacks = scopedDocumentPacks.filter(pack =>
|
||||
!scopedFilters.mainType || pack.mainType === scopedFilters.mainType
|
||||
);
|
||||
const subtypeOptions = unique(scopedByMainTypePacks.map(pack => pack.subtype));
|
||||
const ruleGroupSourcePacks = scopedFilters.subtype
|
||||
@@ -122,8 +140,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
||||
...scopedFilters,
|
||||
ruleGroup: ruleGroupOptions.includes(requestedFilters.ruleGroup) ? requestedFilters.ruleGroup : ''
|
||||
};
|
||||
const visiblePacks = packs.filter(pack =>
|
||||
pack.documentType === filters.documentType &&
|
||||
const visiblePacks = scopedDocumentPacks.filter(pack =>
|
||||
(!filters.mainType || pack.mainType === filters.mainType) &&
|
||||
(!filters.subtype || pack.subtype === filters.subtype)
|
||||
);
|
||||
@@ -196,7 +213,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
||||
pageSize: filters.pageSize,
|
||||
options: {
|
||||
documentTypes,
|
||||
mainTypes: unique(packs.filter(pack => pack.documentType === filters.documentType).map(pack => pack.mainType)),
|
||||
mainTypes: unique(scopedDocumentPacks.map(pack => pack.mainType)),
|
||||
subtypes: subtypeOptions,
|
||||
ruleGroups: ruleGroupOptions
|
||||
}
|
||||
|
||||
@@ -110,6 +110,39 @@ export class WopiService {
|
||||
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 - 返回文件元数据
|
||||
* @param fileId - 文件路径(例如:contracts/test.docx)
|
||||
@@ -123,20 +156,12 @@ export class WopiService {
|
||||
// 清理文件路径
|
||||
const sanitizedFileId = this.sanitizeFileId(fileId);
|
||||
|
||||
// 通过 FastAPI 代理获取文件元数据(使用 HEAD 请求)
|
||||
// 通过 FastAPI 代理获取文件元数据。
|
||||
// 注意:当前后端文件路由对 HEAD 返回 405,不能再直接据此判定“文件不存在”。
|
||||
const fileUrl = `${DOCUMENT_URL}${sanitizedFileId}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(fileUrl, {
|
||||
method: 'HEAD',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${tokenData.frontendJWT}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`文件不存在: ${sanitizedFileId}`);
|
||||
}
|
||||
const response = await this.probeFileMetadata(fileUrl, tokenData.frontendJWT);
|
||||
|
||||
const contentLength = response.headers.get('Content-Length');
|
||||
const lastModified = response.headers.get('Last-Modified');
|
||||
|
||||
Reference in New Issue
Block a user