feat: migrate cross checking ui to v3 flow

This commit is contained in:
wren
2026-05-07 18:15:40 +08:00
parent a14a1f0ee1
commit add399e126
7 changed files with 786 additions and 333 deletions
+155 -107
View File
@@ -25,24 +25,26 @@
import { type MetaFunction, type LoaderFunctionArgs, type ActionFunctionArgs } from "@remix-run/node";
import React, { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { useNavigate, useLoaderData } from "@remix-run/react";
import crossCheckingStyles from "~/styles/cross-checking-result.css?url";
import { getReviewPoints, updateReviewResult, getReviewPoints_fromApi} from "~/api/evaluation_points/reviews";
import reviewsStyles from "~/styles/reviews.css?url";
import { updateReviewResult, getReviewPoints_fromApi, getUnifiedEvaluationResults } from "~/api/evaluation_points/reviews";
import { postgrestGet } from "~/api/postgrest-client";
import { toastService } from "~/components/ui/Toast";
import { confirmReviewResults, checkProposalVotes, findIsProposer } from "~/api/cross-checking/cross-file-result";
import { usePermission } from "~/hooks/usePermission";
// 导入交叉评查详情页面组件
// 复用新版评查详情页外壳,保留交叉评查提案面板
import {
FileInfo,
ReviewTabs,
FilePreview,
ReviewPointsList
} from "~/components/cross-checking";
FileDetails,
} from "~/components/reviews";
import { ReviewPointsList, type CharPosition } from "~/components/cross-checking";
// 导入文档对比组件
import { ComparePreview } from "~/components/reviews/previewComponents/ComparePreview";
// 从ReviewPointsList组件中导入ReviewPoint类型和CharPosition类型
import { type ReviewPoint, type CharPosition } from '~/components/cross-checking';
// 从ReviewPointsList组件中导入ReviewPoint类型
import { type ReviewPoint } from '~/components/cross-checking';
import { messageService } from "~/components/ui/MessageModal";
import { loadingBarService } from "~/components/ui/LoadingBar";
import { Breadcrumb } from "~/components/layout/Breadcrumb";
@@ -177,7 +179,7 @@ export const meta: MetaFunction = () => {
};
export function links() {
return [{ rel: "stylesheet", href: crossCheckingStyles }];
return [{ rel: "stylesheet", href: reviewsStyles }];
}
export const handle = {
@@ -229,15 +231,102 @@ export async function loader({ request }: LoaderFunctionArgs) {
// console.log(`✅ [Loader] 用户 ${userInfo.user_id} (${accessCheck.userRole}) 访问文档 ${id} - 权限验证通过`);
async function patchPointCodes(points: Array<Record<string, any>>, jwt: string) {
try {
const pointIds = points.map((point) => point.pointId).filter(Boolean);
if (pointIds.length === 0) return;
const response = await postgrestGet<any>('/api/postgrest/proxy/evaluation_points', {
select: 'id,code',
filter: { id: `in.(${[...new Set(pointIds)].join(',')})` },
token: jwt,
});
const raw = response.data;
const pointList = Array.isArray(raw) ? raw : (raw?.data && Array.isArray(raw.data) ? raw.data : []);
const codeMap: Record<string, string> = {};
pointList.forEach((point: any) => {
if (point.code) {
codeMap[String(point.id)] = point.code;
}
});
points.forEach((point) => {
point.pointCode = codeMap[String(point.pointId)] || point.pointCode || '';
});
} catch (error) {
console.error('[CrossChecking Loader] patchPointCodes error:', error);
}
}
const isProposer = await findIsProposer(taskId, userInfo?.user_id, frontendJWT);
const unifiedData = await getUnifiedEvaluationResults(id, request);
if (!('error' in unifiedData) && unifiedData.flow_type === 'graphrag') {
const reviewData = await getReviewPoints_fromApi(id, request);
const existingPoints = ('data' in reviewData && !('error' in reviewData)) ? reviewData.data : [];
const notApplicablePoints = (unifiedData.results || [])
.filter((result: any) => result.result_type === 'not_applicable')
.map((result: any) => ({
id: `na-${result.evaluation_point_id}`,
documentId: id,
pointId: result.evaluation_point_id,
editAuditStatusId: '',
editAuditStatus: '',
editAuditStatusMessage: '',
title: '该评查点未涉及',
pointName: result.name || '',
pointCode: result.code || '',
groupName: '',
status: 'notApplicable',
content: {},
contentPage: {},
suggestion: result.ai_suggestion || '该评查点未涉及',
result: null,
score: result.score || 0,
finalScore: null,
machineScore: 0,
postAction: '',
}));
const allReviewPoints = [...existingPoints, ...notApplicablePoints];
await patchPointCodes(allReviewPoints as Array<Record<string, any>>, frontendJWT);
return Response.json({
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,
score: unifiedData.summary?.total_score || 0
},
comparison_document: ('comparison_document' in reviewData && !('error' in reviewData)) ? reviewData.comparison_document : null,
scoring_proposals: ('scoring_proposals' in reviewData && !('error' in reviewData)) ? (reviewData.scoring_proposals || []) : [],
userInfo,
jwtToken: frontendJWT,
isProposer,
taskId,
taskName,
flowType: 'graphrag'
});
}
// 对接接口,新的获取评查点结果的方法
const reviewData = await getReviewPoints_fromApi(id, request)
const reviewData = await getReviewPoints_fromApi(id, request);
// 获取评查点数据,传递request对象 旧获取评查点结果的方法
// const reviewData = await getReviewPoints(id, request);
// 获取当前登录用户是否是负责人
const isProposer = await findIsProposer(taskId, userInfo?.user_id, frontendJWT);
// console.log("documentData-------",JSON.stringify(documentData.data,null,2));
// console.log("reviewData-------",JSON.stringify('data' in reviewData ? reviewData.data : '',null,2));
// console.log("reviewData-------",JSON.stringify(reviewData,null,2));
@@ -249,6 +338,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
// 确保reviewData有效且具有预期的属性
if ('document' in reviewData && 'data' in reviewData && 'reviewInfo' in reviewData && 'stats' in reviewData) {
await patchPointCodes(reviewData.data as Array<Record<string, any>>, frontendJWT);
// console.log("reviewData-------",JSON.stringify(reviewData.data));
return Response.json({
previousRoute: previousRoute,
@@ -262,7 +352,8 @@ export async function loader({ request }: LoaderFunctionArgs) {
jwtToken: frontendJWT, // 传递JWT token
isProposer: isProposer,
taskId: taskId, // 传递任务ID
taskName: taskName // 传递任务名称
taskName: taskName, // 传递任务名称
flowType: 'legacy'
});
} else {
console.error("返回的评查数据格式不正确",JSON.stringify(reviewData,null,2));
@@ -338,12 +429,7 @@ export default function CrossCheckingResult() {
const { document, reviewPoints, statistics, reviewInfo, comparison_document, scoring_proposals, jwtToken, userInfo, isProposer, taskId, taskName } = loaderData;
const [isLoading, setIsLoading] = useState(false); // 已经通过loader加载了数据,不需要再显示加载状态
// 视图切换状态:'review' = 评查结果视图, 'compare' = 结构比对视图
const [viewMode, setViewMode] = useState<'review' | 'compare'>('review');
// 判断是否有模板可以进行结构比对
const hasTemplateForCompare = Boolean(comparison_document?.template_contract_path?.trim());
const [activeTab, setActiveTab] = useState<string>('preview');
// 权限控制
const { hasPermission } = usePermission();
@@ -377,6 +463,12 @@ export default function CrossCheckingResult() {
setLocalScoringProposals(scoring_proposals || []);
}, [scoring_proposals]);
useEffect(() => {
if (!comparison_document?.template_contract_path?.trim() && activeTab === 'filecompare') {
setActiveTab('preview');
}
}, [activeTab, comparison_document?.template_contract_path]);
// 处理意见提交成功的回调
const handleOpinionSubmitted = useCallback((newProposal: ScoringProposal) => {
setLocalScoringProposals(prev => [...prev, newProposal]);
@@ -697,7 +789,7 @@ export default function CrossCheckingResult() {
isProcessingRef.current = false;
toastService.error(`确认评查结果失败: ${error instanceof Error ? error.message : '未知错误'}`);
}
}, [document, jwtToken, navigate]);
}, [document, jwtToken, navigate, taskId, taskName]);
// 构建自定义面包屑项 - 使用 useMemo 缓存
const breadcrumbItems = useMemo(() => {
@@ -716,7 +808,7 @@ export default function CrossCheckingResult() {
}, [document?.id, loaderData.previousRoute]);
return (
<div className="cross-checking-result-container">
<div className="review-container">
{isLoading ? (
<div className="flex justify-center items-center p-12">
<div className="loading-spinner"></div>
@@ -753,90 +845,35 @@ export default function CrossCheckingResult() {
)}
</div>
</div>
{/* 返回按钮 */}
<button
type="button"
onClick={() => {
// 返回到交叉评查列表页,并带上任务信息以自动打开模态框
const params = new URLSearchParams({
openModal: 'true',
taskId: taskId || '',
taskName: taskName || '任务详情'
});
navigate(`/cross-checking?${params.toString()}`);
}}
className="inline-flex items-center px-3 py-1.5 text-sm font-medium text-gray-600 border border-gray-400 rounded-md hover:bg-gray-100 hover:text-gray-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-300 mr-3"
>
<i className="ri-arrow-left-line mr-1.5"></i>
</button>
{/* 结构比对/查看评查结果按钮 - 仅当文档类型包含"合同"且有模板时显示 */}
{hasTemplateForCompare && (
<button
type="button"
onClick={() => setViewMode(viewMode === 'review' ? 'compare' : 'review')}
className="inline-flex items-center px-3 py-1.5 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 mr-2"
>
{viewMode === 'review' ? (
<>
<i className="ri-file-copy-2-line mr-1.5"></i>
</>
) : (
<>
<i className="ri-file-list-3-line mr-1.5"></i>
</>
)}
</button>
)}
{/* 完成评查按钮 - 需要 isProposer 且拥有 cross_review:document:complete 权限 */}
{isProposer && canCompleteDocument && (
<button
type="button"
onClick={(event) => {
// 立即阻止所有默认行为
event.preventDefault();
event.stopPropagation();
// 异步调用处理函数
void handleConfirmResults(event);
}}
disabled={isLoading}
className="inline-flex items-center px-3 py-1.5 text-sm font-medium text-white bg-green-800 border border-transparent rounded-md hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-800 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? (
<>
<i className="ri-loader-4-line ri-spin animate-spin mr-1.5"></i>
...
</>
) : (
<>
<i className="ri-check-double-line mr-1.5"></i>
</>
)}
</button>
)}
</div>
{/* 文件信息和操作按钮 */}
{/* <FileInfo
<ReviewTabs
activeTab={activeTab}
onTabChange={setActiveTab}
fileInfo={{
...reviewData.fileInfo,
previousRoute: loaderData.previousRoute
id: document?.id,
previousRoute: loaderData.previousRoute,
path: document?.path,
auditStatus: document?.auditStatus,
type: document?.type || document?.type_id,
comparisonId: comparison_document?.id ? Number(comparison_document.id) : undefined,
backTo: `/cross-checking?${new URLSearchParams({
openModal: 'true',
taskId: taskId || '',
taskName: taskName || '任务详情'
}).toString()}`
}}
onConfirmResults={handleConfirmResults}
/> */}
{/* 根据视图模式切换内容 */}
{viewMode === 'review' ? (
/* 评查结果视图 */
<div className="flex flex-col lg:flex-row space-y-4 lg:space-y-0 lg:space-x-4 lg:justify-between">
onConfirmResults={() => {
void handleConfirmResults();
}}
jwtToken={jwtToken}
showConfirmButton={Boolean(isProposer && canCompleteDocument)}
showCompareTab={Boolean(comparison_document?.template_contract_path?.trim())}
>
{activeTab === 'preview' && (
<div className="flex flex-col lg:flex-row space-y-4 lg:space-y-0 lg:space-x-4">
{/* 左侧:文件预览 */}
<div className="w-full lg:w-[62%]">
<div className="w-full lg:w-[65%]">
<FilePreview
fileContent={document}
reviewPoints={reviewData.reviewPoints}
@@ -845,6 +882,7 @@ export default function CrossCheckingResult() {
charPositions={charPositions}
highlightValue={highlightValue}
aiSuggestionReplace={aiSuggestionReplace}
userInfo={loaderData.userInfo}
/>
</div>
@@ -868,10 +906,11 @@ export default function CrossCheckingResult() {
canVoteProposal={canVoteProposal}
/>
</div>
</div>
) : (
/* 结构比对视图 */
<div className="w-full" style={{
</div>
)}
{activeTab === 'filecompare' && (
<div className="w-full" style={{
height: 'calc(100vh - 120px)',
minHeight: '600px',
display: 'flex',
@@ -881,8 +920,17 @@ export default function CrossCheckingResult() {
doc1Path={document?.path || ''}
doc2Path={comparison_document?.template_contract_path || ''}
/>
</div>
)}
</div>
)}
{activeTab === 'fileinfo' && (
<FileDetails
fileInfo={reviewData.fileInfo}
contractInfo={reviewData.contractInfo}
reviewInfo={reviewData.reviewInfo}
/>
)}
</ReviewTabs>
</>
)}
</div>
+52 -44
View File
@@ -14,7 +14,7 @@ import {
type CrossCheckingUploadedFile,
generateFileId,
formatFileSize,
batchUploadAndAssignCrossCheckingFiles,
uploadCrossCheckingDocument,
createCrossReviewTask
} from "~/api/cross-checking/cross-files-upload";
import {
@@ -302,8 +302,7 @@ export default function CrossCheckingUpload() {
return;
}
// 第一步:上传文件并自动分配任务(新接口)
// console.log("开始批量上传文件并分配任务:", filesToUpload.length, "个,案卷类型:", selectedDocType.name);
// 第一步:上传文档到平台,再用 v3 接口创建交叉评查任务
// 提取用户ID(从选中的组织架构中获取用户)
const userIds = groupChecked.filter(id => {
@@ -316,23 +315,6 @@ export default function CrossCheckingUpload() {
return;
}
// const requireParam = {
// filesToUpload: filesToUpload,
// selectedDocTypeId: selectedDocTypeId,
// priority: priority,
// documentNumber: documentNumber,
// remark: remark,
// isTestDocument: isTestDocument,
// userIds: userIds,
// taskInfo_name: taskInfo.name,
// selectedDocType_name: selectedDocType.code,
// taskInfo_type: taskInfo.type,
// frontendJWT
// }
// // console.log("requireParam", requireParam)
// return;
// 构建负责人ID数组:当前用户(主要负责人)+ 额外负责人
const principalUserIds: number[] = [];
// 添加当前用户作为主要负责人
@@ -347,37 +329,63 @@ export default function CrossCheckingUpload() {
}
});
// 使用文档类型名称作为 doc_type
const uploadResult = await batchUploadAndAssignCrossCheckingFiles(
filesToUpload,
selectedDocTypeId, // 使用选中的文档类型ID
priority,
documentNumber,
remark,
isTestDocument,
const uploadSuccesses: Array<{ file: CrossCheckingUploadedFile; documentId: number }> = [];
const uploadFailures: Array<{ file: CrossCheckingUploadedFile; error: string }> = [];
for (const fileInfo of filesToUpload) {
const binaryData = await fileInfo.file.arrayBuffer();
const uploadResponse = await uploadCrossCheckingDocument(
binaryData,
fileInfo.name,
fileInfo.type,
selectedDocTypeId,
priority,
documentNumber,
remark,
isTestDocument,
null,
false,
frontendJWT
);
if (uploadResponse.error || !uploadResponse.data?.result?.id) {
uploadFailures.push({
file: fileInfo,
error: uploadResponse.error || '上传后未返回文档ID'
});
continue;
}
uploadSuccesses.push({
file: fileInfo,
documentId: uploadResponse.data.result.id
});
}
if (uploadFailures.length > 0) {
toastService.error(`文件上传失败:${uploadFailures[0].error}`);
return;
}
const createTaskResult = await createCrossReviewTask({
documentIds: uploadSuccesses.map(item => item.documentId),
userIds,
taskInfo.name,
selectedDocType.code, // 使用文档类型code
taskInfo.type, // 使用任务类型(市局间交叉评查 或 区局间交叉评查)
frontendJWT,
principalUserIds, // 负责人ID数组
attributeType // 合同类型
);
principalUserIds,
taskName: taskInfo.name,
docTypeId: selectedDocTypeId,
docType: selectedDocType.code,
taskType: taskInfo.type
}, frontendJWT);
// return;
const { successes, failures } = uploadResult;
if (failures.length > 0) {
toastService.error(`文件上传或任务分配失败:${failures[0].error}`);
if (!createTaskResult.success) {
toastService.error(createTaskResult.error || '创建交叉评查任务失败');
return;
}
// 任务创建成功
toastService.success("交叉评查任务创建成功!");
messageService.success(
`任务创建完成!\n任务名称:${taskInfo.name}\n上传文件:${successes.length}\n评查人员:${userIds.length}`,
`任务创建完成!\n任务名称:${taskInfo.name}\n上传文件:${uploadSuccesses.length}\n评查人员:${userIds.length}`,
{
title: '任务创建成功',
confirmText: '确定',
@@ -1183,4 +1191,4 @@ export default function CrossCheckingUpload() {
</div>
</div>
);
}
}