From 3fb7e9f5d08a93c4972004782f427830f678ab97 Mon Sep 17 00:00:00 2001 From: wren <“porlong@qq.com”> Date: Thu, 30 Apr 2026 17:44:05 +0800 Subject: [PATCH] feat: stabilize document type and upload flows --- app/api/document-types/document-types.ts | 16 +- app/api/files/documents.ts | 336 +++++++----- app/api/files/files-upload.ts | 343 ++++++++++-- app/api/queue.ts | 14 +- app/api/role-permissions/role-permissions.ts | 52 +- app/config/api-config.ts | 18 +- app/config/minimal-scope.ts | 3 +- app/routes/document-types.new.tsx | 449 ++++++++++++--- app/routes/files.upload.tsx | 207 +++++-- app/routes/role-permissions._index.tsx | 157 +++--- app/styles/pages/document-types_index.css | 48 ++ app/styles/pages/document-types_new.css | 545 ++++++++++++++++--- app/styles/pages/files_upload.css | 48 ++ app/utils/route-alias.shared.js | 175 ++++++ app/utils/route-alias.ts | 21 +- docs/route-alias-guidelines.md | 107 ++++ package.json | 1 + scripts/test-route-aliases.mjs | 73 +++ 18 files changed, 2122 insertions(+), 491 deletions(-) create mode 100644 app/utils/route-alias.shared.js create mode 100644 docs/route-alias-guidelines.md create mode 100644 scripts/test-route-aliases.mjs diff --git a/app/api/document-types/document-types.ts b/app/api/document-types/document-types.ts index b3b751a..8d9ab01 100644 --- a/app/api/document-types/document-types.ts +++ b/app/api/document-types/document-types.ts @@ -40,6 +40,9 @@ export interface RuleSetOption { ruleType: string; ruleName: string; status: string; + currentVersionId?: number | null; + fallbackVersionId?: number | null; + hasUsableVersion?: boolean; } export interface EntryModuleOption { @@ -224,11 +227,19 @@ export async function getEntryModules( ): Promise<{ data?: EntryModuleOption[]; error?: string }> { try { const response = await axios.get(`${API_BASE_URL}/api/v3/entry-modules`, { + params: { page: 1, page_size: 200 }, headers: authHeaders(token), }); - const items = extractData(response) || []; + const payload = extractData(response); + const items = Array.isArray(payload) + ? payload + : Array.isArray(payload?.items) + ? payload.items + : []; + return { data: items.map((m: any) => ({ id: m.id, name: m.name })) }; } catch (error) { + console.error("获取入口模块失败:", error); return { error: error instanceof Error ? error.message : "获取入口模块失败" }; } } @@ -250,6 +261,9 @@ export async function getRuleSets( ruleType: r.ruleType, ruleName: r.ruleName, status: r.status, + currentVersionId: r.currentVersionId ?? null, + fallbackVersionId: r.fallbackVersionId ?? null, + hasUsableVersion: !!r.hasUsableVersion, })), }; } catch (error) { diff --git a/app/api/files/documents.ts b/app/api/files/documents.ts index 34c2ed8..d66904d 100644 --- a/app/api/files/documents.ts +++ b/app/api/files/documents.ts @@ -1,4 +1,5 @@ -import { postgrestGet, postgrestDelete, postgrestPut, postgrestPost } from '../postgrest-client'; +import axios from 'axios'; +import { postgrestGet, postgrestDelete } from '../postgrest-client'; import { getDocumentTypes } from '../document-types/document-types'; import { formatDate } from '../../utils'; import { API_BASE_URL } from '~/config/api-config'; @@ -181,6 +182,21 @@ interface LeauditListPage { documents: LeauditListItem[]; } +interface LeauditDocumentDetail extends LeauditListItem { + documentNumber?: string | null; + remark?: string | null; + isTestDocument?: boolean | null; + auditStatus?: number | null; + pageCount?: number | null; +} + +interface DocumentMetadataUpdateDTO { + documentNumber?: string; + auditStatus?: number; + isTestDocument?: boolean; + remark?: string; +} + /** * 获取文件扩展名 * @param filename 文件名 @@ -246,6 +262,92 @@ function buildDocumentNumber(doc: LeauditListItem | LeauditHistoryVersion): stri return ''; } +function isUnsupportedNewDocumentCrud(error: unknown): boolean { + return axios.isAxiosError(error) && [404, 405, 501].includes(error.response?.status || 0); +} + +function getErrorMessage(error: unknown, fallback: string): string { + if (axios.isAxiosError(error)) { + return error.response?.data?.message || error.response?.data?.detail || error.message || fallback; + } + return error instanceof Error ? error.message : fallback; +} + +function mapHistoryVersionToUI(history: LeauditHistoryVersion, source: LeauditListItem): DocumentVersionUI { + return { + id: history.documentId, + name: history.fileName || source.fileName || source.normalizedName || '未命名文档', + documentNumber: buildDocumentNumber(history), + type: source.typeId?.toString() || '', + typeName: typeNameFromCode(source.typeCode), + size: 0, + auditStatus: mapLeauditDocToAuditStatus({ + processingStatus: history.processingStatus, + runStatus: history.runStatus, + passedCount: null, + failedCount: null, + }), + fileStatus: mapProcessingStatusToFileStatus(history.processingStatus), + issues: null, + uploadTime: formatDate(history.updatedAt || ''), + fileType: history.fileExt || getFileExtension(history.fileName || source.fileName || ''), + path: '', + isTest: false, + updatedAt: formatDate(history.updatedAt || ''), + pageCount: 0, + ocrResult: undefined, + versionNumber: history.versionNo, + pass_count: null, + warning_count: 0, + error_count: null, + manual_count: null, + previous_pass_count: null, + previous_warning_count: null, + previous_error_count: null, + previous_manual_count: null + }; +} + +function mapLeauditDocumentToUI(doc: LeauditListItem | LeauditDocumentDetail): DocumentUI { + const historyVersions = (doc.historyVersions || []).map((history) => mapHistoryVersionToUI(history, doc)); + + return { + id: doc.documentId, + name: doc.fileName || doc.normalizedName || '未命名文档', + documentNumber: ('documentNumber' in doc && doc.documentNumber) ? doc.documentNumber : buildDocumentNumber(doc), + type: doc.typeId?.toString() || '', + typeName: typeNameFromCode(doc.typeCode), + size: doc.fileSize || 0, + auditStatus: ('auditStatus' in doc && doc.auditStatus !== null && doc.auditStatus !== undefined) + ? doc.auditStatus + : mapLeauditDocToAuditStatus(doc), + fileStatus: mapProcessingStatusToFileStatus(doc.processingStatus), + issues: doc.failedCount ?? null, + uploadTime: formatDate(doc.updatedAt || ''), + fileType: doc.fileExt || getFileExtension(doc.fileName || ''), + path: doc.ossUrl || '', + isTest: Boolean(('isTestDocument' in doc && doc.isTestDocument) || false), + remark: 'remark' in doc ? (doc.remark || '') : '', + updatedAt: formatDate(doc.updatedAt || ''), + pageCount: ('pageCount' in doc ? (doc.pageCount || 0) : 0), + ocrResult: undefined, + pass_count: doc.passedCount ?? null, + warning_count: 0, + error_count: doc.failedCount ?? null, + manual_count: doc.skippedCount ?? null, + warning_messages: [], + error_messages: [], + manual_messages: [], + historyCount: Math.max(0, (doc.totalVersions || 1) - 1), + previousIssues: historyVersions[0]?.issues ?? null, + previous_pass_count: historyVersions[0]?.pass_count ?? null, + previous_warning_count: historyVersions[0]?.warning_count ?? null, + previous_error_count: historyVersions[0]?.error_count ?? null, + previous_manual_count: historyVersions[0]?.manual_count ?? null, + historyVersions: historyVersions.length > 0 ? historyVersions : undefined + }; +} + /** * 获取评查结果 * @param id 评查结果ID @@ -360,21 +462,36 @@ export async function deleteDocument(id: string, userId: string, token?: string) return { error: '用户身份验证失败', status: 401 }; } - const response = await postgrestDelete( - '/api/postgrest/proxy/documents', - { - filter: { - 'id': `eq.${id}`, - 'user_id': `eq.${userId}` // 确保只能删除自己的文档 - }, - token + try { + // 新后端接口应基于 JWT 在服务端做数据隔离: + // - provincial_admin: 全量 + // - admin: 本地市 + // - common: 自己上传的文档 + await axios.delete(`${API_BASE_URL}/api/documents/${id}`, { + headers: token ? { 'Authorization': `Bearer ${token}` } : undefined + }); + return { success: true }; + } catch (error) { + if (!isUnsupportedNewDocumentCrud(error)) { + return { + error: getErrorMessage(error, '删除文档失败'), + status: axios.isAxiosError(error) ? error.response?.status : 500 + }; } - ); - + } + + const response = await postgrestDelete('/api/postgrest/proxy/documents', { + filter: { + 'id': `eq.${id}`, + 'user_id': `eq.${userId}` // 旧链路仅允许删除自己的文档 + }, + token + }); + if (response.error) { return { error: response.error, status: response.status }; } - + return { success: true }; } catch (error) { console.error('删除文档失败:', error); @@ -404,22 +521,39 @@ export async function getDocument(id: string, userId: string, frontendJWT?: stri return { error: '用户身份验证失败', status: 401 }; } - const response = await postgrestGet( - '/api/postgrest/proxy/documents', - { - filter: { - 'id': `eq.${id}`, - 'user_id': `eq.${userId}` - }, - limit: 1, - token: frontendJWT + try { + const response = await axios.get(`${API_BASE_URL}/api/documents/${id}`, { + headers: frontendJWT ? { 'Authorization': `Bearer ${frontendJWT}` } : undefined + }); + + const detail = extractApiData(response.data); + if (!detail) { + return { error: '文档不存在', status: 404 }; } - ); - + + return { data: mapLeauditDocumentToUI(detail) }; + } catch (error) { + if (!isUnsupportedNewDocumentCrud(error)) { + return { + error: getErrorMessage(error, '获取文档详情失败'), + status: axios.isAxiosError(error) ? error.response?.status : 500 + }; + } + } + + const response = await postgrestGet('/api/postgrest/proxy/documents', { + filter: { + 'id': `eq.${id}`, + 'user_id': `eq.${userId}` + }, + limit: 1, + token: frontendJWT + }); + if (response.error) { return { error: response.error, status: response.status }; } - + const extractedData = extractApiData(response.data); if (!extractedData || extractedData.length === 0) { return { error: '文档不存在', status: 404 }; @@ -555,54 +689,68 @@ export async function updateDocument(id: string, document: Partial & return { error: '用户身份验证失败', status: 401 }; } - // 准备API数据 - 将UI数据转换为API格式 - // 根据文档,可更新字段:document_number, audit_status, is_test_document, remark - const apiDocument: Partial = {}; + const apiDocument: DocumentMetadataUpdateDTO = {}; if (document.documentNumber !== undefined) { - apiDocument.document_number = document.documentNumber; + apiDocument.documentNumber = document.documentNumber; } if (document.auditStatus !== undefined) { - apiDocument.audit_status = document.auditStatus; + apiDocument.auditStatus = document.auditStatus; } if (document.isTest !== undefined) { - apiDocument.is_test_document = document.isTest; + apiDocument.isTestDocument = document.isTest; } if (document.remark !== undefined) { apiDocument.remark = document.remark; } - // console.log('📤 [updateDocument] 更新文档API数据:', apiDocument); - - // 使用 axios-client 的 apiRequest 方法(支持自定义 headers) - // 接口路径: /api/postgrest/proxy/documents?id=eq.{id} - // 后端会自动注入 user_id 过滤条件(根据JWT中的用户信息) - const { apiRequest } = await import('../axios-client'); - const response = await apiRequest( - `/api/postgrest/proxy/documents?id=eq.${id}`, - { - method: 'PATCH', - data: apiDocument, + try { + await axios.put(`${API_BASE_URL}/api/documents/${id}`, apiDocument, { headers: { 'Content-Type': 'application/json', ...(frontendJWT ? { 'Authorization': `Bearer ${frontendJWT}` } : {}) } + }); + } catch (error) { + if (!isUnsupportedNewDocumentCrud(error)) { + console.error('❌ [updateDocument] 更新文档API错误:', error); + return { + error: getErrorMessage(error, '更新文档信息失败'), + status: axios.isAxiosError(error) ? error.response?.status : 500 + }; } - ); - if (response.error) { - console.error('❌ [updateDocument] 更新文档API错误:', response.error); - return { error: response.error, status: response.status }; - } + // 旧链路回退:仅允许修改自己的文档;新链路上线后应由后端基于地区/角色做数据隔离。 + const { apiRequest } = await import('../axios-client'); + const response = await apiRequest( + `/api/postgrest/proxy/documents?id=eq.${id}`, + { + method: 'PATCH', + data: { + ...(document.documentNumber !== undefined ? { document_number: document.documentNumber } : {}), + ...(document.auditStatus !== undefined ? { audit_status: document.auditStatus } : {}), + ...(document.isTest !== undefined ? { is_test_document: document.isTest } : {}), + ...(document.remark !== undefined ? { remark: document.remark } : {}), + }, + headers: { + 'Content-Type': 'application/json', + ...(frontendJWT ? { 'Authorization': `Bearer ${frontendJWT}` } : {}) + } + } + ); - // 检查返回数据 - // 成功时返回更新后的文档数组,空数组表示文档不存在或无权访问 - const responseData = response.data; - if (!responseData || (Array.isArray(responseData) && responseData.length === 0)) { - return { error: '文档不存在或无权访问', status: 404 }; + if (response.error) { + console.error('❌ [updateDocument] 更新文档API错误:', response.error); + return { error: response.error, status: response.status }; + } + + const responseData = response.data; + if (!responseData || (Array.isArray(responseData) && responseData.length === 0)) { + return { error: '文档不存在或无权访问', status: 404 }; + } } // 获取更新后的完整文档数据(包含关联的文档类型信息) @@ -672,26 +820,18 @@ export async function getDocumentsListFromAPI(searchParams: { } } - if (documentTypeIds && documentTypeIds.length === 1) { - const typeResponse = await getDocumentTypes({ ids: documentTypeIds, page: 1, pageSize: 10 }, token); - const matchedType = typeResponse.data?.types?.[0]; - if (matchedType?.code) { - params.typeCode = matchedType.code; - } + if (documentTypeIds && documentTypeIds.length > 0) { + params.type_ids = documentTypeIds.join(','); } - // 下面几个旧筛选项在新系统版列表接口里暂未一一对齐: + // 下面几个旧筛选项暂未完全对齐: // - documentNumber // - auditStatus - // - dateFrom/dateTo - // - 多个 documentTypeIds 的组合筛选 - // 先保留参数签名,后续再单独接新后端的类型/状态体系。 void documentNumber; void auditStatus; - void dateFrom; - void dateTo; + if (dateFrom) params.dateFrom = dateFrom; + if (dateTo) params.dateTo = dateTo; - const axios = await import('axios').then(m => m.default); const response = await axios.get(`${API_BASE_URL}/api/documents/list`, { params, headers: { @@ -709,73 +849,7 @@ export async function getDocumentsListFromAPI(searchParams: { const totalCount = pageData.total || 0; const totalPages = pageData.totalPages || Math.ceil(totalCount / pageSize) || 0; - const convertedDocuments: DocumentUI[] = backendDocuments.map((doc) => { - const historyVersions: DocumentVersionUI[] = (doc.historyVersions || []).map((hv) => ({ - id: hv.documentId, - name: hv.fileName || doc.fileName || doc.normalizedName || '未命名文档', - documentNumber: buildDocumentNumber(hv), - type: doc.typeId?.toString() || '', - typeName: typeNameFromCode(doc.typeCode), - size: 0, - auditStatus: mapLeauditDocToAuditStatus({ - processingStatus: hv.processingStatus, - runStatus: hv.runStatus, - passedCount: null, - failedCount: null, - }), - fileStatus: mapProcessingStatusToFileStatus(hv.processingStatus), - issues: null, - uploadTime: formatDate(hv.updatedAt || ''), - fileType: hv.fileExt || getFileExtension(hv.fileName || doc.fileName || ''), - path: '', - isTest: false, - updatedAt: formatDate(hv.updatedAt || ''), - pageCount: 0, - ocrResult: undefined, - versionNumber: hv.versionNo, - pass_count: null, - warning_count: 0, - error_count: null, - manual_count: null, - previous_pass_count: null, - previous_warning_count: null, - previous_error_count: null, - previous_manual_count: null - })); - - return { - id: doc.documentId, - name: doc.fileName || doc.normalizedName || '未命名文档', - documentNumber: buildDocumentNumber(doc), - type: doc.typeId?.toString() || '', - typeName: typeNameFromCode(doc.typeCode), - size: doc.fileSize || 0, - auditStatus: mapLeauditDocToAuditStatus(doc), - fileStatus: mapProcessingStatusToFileStatus(doc.processingStatus), - issues: doc.failedCount ?? null, - uploadTime: formatDate(doc.updatedAt || ''), - fileType: doc.fileExt || getFileExtension(doc.fileName || ''), - path: doc.ossUrl || '', - isTest: false, - updatedAt: formatDate(doc.updatedAt || ''), - pageCount: 0, - ocrResult: undefined, - pass_count: doc.passedCount ?? null, - warning_count: 0, - error_count: doc.failedCount ?? null, - manual_count: doc.skippedCount ?? null, - warning_messages: [], - error_messages: [], - manual_messages: [], - historyCount: Math.max(0, (doc.totalVersions || 1) - 1), - previousIssues: historyVersions[0]?.issues ?? null, - previous_pass_count: historyVersions[0]?.pass_count ?? null, - previous_warning_count: historyVersions[0]?.warning_count ?? null, - previous_error_count: historyVersions[0]?.error_count ?? null, - previous_manual_count: historyVersions[0]?.manual_count ?? null, - historyVersions: historyVersions.length > 0 ? historyVersions : undefined - }; - }); + const convertedDocuments: DocumentUI[] = backendDocuments.map((doc) => mapLeauditDocumentToUI(doc)); return { data: { diff --git a/app/api/files/files-upload.ts b/app/api/files/files-upload.ts index e7cc6bd..e9e1ba4 100644 --- a/app/api/files/files-upload.ts +++ b/app/api/files/files-upload.ts @@ -110,6 +110,25 @@ function getDocumentTypeIdsFromSession(): number[] | null { } } +function getSelectedModuleIdFromSession(): number | null { + if (typeof window === 'undefined') { + return null; + } + + try { + const moduleId = sessionStorage.getItem('selectedModuleId'); + if (!moduleId) { + return null; + } + + const parsed = Number(moduleId); + return Number.isFinite(parsed) && parsed > 0 ? parsed : null; + } catch (error) { + console.error('❌ [getSelectedModuleId] 解析 selectedModuleId 失败:', error); + return null; + } +} + // 文档状态枚举 export enum DocumentStatus { waiting = 'waiting', @@ -132,6 +151,15 @@ export interface DocumentType { ruleSetIds?: number[]; } +export interface UploadErrorDetails { + title: string; + summary: string; + detailLines: string[]; + actionLines: string[]; + rawMessage: string; + category: 'rule_binding' | 'rule_version' | 'entry_module' | 'permission' | 'file' | 'unknown'; +} + // 提取结果接口 interface ExtractedResult { [key: string]: unknown; @@ -216,6 +244,223 @@ interface NewUploadResponse { autoRunTriggered: boolean; } +type UploadErrorPayload = Record | null | undefined; + +function readPayloadValue(payload: UploadErrorPayload, keys: string[]): unknown { + if (!payload || typeof payload !== 'object') { + return undefined; + } + + for (const key of keys) { + if (key in payload && payload[key] !== undefined && payload[key] !== null && payload[key] !== '') { + return payload[key]; + } + } + + const nestedData = payload.data; + if (nestedData && typeof nestedData === 'object') { + const nestedRecord = nestedData as Record; + for (const key of keys) { + if (key in nestedRecord && nestedRecord[key] !== undefined && nestedRecord[key] !== null && nestedRecord[key] !== '') { + return nestedRecord[key]; + } + } + } + + return undefined; +} + +function stringifyIdList(values?: number[]): string | null { + if (!values || values.length === 0) { + return null; + } + + return values.join('、'); +} + +export function buildUploadErrorDetails( + rawMessage: string, + options?: { + documentType?: DocumentType | null; + status?: number; + payload?: UploadErrorPayload; + } +): UploadErrorDetails { + const documentType = options?.documentType ?? null; + const status = options?.status; + const payload = options?.payload; + const message = (rawMessage || '上传失败').trim(); + const normalizedMessage = message.toLowerCase(); + + const payloadRuleSetName = readPayloadValue(payload, ['ruleSetName', 'ruleName', 'ruleSet', 'rule_type']); + const payloadRuleSetId = readPayloadValue(payload, ['ruleSetId', 'rule_set_id']); + const payloadEntryModuleName = readPayloadValue(payload, ['entryModuleName', 'moduleName', 'entryModule']); + const payloadEntryModuleId = readPayloadValue(payload, ['entryModuleId', 'entry_module_id']); + + const ruleSetDisplayName = payloadRuleSetName + ? String(payloadRuleSetName) + : documentType?.ruleSetIds?.length === 1 + ? `规则集 ID ${documentType.ruleSetIds[0]}` + : null; + const entryModuleDisplayName = payloadEntryModuleName + ? String(payloadEntryModuleName) + : payloadEntryModuleId + ? `入口模块 ID ${payloadEntryModuleId}` + : documentType?.entryModuleId + ? `入口模块 ID ${documentType.entryModuleId}` + : null; + const documentTypeDisplayName = documentType + ? `${documentType.name}${documentType.code ? `(${documentType.code})` : ''}` + : '当前文档类型'; + + if (status === 401 || normalizedMessage.includes('unauthorized') || message.includes('未登录') || message.includes('token')) { + return { + title: '登录状态已失效', + summary: '当前登录状态已失效,系统无法继续上传文件。', + detailLines: [ + '请先重新登录,再重新选择文件上传。', + `后端返回:${message}`, + ], + actionLines: ['刷新页面并重新登录后重试。'], + rawMessage: message, + category: 'permission', + }; + } + + if (status === 403 || message.includes('无权限') || message.includes('权限') || normalizedMessage.includes('forbidden')) { + return { + title: '当前账号没有上传权限', + summary: '当前账号没有执行该上传操作的权限,文件尚未进入审核流程。', + detailLines: [ + `文档类型:${documentTypeDisplayName}`, + `后端返回:${message}`, + ], + actionLines: ['请联系管理员检查当前账号的上传权限或入口模块授权。'], + rawMessage: message, + category: 'permission', + }; + } + + if ( + message.includes('未绑定可用规则版本') || + message.includes('未绑定规则版本') || + message.includes('没有可用规则版本') || + message.includes('未找到可用规则版本') + ) { + const detailLines = [ + `文档类型:${documentTypeDisplayName}`, + documentType?.ruleSetIds?.length + ? `已绑定规则集:${stringifyIdList(documentType.ruleSetIds)}` + : '当前文档类型还没有绑定任何规则集。', + entryModuleDisplayName + ? `归属入口:${entryModuleDisplayName}` + : '当前文档类型还没有配置入口模块,上传入口配置也需要一起检查。', + ]; + + if (ruleSetDisplayName || payloadRuleSetId) { + detailLines.push(`疑似异常规则集:${ruleSetDisplayName || `规则集 ID ${payloadRuleSetId}`}`); + } else if (documentType?.ruleSetIds && documentType.ruleSetIds.length > 1) { + detailLines.push('该文档类型绑定了多个规则集,需要逐个确认是否都已发布可用版本。'); + } else { + detailLines.push('后端没有返回具体规则集名称,建议优先检查该文档类型绑定的规则集是否已发布版本。'); + } + + detailLines.push(`后端返回:${message}`); + + return { + title: '审核规则未配置完整', + summary: `${documentTypeDisplayName} 目前没有可用的审核规则版本,所以系统无法接收本次上传。`, + detailLines, + actionLines: [ + '到“系统设置 / 文档类型管理”检查该文档类型是否绑定了正确的规则集。', + '到“规则管理 / 规则集管理”确认对应规则集至少有一个已发布、可用的版本。', + '如果首页入口也异常,请同时到“系统设置 / 入口模块管理”检查入口模块绑定。', + ], + rawMessage: message, + category: 'rule_binding', + }; + } + + if (message.includes('规则集不存在') || message.includes('规则版本不存在') || message.includes('未发布')) { + const detailLines = [ + `文档类型:${documentTypeDisplayName}`, + ruleSetDisplayName + ? `相关规则集:${ruleSetDisplayName}` + : '后端未返回明确的规则集名称。', + `后端返回:${message}`, + ]; + + return { + title: '规则集版本不可用', + summary: '当前上传入口关联的规则集或规则版本不可用,文件无法开始审核。', + detailLines, + actionLines: [ + '到“规则管理 / 规则集管理”检查对应规则集是否存在、是否已发布版本。', + '如文档类型绑定了错误的规则集,请到“系统设置 / 文档类型管理”修正绑定关系。', + ], + rawMessage: message, + category: 'rule_version', + }; + } + + if (message.includes('入口模块')) { + return { + title: '入口模块配置异常', + summary: '当前文档类型关联的入口模块配置异常,导致上传链路无法正常工作。', + detailLines: [ + `文档类型:${documentTypeDisplayName}`, + entryModuleDisplayName + ? `相关入口模块:${entryModuleDisplayName}` + : '后端未返回明确的入口模块名称或编号。', + `后端返回:${message}`, + ], + actionLines: [ + '到“系统设置 / 入口模块管理”检查目标入口模块是否存在、是否启用、跳转路径是否正确。', + '到“系统设置 / 文档类型管理”确认该文档类型绑定到了正确的入口模块。', + ], + rawMessage: message, + category: 'entry_module', + }; + } + + if (status === 413 || message.includes('文件过大') || message.includes('too large')) { + return { + title: '文件过大,上传被拒绝', + summary: '当前文件大小超出系统允许范围,服务器未接收该文件。', + detailLines: [`后端返回:${message}`], + actionLines: ['请压缩文件体积或拆分后重新上传。'], + rawMessage: message, + category: 'file', + }; + } + + if (message.includes('文件类型') || message.includes('格式') || status === 415) { + return { + title: '文件格式不支持', + summary: '当前文件格式不符合上传要求,服务器拒绝处理。', + detailLines: [`后端返回:${message}`], + actionLines: ['请确认上传的是 PDF 或 Word 文件,并检查文件是否损坏。'], + rawMessage: message, + category: 'file', + }; + } + + return { + title: '上传失败', + summary: '文件未能成功上传到服务器,请根据下方信息检查原因。', + detailLines: [ + `文档类型:${documentTypeDisplayName}`, + `后端返回:${message}`, + ], + actionLines: [ + '如果是规则或入口配置问题,请先检查文档类型、规则集、入口模块三处配置。', + '如暂时无法定位,可将下方后端原始提示发给后端同学继续排查。', + ], + rawMessage: message, + category: 'unknown', + }; +} + /** * 将文件转换为二进制数据 */ @@ -406,7 +651,7 @@ export async function uploadDocumentToServer( autoRun: boolean = true, speed: string = "normal", jwtToken?: string, -): Promise<{ data: UploadResult } | { error: string; status?: number }> { +): Promise<{ data: UploadResult } | { error: string; status?: number; payload?: UploadErrorPayload }> { try { const formData = new FormData(); const blob = new Blob([binaryData], { type: fileType }); @@ -433,7 +678,7 @@ export async function uploadDocumentToServer( // Result envelope const uploadData: NewUploadResponse | undefined = body?.data; if (!uploadData || !uploadData.documentId) { - return { error: body?.message || "上传响应解析失败", status: response.status }; + return { error: body?.message || body?.msg || "上传响应解析失败", status: response.status, payload: body }; } return { @@ -454,10 +699,12 @@ export async function uploadDocumentToServer( if (axios.isAxiosError(axiosError)) { const serverMessage = (axiosError.response?.data as any)?.message || - (axiosError.response?.data as any)?.msg; + (axiosError.response?.data as any)?.msg || + (axiosError.response?.data as any)?.error; return { error: serverMessage || `上传失败 (HTTP ${axiosError.response?.status || "unknown"})`, status: axiosError.response?.status, + payload: axiosError.response?.data, }; } return { error: axiosError instanceof Error ? axiosError.message : "上传失败" }; @@ -489,8 +736,11 @@ export async function getTodayDocuments( dateFrom: today, }; - if (documentTypeIds && documentTypeIds.length > 0) { - params.typeCode = ""; // 后续可按 typeId→typeCode 映射 + const selectedModuleId = getSelectedModuleIdFromSession(); + if (selectedModuleId) { + params.entry_module_id = selectedModuleId; + } else if (documentTypeIds && documentTypeIds.length > 0) { + params.type_ids = documentTypeIds.join(","); } const headers: Record = {}; @@ -532,8 +782,11 @@ export async function getTodayDocuments( export async function getDocumentTypes(token?: string): Promise<{data: DocumentType[]; error?: never} | {data?: never; error: string; status?: number}> { try { const documentTypeIds = getDocumentTypeIdsFromSession(); + const selectedModuleId = getSelectedModuleIdFromSession(); const params: Record = {}; - if (documentTypeIds && documentTypeIds.length > 0) { + if (selectedModuleId) { + params.entry_module_id = String(selectedModuleId); + } else if (documentTypeIds && documentTypeIds.length > 0) { params.ids = documentTypeIds.join(","); } @@ -582,23 +835,37 @@ export async function getDocumentsStatus( if ((!documentIds || documentIds.length === 0) && (!attachmentIds || attachmentIds.length === 0)) { return { data: [] }; } - - // 查询主文档状态 - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let documentsResponse: any = { data: [], error: undefined, status: undefined }; - if (documentIds && documentIds.length > 0) { - const documentsParams: PostgrestParams = { - select: 'id, status', - filter: { - 'id': `in.(${documentIds.join(',')})` - } - }; - documentsResponse = await postgrestGet('/api/postgrest/proxy/documents', { ...documentsParams, token }); + const headers: Record = {}; + if (token) { + headers["Authorization"] = `Bearer ${token}`; + } + + const normalizedIds = Array.from(new Set(documentIds.filter(id => Number.isFinite(id) && id > 0))); + const allData: Document[] = []; + + if (normalizedIds.length > 0) { + const response = await axios.get(`${API_BASE_URL}/api/documents/status`, { + params: { ids: normalizedIds.join(",") }, + headers, + }); + const statusItems = extractApiData>(response.data) || []; + + statusItems.forEach(item => { + allData.push({ + id: item.documentId, + name: `文档_${item.documentId}`, + type_id: 0, + file_size: 0, + status: (item.processingStatus as DocumentStatus) || DocumentStatus.waiting, + created_at: item.updatedAt || "", + }); + }); } - // 查询合同附件状态 - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let attachmentResponse: any = { data: [], error: undefined, status: undefined }; if (attachmentIds && attachmentIds.length > 0) { const attachmentParams: PostgrestParams = { select: 'id, status', @@ -606,38 +873,22 @@ export async function getDocumentsStatus( 'id': `in.(${attachmentIds.join(',')})` } }; - attachmentResponse = await postgrestGet('/api/postgrest/proxy/contract_structure_comparison', { ...attachmentParams, token }); - } - - if (documentsResponse.error && attachmentResponse.error) { - return { error: documentsResponse.error || attachmentResponse.error, status: documentsResponse.status || attachmentResponse.status }; - } - - let allData: Document[] = []; - - // 处理主文档数据 - if (!documentsResponse.error && documentsResponse.data) { - const extractedDocuments = extractApiData(documentsResponse.data); - if (extractedDocuments) { - allData = [...allData, ...extractedDocuments]; + const attachmentResponse = await postgrestGet('/api/postgrest/proxy/contract_structure_comparison', { ...attachmentParams, token }); + if (attachmentResponse.error) { + return { error: attachmentResponse.error, status: attachmentResponse.status }; } - } - // 处理合同附件数据 - if (!attachmentResponse.error && attachmentResponse.data) { - const extractedAttachments = extractApiData(attachmentResponse.data); - if (extractedAttachments) { - // 将ContractStructureComparison转换为Document格式 - const convertedAttachments: Document[] = extractedAttachments.map(item => ({ + const extractedAttachments = extractApiData(attachmentResponse.data) || []; + extractedAttachments.forEach(item => { + allData.push({ id: item.id, name: item.template_contract_name || `合同结构比较记录_${item.id}`, type_id: 1, file_size: item.file_size || 0, status: item.status, created_at: item.created_at - })); - allData = [...allData, ...convertedAttachments]; - } + }); + }); } return { data: allData }; @@ -648,4 +899,4 @@ export async function getDocumentsStatus( status: 500 }; } -} \ No newline at end of file +} diff --git a/app/api/queue.ts b/app/api/queue.ts index 7bcfc19..6694bfe 100644 --- a/app/api/queue.ts +++ b/app/api/queue.ts @@ -4,6 +4,12 @@ import axios from 'axios'; import { API_BASE_URL } from '../config/api-config'; +interface ApiResult { + code: number; + message: string; + data: T | null; +} + /** * 队列状态响应接口 */ @@ -43,12 +49,16 @@ export async function getQueueStatus(): Promise<{ data?: QueueStatus; error?: st headers['Authorization'] = `Bearer ${token}`; } - const response = await axios.get( + const response = await axios.get>( `${API_BASE_URL}/api/v2/system/queue/status`, { headers } ); - return { data: response.data }; + if (response.data?.data) { + return { data: response.data.data }; + } + + return { error: response.data?.message || '队列状态响应格式错误' }; } catch (error) { // 队列接口暂未迁移,404 时返回空状态不报错 if (axios.isAxiosError(error) && error.response?.status === 404) { diff --git a/app/api/role-permissions/role-permissions.ts b/app/api/role-permissions/role-permissions.ts index 3bc306c..31b8c42 100644 --- a/app/api/role-permissions/role-permissions.ts +++ b/app/api/role-permissions/role-permissions.ts @@ -516,7 +516,8 @@ export async function getRoleRoutesWithPermissions(roleId: number): Promise<{ */ export async function saveRoleApiPermissions( roleId: number, - permissionIds: number[] + permissionIds: number[], + scopePermissionIds: number[] = [] ): Promise<{ success: boolean; message: string }> { try { // 构建权限配置 @@ -530,7 +531,8 @@ export async function saveRoleApiPermissions( const response = await post('/api/v3/rbac/role-permissions', { role_id: roleId, permissions, - replace: true // 替换模式:先删除现有权限,再插入新权限 + replace: true, // 替换模式:仅替换当前页面涉及的权限范围 + replace_scope_permission_ids: scopePermissionIds }); if (response.error) { @@ -555,6 +557,52 @@ export async function saveRoleApiPermissions( } } +/** + * 原子保存角色的菜单权限与 API 权限 + * @param roleId 角色ID + * @param routeIds 路由ID数组 + * @param permissionIds 权限ID数组 + * @param scopePermissionIds 本页允许被替换清理的权限范围 + */ +export async function saveRoleAccess( + roleId: number, + routeIds: number[], + permissionIds: number[], + scopePermissionIds: number[] = [] +): Promise<{ success: boolean; message: string; code?: number }> { + try { + const response = await post(`/api/v3/rbac/roles/${roleId}/access`, { + route_ids: routeIds, + permission_ids: permissionIds, + route_permission: 'RW', + replace_scope_permission_ids: scopePermissionIds + }); + + if (response.error) { + throw new Error(response.error); + } + + if (response.data?.code && response.data.code !== 200) { + return { + success: false, + message: response.data.message || '角色权限保存失败', + code: response.data.code + }; + } + + return { + success: true, + message: response.data?.message || '角色权限保存成功' + }; + } catch (error) { + console.error('❌ [saveRoleAccess] 保存角色联合权限失败:', error); + return { + success: false, + message: error instanceof Error ? error.message : '角色权限保存失败' + }; + } +} + /** * 更新角色的路由权限 - v3.2更新 * @param roleId 角色ID diff --git a/app/config/api-config.ts b/app/config/api-config.ts index ec0520c..77c58d2 100644 --- a/app/config/api-config.ts +++ b/app/config/api-config.ts @@ -54,9 +54,9 @@ export const portConfigs: Record> = { // documentUrl: 'http://nas.7bm.co:8096/docauditai/', // uploadUrl: 'http://nas.7bm.co:8096/api/v2/documents', // leaudit-platform 联调地址 - baseUrl: 'http://172.16.0.59:8096', - documentUrl: 'http://172.16.0.59:8096/docauditai/', - uploadUrl: 'http://172.16.0.59:8096/api/upload', + baseUrl: 'http://nas.7bm.co:8096', + documentUrl: 'http://nas.7bm.co:8096/docauditai/', + uploadUrl: 'http://nas.7bm.co:8096/api/upload', collaboraUrl: 'http://172.16.0.58:9980', appUrl: 'http://172.16.0.34:51703', @@ -89,9 +89,9 @@ export const portConfigs: Record> = { // collaboraUrl: 'http://172.16.0.81:9980', // appUrl: 'http://172.16.0.34:51704', - baseUrl: 'http://172.16.0.59:8096', // FastAPI后端(包含/dify代理) - documentUrl: 'http://172.16.0.59:8096/docauditai/', - uploadUrl: 'http://172.16.0.59:8096/api/v2/documents', + baseUrl: 'http://nas.7bm.co:8096', // FastAPI后端(包含/dify代理) + documentUrl: 'http://nas.7bm.co:8096/docauditai/', + uploadUrl: 'http://nas.7bm.co:8096/api/v2/documents', collaboraUrl: 'http://172.16.0.58:9980', appUrl: 'http://172.16.0.34:51703', @@ -173,9 +173,9 @@ const configs: Record = { // documentUrl: 'http://172.16.0.59:8096/docauditai/', // uploadUrl: 'http://172.16.0.59:8096/api/v2/documents', // leaudit-platform 联调地址 - baseUrl: 'http://172.16.0.59:8096', - documentUrl: 'http://172.16.0.59:8096/docauditai/', - uploadUrl: 'http://172.16.0.59:8096/api/upload', + baseUrl: 'http://nas.7bm.co:8096', + documentUrl: 'http://nas.7bm.co:8096/docauditai/', + uploadUrl: 'http://nas.7bm.co:8096/api/upload', // baseUrl: 'http://172.16.0.84:8073', // FastAPI后端(包含/dify代理) // documentUrl: 'http://172.16.0.84:8073/docauditai/', // uploadUrl: 'http://172.16.0.84:8073/api/v2/documents', diff --git a/app/config/minimal-scope.ts b/app/config/minimal-scope.ts index c8cd81d..173e0dc 100644 --- a/app/config/minimal-scope.ts +++ b/app/config/minimal-scope.ts @@ -5,7 +5,8 @@ export const MINIMAL_MENU_PREFIXES = [ '/documents', '/settings', '/entry-modules', - '/role-permissions' + '/role-permissions', + '/document-types' ] as const; export const MINIMAL_HOME_TARGETS = [ diff --git a/app/routes/document-types.new.tsx b/app/routes/document-types.new.tsx index 4f832d4..5451dcb 100644 --- a/app/routes/document-types.new.tsx +++ b/app/routes/document-types.new.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from "react"; -import { useNavigate, useSearchParams, useLoaderData } from "@remix-run/react"; +import { useNavigate, useLoaderData } from "@remix-run/react"; import { LoaderFunctionArgs, MetaFunction } from "@remix-run/node"; import { Card } from "~/components/ui/Card"; import { Button } from "~/components/ui/Button"; @@ -64,9 +64,28 @@ export default function DocumentTypeNew() { const [description, setDescription] = useState(""); const [entryModuleId, setEntryModuleId] = useState(null); const [selectedRuleSetIds, setSelectedRuleSetIds] = useState([]); + const [ruleSetKeyword, setRuleSetKeyword] = useState(""); const [saving, setSaving] = useState(false); const [errors, setErrors] = useState>({}); + const selectedModule = loaderData.entryModules.find((m) => m.id === entryModuleId); + const selectedRuleSets = loaderData.ruleSets.filter((rs) => selectedRuleSetIds.includes(rs.id)); + const selectedUnavailableRuleSets = selectedRuleSets.filter((rs) => !rs.hasUsableVersion); + const normalizedRuleSetKeyword = ruleSetKeyword.trim().toLowerCase(); + const filteredRuleSets = loaderData.ruleSets.filter((rs) => { + if (!normalizedRuleSetKeyword) return true; + return [ + rs.ruleName, + rs.ruleType, + rs.status, + String(rs.id), + rs.currentVersionId ? String(rs.currentVersionId) : "", + rs.fallbackVersionId ? String(rs.fallbackVersionId) : "", + ].some((value) => value.toLowerCase().includes(normalizedRuleSetKeyword)); + }); + const completionCount = [!!code.trim(), !!name.trim(), entryModuleId !== null, selectedRuleSetIds.length > 0] + .filter(Boolean).length; + useEffect(() => { if (editType) { setCode(editType.code || ""); @@ -82,6 +101,9 @@ export default function DocumentTypeNew() { if (!code.trim()) errs.code = "编码不能为空"; else if (!/^[a-zA-Z][a-zA-Z0-9_.]*$/.test(code.trim())) errs.code = "编码格式:字母开头,可含字母数字._"; if (!name.trim()) errs.name = "名称不能为空"; + if (selectedUnavailableRuleSets.length > 0) { + errs.ruleSetIds = "已选择的规则集中包含不可用于上传评查的项,请先发布/回滚可用版本"; + } setErrors(errs); return Object.keys(errs).length === 0; }; @@ -131,99 +153,354 @@ export default function DocumentTypeNew() { return (
-

- - {isEdit ? `编辑文档类型 — ${editType?.code}` : "新建文档类型"} -

+
+ {isEdit ? "文档类型编辑" : "文档类型配置"} +

+ + {isEdit ? `编辑文档类型 — ${editType?.code}` : "新建文档类型"} +

+

+ 为上传入口绑定清晰的文档语义、规则集和处理流向,减少后续抽取与评查配置分散的问题。 +

+
+
+
+ + + {isEdit ? "编辑模式" : "创建模式"} + + {entryModuleId ? ( + + + 已绑定入口模块 + + ) : ( + + + 尚未绑定入口模块 + + )} +
+
+
+ 配置完成度 + {completionCount}/4 + 基础信息、入口、规则集逐步补齐 +
+
+ 已关联规则集 + {selectedRuleSetIds.length} 个 + {selectedRuleSetIds.length > 0 ? "已进入评查链路" : "建议按业务场景精确选择"} +
+
+
- -
-
-
- - { setCode(e.target.value); setErrors({ ...errors, code: "" }); }} - disabled={isEdit} - /> - {errors.code && {errors.code}} - {isEdit && 编码创建后不可修改} +
+ + +
+
+
+ Step 01 +

基础标识

+
+

先定义业务识别码与名称,确保后续抽取、归档、评查引用一致。

+
+
+
+ +
+ 编码保持稳定 + 编码创建后建议长期复用,避免和展示文案耦合。 +
+
+
+ +
+ 名称面向使用者 + 名称会直接出现在上传入口与评查流程,尽量让业务人员一眼看懂。 +
+
+
+ +
+
+ + { setCode(e.target.value); setErrors({ ...errors, code: "" }); }} + disabled={isEdit} + /> + {errors.code && {errors.code}} + {!errors.code && ( + {isEdit ? "编码创建后不可修改" : "建议使用业务域.场景名,便于接口与导航复用"} + )} +
+
+ + { setName(e.target.value); setErrors({ ...errors, name: "" }); }} + /> + {errors.name && {errors.name}} + {!errors.name && 名称会直接出现在上传入口、文档详情和评查流程里} +
+
+ +
+ +