From 22ef99754cba6f5a45e06aa3ed7cf015c6ca3fb9 Mon Sep 17 00:00:00 2001 From: wren <“porlong@qq.com”> Date: Wed, 6 May 2026 10:35:57 +0800 Subject: [PATCH] fix: align rules list and review detail flows --- app/components/layout/Sidebar.tsx | 8 +- app/components/reviews/ReviewPointsList.tsx | 4 +- .../reviews/leftColumn/RulesDirectory.tsx | 4 +- .../reviews/rightColumn/DetailPanel.tsx | 46 +++-- .../rightColumn/ReviewPointDetailCard.tsx | 97 +++++++++- app/config/api-config.ts | 6 +- app/routes/reviewsTest.tsx | 174 ++++++------------ app/routes/rulesTest.list.tsx | 43 +++-- app/services/collabora.wopi.server.ts | 47 +++-- 9 files changed, 257 insertions(+), 172 deletions(-) diff --git a/app/components/layout/Sidebar.tsx b/app/components/layout/Sidebar.tsx index 0aa5a86..b76db1d 100644 --- a/app/components/layout/Sidebar.tsx +++ b/app/components/layout/Sidebar.tsx @@ -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); diff --git a/app/components/reviews/ReviewPointsList.tsx b/app/components/reviews/ReviewPointsList.tsx index e245044..b33dbda 100644 --- a/app/components/reviews/ReviewPointsList.tsx +++ b/app/components/reviews/ReviewPointsList.tsx @@ -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({ /> ); -} \ No newline at end of file +} diff --git a/app/components/reviews/leftColumn/RulesDirectory.tsx b/app/components/reviews/leftColumn/RulesDirectory.tsx index 4f4c47b..93ae5ab 100644 --- a/app/components/reviews/leftColumn/RulesDirectory.tsx +++ b/app/components/reviews/leftColumn/RulesDirectory.tsx @@ -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; } diff --git a/app/components/reviews/rightColumn/DetailPanel.tsx b/app/components/reviews/rightColumn/DetailPanel.tsx index f70338f..e19ce5a 100644 --- a/app/components/reviews/rightColumn/DetailPanel.tsx +++ b/app/components/reviews/rightColumn/DetailPanel.tsx @@ -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((val as Record).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)} >
{f.key} @@ -180,11 +199,8 @@ export function DetailPanel({ {activeTab === 'fields' && ( { - // 通过 activeReviewPoint 的 id 跳转页面 - if (activeReviewPoint) { - onReviewPointSelect(activeReviewPoint.id, page); - } + onFieldClick={(pointId, page) => { + onReviewPointSelect(pointId, page); }} /> )} diff --git a/app/components/reviews/rightColumn/ReviewPointDetailCard.tsx b/app/components/reviews/rightColumn/ReviewPointDetailCard.tsx index 7bfde1d..135e117 100644 --- a/app/components/reviews/rightColumn/ReviewPointDetailCard.tsx +++ b/app/components/reviews/rightColumn/ReviewPointDetailCard.tsx @@ -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; reviewPoint: ReviewPoint; onReviewPointSelect: (id: string, page?: number, charPos?: CharPosition[], value?: string) => void }) { +function RenderConsistencyRule({ rule, reviewPoint, onReviewPointSelect }: { rule: Record; 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; targetField: Record; 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; reviewPoint: ReviewPoint; onReviewPointSelect: (id: string, page?: number, charPos?: CharPosition[], value?: string) => void; fileFormat?: string }) { +function RenderModelRule({ rule, reviewPoint, onReviewPointSelect, fileFormat }: { rule: Record; reviewPoint: ReviewPoint; onReviewPointSelect: (id: string | number, page?: number, charPos?: CharPosition[], value?: string) => void; fileFormat?: string }) { const config = rule.config as { model?: string; fields?: Record; ai_suggestion?: { summary?: string; analysis?: { failure_reason?: string; solution_approach?: string; rule_understanding?: string }; suggestions?: Record; 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; + 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; + const detail = (config.detail && typeof config.detail === 'object' ? config.detail : {}) as Record; + 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 ( +
+
+
{badgeText}
+ + + {passed ? '通过' : '未通过'} + +
+ + {reason && ( +
+ {reason} +
+ )} + + {fieldNames.length > 0 && ( +
+ {fieldNames.map((fieldName) => { + const fieldData = reviewPoint.content?.[fieldName]; + const fieldValue = fieldData?.value; + const displayValue = + typeof fieldValue === 'string' + ? fieldValue + : fieldValue == null + ? '未抽取到值' + : JSON.stringify(fieldValue); + + return ( + + ); + })} +
+ )} +
+ ); +} + // ── 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
{otherRules.length > 0 &&
}
; } - return null; + return ; })} diff --git a/app/config/api-config.ts b/app/config/api-config.ts index 77c58d2..fc732b5 100644 --- a/app/config/api-config.ts +++ b/app/config/api-config.ts @@ -180,9 +180,9 @@ const configs: Record = { // 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服务器地址 diff --git a/app/routes/reviewsTest.tsx b/app/routes/reviewsTest.tsx index fb83693..4f2d340 100644 --- a/app/routes/reviewsTest.tsx +++ b/app/routes/reviewsTest.tsx @@ -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(fallbackReviewData); - const [activeReviewPointResultId, setActiveReviewPointResultId] = useState(null); + const [activeReviewPointResultId, setActiveReviewPointResultId] = useState(null); const [targetPage, setTargetPage] = useState(undefined); const [charPositions, setCharPositions] = useState | undefined>(undefined); const [highlightValue, setHighlightValue] = useState(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(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 预览 */} {/* 中栏:文件预览(根据文件类型切换) */}
- {document?.path?.split('.').pop()?.toLowerCase() === 'docx' ? ( + {previewExtension === 'docx' ? ( ) : ( ): 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 } diff --git a/app/services/collabora.wopi.server.ts b/app/services/collabora.wopi.server.ts index 86ccd14..fb68af2 100644 --- a/app/services/collabora.wopi.server.ts +++ b/app/services/collabora.wopi.server.ts @@ -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');