diff --git a/app/api/auth/user-routes.ts b/app/api/auth/user-routes.ts index ea38ccf..400fcfc 100644 --- a/app/api/auth/user-routes.ts +++ b/app/api/auth/user-routes.ts @@ -493,7 +493,7 @@ const FALLBACK_MENU_DATA: Record = { */ export async function getUserRoutesByRole(roleKey: string, jwt?: string, includeHidden: boolean = false): Promise<{ success: boolean; data?: MenuItem[]; error?: string; shouldRedirectToHome?: boolean }> { try { - console.log(`🔍 [User Routes] 获取用户路由,角色: ${roleKey}`); + // console.log(`🔍 [User Routes] 获取用户路由,角色: ${roleKey}`); if (!jwt) { console.error('❌ [User Routes] JWT token 未提供'); @@ -554,13 +554,15 @@ export async function getUserRoutesByRole(roleKey: string, jwt?: string, include return { success: false, error: "用户没有分配任何路由权限", shouldRedirectToHome: true }; } - // console.log('📋 [User Routes] 菜单数据:', routes); + // console.log('🔍 [User Routes] 后端返回的原始路由数据:', JSON.stringify(routes, null, 2)); + // console.log('🔍 [User Routes] 检查第一个路由是否有children:', routes[0]?.children); // 将后端路由格式转换为前端 MenuItem 格式 const menuItems = convertBackendRoutesToMenuItems(routes, includeHidden); - // console.log(`✅ [User Routes] 成功获取 ${menuItems.length} 个路由 (includeHidden: ${includeHidden})`); - // console.log('📋 [User Routes] 菜单数据:', menuItems); + // console.log(`✅ [User Routes] 转换后得到 ${menuItems.length} 个菜单项 (includeHidden: ${includeHidden})`); + // console.log('🔍 [User Routes] 转换后的菜单数据:', JSON.stringify(menuItems, null, 2)); + // console.log('🔍 [User Routes] 检查第一个菜单项是否有children:', menuItems[0]?.children); return { success: true, data: menuItems }; @@ -608,12 +610,40 @@ function convertIcon(elementIcon: string | null): string { return ICON_MAPPING[elementIcon] || 'ri-file-line'; } +/** + * 递归提取所有路由(包括嵌套的子路由)为平铺数组 + * @param routes 路由数组(可能包含嵌套的 children) + * @returns 平铺的路由数组 + */ +function flattenRoutes(routes: BackendRouteInfo[]): BackendRouteInfo[] { + const flattened: BackendRouteInfo[] = []; + + function traverse(routeList: BackendRouteInfo[]) { + for (const route of routeList) { + // 添加当前路由(不带 children,因为我们要重新构建) + const { children, ...routeWithoutChildren } = route; + flattened.push(routeWithoutChildren); + + // 递归处理子路由 + if (children && children.length > 0) { + traverse(children); + } + } + } + + traverse(routes); + // console.log('🔄 [flattenRoutes] 平铺后的路由数量:', flattened.length); + return flattened; +} + /** * 将平铺的路由数组构建为树形结构 * @param routes 平铺的路由数组 * @returns 树形结构的路由数组 */ function buildRouteTree(routes: BackendRouteInfo[]): BackendRouteInfo[] { + // console.log('🌲 [buildRouteTree] 开始构建树,接收到的路由数量:', routes.length); + // 创建路由映射 const routeMap = new Map(); const rootRoutes: BackendRouteInfo[] = []; @@ -641,12 +671,13 @@ function buildRouteTree(routes: BackendRouteInfo[]): BackendRouteInfo[] { parentRoute.children.push(currentRoute); } else { // 如果找不到父路由,当作顶级路由处理 - // console.warn(`⚠️ [User Routes] 找不到父路由 (parent_id: ${route.parent_id}) for route: ${route.route_name}`); + // console.warn(`⚠️ [buildRouteTree] 找不到父路由 (parent_id: ${route.parent_id}) for route: ${route.route_name}`); rootRoutes.push(currentRoute); } } }); + // console.log('🌲 [buildRouteTree] 构建完成,根路由数量:', rootRoutes.length); return rootRoutes; } @@ -659,20 +690,43 @@ function buildRouteTree(routes: BackendRouteInfo[]): BackendRouteInfo[] { * @returns MenuItem 数组 */ function convertBackendRoutesToMenuItems(backendRoutes: BackendRouteInfo[], includeHidden: boolean = false): MenuItem[] { + // console.log('🔄 [convertBackendRoutesToMenuItems] 开始转换,接收到的路由数量:', backendRoutes.length); + // console.log('🔄 [convertBackendRoutesToMenuItems] includeHidden:', includeHidden); + // console.log('🔄 [convertBackendRoutesToMenuItems] 接收到的路由数据:', JSON.stringify(backendRoutes, null, 2)); + // 检查是否需要构建树形结构 - // 如果存在 parent_id 不为 null/0 但没有对应的父路由在 children 中,说明是平铺数组 + // 如果存在 parent_id 不为 null/0 但没有对应的父路由在 children 中,说明需要重建树 const needsBuildTree = backendRoutes.some(route => route.parent_id !== null && route.parent_id !== 0 && !backendRoutes.some(r => r.children?.some(c => c.id === route.id)) ); - // 如果是平铺数组,先构建树形结构 - const treeRoutes = needsBuildTree ? buildRouteTree(backendRoutes) : backendRoutes; + // console.log('🔄 [convertBackendRoutesToMenuItems] needsBuildTree:', needsBuildTree); - return treeRoutes - .filter(route => includeHidden || !route.is_hidden) // 根据 includeHidden 决定是否过滤隐藏路由 + let treeRoutes: BackendRouteInfo[]; + if (needsBuildTree) { + // 🔑 关键修复:先平铺所有路由(包括嵌套的 children),再重新构建树 + // console.log('🔄 [convertBackendRoutesToMenuItems] 检测到混合格式,先平铺再重建树...'); + const flattenedRoutes = flattenRoutes(backendRoutes); + treeRoutes = buildRouteTree(flattenedRoutes); + } else { + // 后端已经返回正确的树形结构,直接使用 + // console.log('🔄 [convertBackendRoutesToMenuItems] 后端返回的是完整树形结构,直接使用'); + treeRoutes = backendRoutes; + } + + // console.log('🔄 [convertBackendRoutesToMenuItems] 构建树后的路由数据:', JSON.stringify(treeRoutes, null, 2)); + + const result = treeRoutes + .filter(route => { + const shouldInclude = includeHidden || !route.is_hidden; + // console.log(`🔄 [convertBackendRoutesToMenuItems] 过滤路由 ${route.route_name}: is_hidden=${route.is_hidden}, includeHidden=${includeHidden}, 结果=${shouldInclude}`); + return shouldInclude; + }) .map(route => { + // console.log(`🔄 [convertBackendRoutesToMenuItems] 处理路由 ${route.route_name}, children数量: ${route.children?.length || 0}`); + const menuItem: MenuItem = { id: route.route_name || `route-${route.id}`, title: route.route_title, @@ -684,12 +738,19 @@ function convertBackendRoutesToMenuItems(backendRoutes: BackendRouteInfo[], incl // 递归处理子路由,传递 includeHidden 参数 if (route.children && route.children.length > 0) { + // console.log(`🔄 [convertBackendRoutesToMenuItems] ${route.route_name} 有 ${route.children.length} 个子路由,递归处理...`); menuItem.children = convertBackendRoutesToMenuItems(route.children, includeHidden); + // console.log(`🔄 [convertBackendRoutesToMenuItems] ${route.route_name} 转换后的children:`, menuItem.children); } return menuItem; }) .sort((a, b) => a.order - b.order); // 按 sort_order 排序 + + // console.log('🔄 [convertBackendRoutesToMenuItems] 转换完成,返回的菜单项数量:', result.length); + // console.log('🔄 [convertBackendRoutesToMenuItems] 返回的菜单数据:', JSON.stringify(result, null, 2)); + + return result; } diff --git a/app/api/axios-client.ts b/app/api/axios-client.ts index d993532..0aade96 100644 --- a/app/api/axios-client.ts +++ b/app/api/axios-client.ts @@ -106,6 +106,13 @@ axiosInstance.interceptors.response.use( if (isAxiosError(error) && error.response?.status === 401) { // Token 过期或无效 console.warn('⚠️ Token 已过期或无效,请重新登录'); + console.warn('⚠️ 401 错误详情:', { + url: error.config?.url, + status: error.response?.status, + statusText: error.response?.statusText, + data: error.response?.data, + headers: error.response?.headers + }); if (typeof window !== 'undefined') { // 🌐 客户端环境:清除 localStorage 并跳转 @@ -328,13 +335,20 @@ export async function apiRequest( // 确保使用默认超时时间 timeout: options.timeout || DEFAULT_TIMEOUT }; + + // 🔍 调试:打印 Authorization 头 + if (headers['Authorization']) { + // console.log('🔑 [apiRequest] 请求包含 Authorization 头:', headers['Authorization'].substring(0, 20) + '...'); + } else { + console.warn('⚠️ [apiRequest] 请求缺少 Authorization 头!headers:', Object.keys(headers)); + } // console.log(`📦 axios-client.ts->请求配置: \n${JSON.stringify(config)}`); - + // 删除body属性,避免axios警告 if ('body' in config) { delete (config as ExtendedAxiosRequestConfig).body; } - + // 使用带重试功能的请求方法 const response: AxiosResponse = await axiosRetry(config); diff --git a/app/api/evaluation_points/rules-files.ts b/app/api/evaluation_points/rules-files.ts index 1f4a3b0..05d719b 100644 --- a/app/api/evaluation_points/rules-files.ts +++ b/app/api/evaluation_points/rules-files.ts @@ -1,5 +1,4 @@ -import { postgrestPut, postgrestPost } from '../postgrest-client'; -import { formatDate } from '../../utils'; +import { postgrestPut } from '../postgrest-client'; // 文档数据库表接口 export interface Document { @@ -62,32 +61,6 @@ export interface ReviewFileUI { manualCount: number; } -// 数据库函数返回的评查文件结构 -interface ReviewFileFromSQL { - id: number; - status: string; - path: string; - file_name: string; - file_code: string; - file_type_name: string; - file_type_id: number; - file_size: number; - upload_time: string; - created_at: string; - evaluations_status: number; - audit_status: number | null; - created_by_user_id: number | null; - issue_count: number; - total_score: number; - pass_count: number; - warning_count: number; - fail_count: number; - manual_count: number; - issues: Array<{ - severity: 'info' | 'warning' | 'error' | 'critical'; - message: string; - }> | null; -} // 文件列表搜索参数 export interface DocumentSearchParams { @@ -133,183 +106,7 @@ export function mapUIToReviewStatus(status: string): number { } } -/** - * 获取文件扩展名 - * @param fileName 文件名 - * @returns 文件扩展名 - */ -export function getFileExtension(fileName: string): string { - return fileName.split('.').pop()?.toLowerCase() || ''; -} -/** - * 获取评查文件列表 - * @param searchParams 搜索参数 - * @param documentIds 文档ID数组(可选) - * @param userId 用户ID - * @returns 评查文件列表和总数 - */ -export async function getReviewFiles(searchParams: DocumentSearchParams = {}, documentIds: number[] | null = null, userId?: string): Promise<{ - data?: { files: ReviewFileUI[], total: number }; - error?: string; - status?: number; -}> { - try { - // 确保userId必须存在,如果不存在则抛出错误 - if (!userId) { - return { error: '用户身份验证失败,无法获取评查文件列表', status: 401 }; - } - - const { - page = 1, - pageSize = 10, - keyword, - fileType, // sessionStorage.getItem('reviewType') - reviewStatus, - dateFrom, - dateTo, - sortOrder = 'upload_time_desc' - } = searchParams; - - let p_typeid: number[] | null = null; - if (fileType) { - if (fileType === 'record') { - p_typeid = [2, 3, 155]; - } else if (fileType === 'contract') { - p_typeid = [1]; - } else { - const typeId = parseInt(fileType, 10); - if (!isNaN(typeId)) { - p_typeid = [typeId]; - } - } - } - - const rpcParams = { - p_keyword: keyword || null, - p_typeid: p_typeid, - p_evaluations_status: reviewStatus ? mapUIToReviewStatus(reviewStatus) : null, - p_date_from: dateFrom || null, - p_date_to: dateTo || null, - p_document_ids: documentIds || null, - p_user_id: parseInt(userId, 10), // 强制要求传递用户ID - }; - - const listParams = { - ...rpcParams, - p_page: page, - p_page_size: pageSize, - p_sort_order: sortOrder - }; - - // 并行执行获取数据和获取总数的请求 - const [filesResponse, countResponse] = await Promise.all([ - postgrestPost('rpc/get_review_files_with_details', listParams), - postgrestPost('rpc/count_review_files', rpcParams) - ]); - - // 处理获取文档列表的错误 - if (filesResponse.error || !filesResponse.data) { - return { error: filesResponse.error || '获取文档数据失败', status: filesResponse.status || 500 }; - } - - // 处理获取总数的错误 - if (countResponse.error || typeof countResponse.data !== 'number') { - console.error('获取文档总数失败:', countResponse.error); - } - - const totalCount = typeof countResponse.data === 'number' ? countResponse.data : 0; - - // 将SQL返回的数据转换为UI格式 - const reviewFiles: ReviewFileUI[] = filesResponse.data.map((file: ReviewFileFromSQL) => ({ - id: file.id.toString(), - status: file.status, - path: file.path, - fileName: file.file_name, - fileCode: file.file_code, - fileType: file.file_type_name, - fileTypeId: file.file_type_id, - fileSize: file.file_size, - uploadTime: formatDate(file.created_at), - reviewStatus: mapReviewStatusToUI(file.evaluations_status), - reviewStatusCode: file.evaluations_status, - issueCount: file.issue_count, - score: file.total_score, - auditStatus: file.audit_status, - issues: file.issues || [], - createdBy: file.created_by_user_id?.toString() || '系统', - passCount: file.pass_count, - warningCount: file.warning_count, - failCount: file.fail_count, - manualCount: file.manual_count, - })); - - return { - data: { - files: reviewFiles, - total: totalCount - } - }; - } catch (error) { - console.error('获取评查文件列表失败:', error); - return { - error: error instanceof Error ? error.message : '获取评查文件列表失败', - status: 500 - }; - } -} - -/** - * 更新文件的评查状态 - * @param id 文件ID - * @param status 评查状态 - * @returns 更新后的文件信息 - */ -// export async function updateReviewStatus(id: string, status: string): Promise<{ -// data?: ReviewFileUI; -// error?: string; -// status?: number; -// }> { -// try { -// if (!id) { -// return { error: '文件ID不能为空', status: 400 }; -// } - -// const statusValue = mapUIToReviewStatus(status); - -// const response = await postgrestPut>( -// 'documents', -// { evaluations_status: statusValue }, -// { id: parseInt(id) } -// ); - -// if (response.error) { -// return { error: response.error, status: response.status }; -// } - -// const extractedData = extractApiData(response.data); - -// if (!extractedData) { -// return { error: '更新评查状态失败', status: 500 }; -// } - -// // 获取文档类型,用于查找文档类型名称 -// const documentTypesResponse = await getDocumentTypes({pageSize: 500}); -// const documentTypes = documentTypesResponse.data?.types || []; - -// // 查找文档类型名称 -// const docType = documentTypes.find((type: DocumentTypeUI) => type.id === extractedData.type_id); -// const typeName = docType ? docType.name : '未知类型'; - -// return { data: convertToReviewFileUI(extractedData, typeName) }; -// } catch (error) { -// console.error('更新评查状态失败:', error); -// return { -// error: error instanceof Error ? error.message : '更新评查状态失败', -// status: 500 -// } -// } -// } /** * 更新文件的审核状态 diff --git a/app/api/evaluation_points/rules.ts b/app/api/evaluation_points/rules.ts index 876f0d9..2a3aaf2 100644 --- a/app/api/evaluation_points/rules.ts +++ b/app/api/evaluation_points/rules.ts @@ -32,9 +32,10 @@ export interface RulesQueryParams { groupId?: string; // 规则组ID isActive?: boolean; keyword?: string; + area?: string; // 地区过滤 orderBy?: string; orderDirection?: 'asc' | 'desc'; - reviewType?: string; // 添加 reviewType 参数,值为 contract 或 record + userRole?: string; // 用户角色 token?: string; // JWT token } @@ -53,6 +54,7 @@ export interface ApiRule { id: number; code: string; name: string; + area?: string; // 地区 evaluation_point_groups_id: number | null; risk: string; description: string; @@ -61,6 +63,15 @@ export interface ApiRule { first_name: string; second_name: string; }; + // 🆕 PostgREST 双连接查询返回的字段 + child_group?: { + id: number; + name: string; + } | null; + parent_group?: { + id: number; + name: string; + } | null; references_laws: Record; extraction_config: { type: string; @@ -118,27 +129,42 @@ function mapApiRuleToFrontendModel(apiRule: ApiRule): Rule { '低': 'low' }; - //评查点类型映射 + // 🆕 优先使用 PostgREST 双连接查询返回的数据 + let ruleType = ''; + let groupName = ''; + let groupId = ''; - // 规则类型映射(这里根据实际业务逻辑设置一个默认值) -// const ruleType = 'essential'; // 实际应用中可能需要从其他字段推断 -// console.log("apiRule.evaluation_point_groups",apiRule); - - // 如果evaluation_point_groups_id为null或空,则不显示ruleType和groupName - const isGroupIdEmpty = !apiRule.evaluation_point_groups_id; - - // 规则类型只在有分组ID时才显示 - const ruleType = isGroupIdEmpty ? '' : (apiRule.evaluation_point_groups?.first_name || ''); + if (apiRule.child_group || apiRule.parent_group) { + // 有 PostgREST 双连接查询数据(外键约束方式) + groupId = apiRule.child_group?.id.toString() || ''; + groupName = apiRule.child_group?.name || ''; + ruleType = apiRule.parent_group?.name || ''; + } else if (apiRule.evaluation_point_groups) { + // 兼容旧的查询方式(RPC 函数或手动拼接) + const isGroupIdEmpty = !apiRule.evaluation_point_groups_id; + ruleType = isGroupIdEmpty ? '' : (apiRule.evaluation_point_groups.first_name || ''); + groupName = isGroupIdEmpty ? '' : (apiRule.evaluation_point_groups.second_name || `${apiRule.evaluation_point_groups_id}`); + groupId = isGroupIdEmpty ? '' : apiRule.evaluation_point_groups_id?.toString() || ''; + } else { + // 没有任何分组信息 + const isGroupIdEmpty = !apiRule.evaluation_point_groups_id; + groupId = isGroupIdEmpty ? '' : apiRule.evaluation_point_groups_id?.toString() || ''; + } + + // 🔑 清洗评查点编码:移除最后一个 '--' 及其后面的字符 + // 例如:'code-mis--mz' --> 'code-mis', 'code-mbs--alsi--gz' --> 'code-mbs--alsi' + let cleanedCode = apiRule.code || ''; + const lastDoubleHyphenIndex = cleanedCode.lastIndexOf('--'); + if (lastDoubleHyphenIndex !== -1) { + cleanedCode = cleanedCode.substring(0, lastDoubleHyphenIndex); + } - // 规则组名称只在有分组ID时才显示 - const groupName = isGroupIdEmpty ? '' : (apiRule.evaluation_point_groups?.second_name || `${apiRule.evaluation_point_groups_id}`); - return { - id: apiRule.id ? apiRule.id.toString() : '', // 添加空值检查 - code: apiRule.code || '', + id: apiRule.id ? apiRule.id.toString() : '', + code: cleanedCode, name: apiRule.name || '', ruleType: ruleType, - groupId: isGroupIdEmpty ? '' : apiRule.evaluation_point_groups_id?.toString() || '', + groupId: groupId, groupName: groupName, priority: priorityMap[apiRule.risk] || 'medium', description: apiRule.description || '', @@ -156,40 +182,63 @@ function mapApiRuleToFrontendModel(apiRule: ApiRule): Rule { export async function getRulesList(params: RulesQueryParams): Promise<{data: RulesListResponse; error?: never} | {data?: never; error: string; status?: number}> { try { // 解构并设置默认值 - const { - page = 1, - pageSize = 10, - ruleType, - groupId, - isActive, + const { + page = 1, + pageSize = 10, + ruleType, + groupId, + isActive, keyword, + area, orderBy = 'created_at', orderDirection = 'desc', - reviewType, + userRole, token } = params; + + // 🔑 如果没有传递 userRole,尝试从 localStorage 中获取 + let user_role = '' + if (!userRole && typeof window !== 'undefined' && window.localStorage) { + try { + const userInfoStr = localStorage.getItem('user_info'); + if (userInfoStr) { + const userInfo = JSON.parse(userInfoStr); + user_role = userInfo.user_role || userInfo.userRole; + // console.log('📋 [getRulesList] 从 localStorage 获取用户角色:', userRole); + } + } catch (error) { + console.error('❌ [getRulesList] 解析 localStorage 用户信息失败:', error); + } + } // 构建PostgrestParams参数 const postgrestParams: PostgrestParams = { - // 修改select语句,不使用嵌入查询语法 + // 🆕 使用 PostgREST 双连接查询(直接连接父子分组) + // child_group: 通过 evaluation_point_groups_id 获取子分组(所属规则组) + // parent_group: 通过 evaluation_point_groups_pid 直接获取父分组(评查点类型) + // ⚠️ 重要:使用 .replace() 移除换行符,PostgREST 的 select 参数不支持多行字符串 select: ` id, code, name, + area, evaluation_point_groups_id, + evaluation_point_groups_pid, risk, description, is_enabled, created_at, - updated_at - `, + updated_at, + child_group:evaluation_point_groups!fk_evaluation_points_group(id,name), + parent_group:evaluation_point_groups!fk_evaluation_points_parent_group(id,name) + `.replace(/\s+/g, ' ').trim(), // 设置分页 limit: pageSize, offset: (page - 1) * pageSize, - + // 设置排序 order: `${orderBy}.${orderDirection}`, - + // 构建过滤条件 filter: {}, @@ -204,62 +253,30 @@ export async function getRulesList(params: RulesQueryParams): Promise<{data: Rul if (groupId) { postgrestParams.filter!['evaluation_point_groups_id'] = `eq.${groupId}`; } - + if (isActive !== undefined) { postgrestParams.filter!['is_enabled'] = `eq.${isActive}`; } - - // 根据reviewType添加过滤条件 - if (reviewType) { - try { - // 先获取所有评查点组数据,用于找到对应的pid - const groupsAllResponse = await postgrestGet<{code: number; msg: string; data: Array<{id: number; pid: number; m_type: number}>}>('evaluation_point_groups', { - select: 'id,pid,m_type', - token - }); - - let groups: Array<{id: number; pid: number; m_type: number}> = []; - - if (!groupsAllResponse.error) { - if (groupsAllResponse.data && 'code' in groupsAllResponse.data && groupsAllResponse.data.data) { - groups = groupsAllResponse.data.data; - } else if (Array.isArray(groupsAllResponse.data)) { - groups = groupsAllResponse.data; - } - } - - // 根据reviewType过滤pid - let pidList: number[] = []; - - if (reviewType === 'contract') { - // 合同类型,找到所有pid=3的评查点组 2025/10/29: 修改为通过m_type = 0 去查找评查点组 - // const contractGroups = groups.filter(g => g.pid === 3).map(g => g.id); - const contractGroups = groups.filter(g => g.m_type === 0).map(g => g.id); - pidList = contractGroups; - } else if (reviewType === 'record') { - // 卷宗类型,找到所有pid=1或pid=2的评查点组 2025/10/29: 修改为通过m_type = 1 去查找评查点组 - // const recordGroups = groups.filter(g => g.pid === 1 || g.pid === 2).map(g => g.id); - const recordGroups = groups.filter(g => g.m_type === 1).map(g => g.id); - pidList = recordGroups; - } - - // 如果有过滤的组id,则添加到查询条件中 - if (pidList.length > 0) { - postgrestParams.filter!['evaluation_point_groups_id'] = `in.(${pidList.join(',')})`; - } - } catch (error) { - console.error('获取评查点组数据出错:', error); - } + + // 🔑 添加地区过滤 + if (user_role == 'provincial_admin') { + postgrestParams.filter!['area'] = `eq.省级`; + }else{ + postgrestParams.filter!['area'] = `eq.${area}`; } - + // 如果指定了评查点类型ID,需要先查询该类型下的所有规则组ID if (ruleType) { try { - // 先获取该类型下的所有规则组 + // 🔑 检查是否为多个类型(逗号分隔) + const isMultipleTypes = ruleType.includes(','); + + // 先获取该类型(或多个类型)下的所有规则组 const groupsParams: PostgrestParams = { select: 'id', filter: { - 'pid': `eq.${ruleType}` + // 如果是多个类型,使用 in.(type1,type2),否则使用 eq.type + 'pid': isMultipleTypes ? `in.(${ruleType})` : `eq.${ruleType}` }, token }; @@ -311,7 +328,7 @@ export async function getRulesList(params: RulesQueryParams): Promise<{data: Rul if (response.error) { return { error: response.error, status: response.status }; } - + // 处理不同的API响应格式 let apiRules: ApiRule[] = []; let totalCount = 0; @@ -352,87 +369,18 @@ export async function getRulesList(params: RulesQueryParams): Promise<{data: Rul totalCount = apiRules.length; } - // 打印第一个数据项来查看结构(仅用于调试) - // if (apiRules.length > 0) { - // console.log('评查点数据示例:', JSON.stringify(apiRules[0], null, 2)); - // } - + // 🆕 使用嵌套查询后,不再需要手动查询分组信息 + // PostgREST 的嵌套 select 已经自动关联了分组数据 + console.log('📋 [getRulesList] 使用 PostgREST 嵌套查询获取评查点数据'); - // 收集所有规则分组ID(实际上是二级分组) (多进行一步查找表的操作) - const groupIds = [...new Set(apiRules.map(rule => rule.evaluation_point_groups_id))]; - // 如果有分组ID,查询分组信息 - 更新类型定义 - const groupsMap: Record = {}; - if (groupIds.length > 0) { - try { - // 过滤null和空值 - const validGroupIds = groupIds.filter(id => id != null && id.toString().trim() !== ''); - - if (validGroupIds.length > 0) { - // 使用Promise.all并行查询所有分组信息 - 使用正确的函数名 - const groupPromises = validGroupIds.map(id => - postgrestGet<{code: number; msg: string; data: {id: number; pid: number; name: string; first_name: string; second_name: string}[]}>( - `rpc/get_evaluation_point_group_with_pid?input_id=${id}`, - { token } - ) - ); - - const groupResponses = await Promise.all(groupPromises); - // console.log("groupResponsesdddddddddddddddddd",groupResponses); - // 处理响应,填充groupsMap - groupResponses.forEach((response) => { - if (!response.error && response.data) { - // 处理不同API响应格式 - if (Array.isArray(response.data.data) && response.data.data.length > 0) { - const groupid = response.data.data[1]?.id || ""; - groupsMap[groupid] = { - first_name: response.data.data[0]?.name || "", - second_name: response.data.data[1]?.name || "" - }; - }else if(Array.isArray(response.data) && response.data.length > 0){ - const groupid = response.data[1]?.id || ""; - groupsMap[groupid] = { - first_name: response.data[0]?.name || "", - second_name: response.data[1]?.name || "" - }; - } - } - }); - } - } catch (error) { - console.error('获取分组数据失败:', error); - // 失败不阻止主流程,使用默认分组名 - } - } - + // 将API返回的数据映射到前端模型 + const mappedRules = apiRules.map(apiRule => { + const rule = mapApiRuleToFrontendModel(apiRule); - // 应用本地过滤 - 只在API不支持的情况下使用 - const filteredRules = [...apiRules]; - // 不再需要本地过滤,因为已经在API层面添加了评查点类型过滤 - // 如果进行了本地过滤,则需要调整总记录数 - // if (ruleType) { - // totalCount = filteredRules.length; - // } - - - // 将API返回的数据映射到前端模型,并附加分组名称 - // console.log("groupsMap",groupsMap); - const mappedRules = filteredRules.map(apiRule => { - // 创建修改版的apiRule,添加从分组映射获取的名称 - const enhancedApiRule = { - ...apiRule, - // 从映射中获取分组名称,如果不存在则使用默认值 - evaluation_point_groups: { - first_name: groupsMap[apiRule.evaluation_point_groups_id?.toString() || '']?.first_name || `${apiRule.evaluation_point_groups_id}`, - second_name: groupsMap[apiRule.evaluation_point_groups_id?.toString() || '']?.second_name || `${apiRule.evaluation_point_groups_id}` - } - }; - - const rule = mapApiRuleToFrontendModel(enhancedApiRule); - // 格式化日期字段 rule.createdAt = formatDate(rule.createdAt); rule.updatedAt = formatDate(rule.updatedAt); - + return rule; }); @@ -848,72 +796,134 @@ export interface RuleGroup { /** * 获取评查点类型列表 - * @param reviewType 评查类型,contract表示合同,record表示卷宗 + * @param documentTypeIds 文档类型 ID 列表 * @param token JWT token (可选) * @returns 评查点类型列表 */ -export async function getRuleTypes(reviewType?: string, token?: string): Promise<{data: RuleType[]; error?: never} | {data?: never; error: string; status?: number}> { +export async function getRuleTypes(documentTypeIds?: number[], token?: string): Promise<{data: RuleType[]; error?: never} | {data?: never; error: string; status?: number}> { try { - // 构建PostgrestParams参数 - const postgrestParams: PostgrestParams = { + // 如果没有传入 documentTypeIds,返回空数组 + if (!documentTypeIds || documentTypeIds.length === 0) { + console.warn('getRuleTypes: 未提供 documentTypeIds'); + return { data: [] }; + } + + // 1️⃣ 根据 documentTypeIds 查询 document_types 表 + const typeIdsStr = documentTypeIds.join(','); + const documentTypesParams: PostgrestParams = { + select: 'id, name, evaluation_point_groups_ids', + filter: { + 'id': `in.(${typeIdsStr})` + }, + token + }; + + const documentTypesResponse = await postgrestGet<{ + code: number; + msg: string; + data: Array<{ + id: number; + name: string; + evaluation_point_groups_ids: number[]; + }> + }>('document_types', documentTypesParams); + + if (documentTypesResponse.error) { + return { error: documentTypesResponse.error, status: documentTypesResponse.status }; + } + + // 提取 document_types 数据 + let documentTypesData: Array<{ + id: number; + name: string; + evaluation_point_groups_ids: number[]; + }> = []; + + if (documentTypesResponse.data && 'code' in documentTypesResponse.data && documentTypesResponse.data.data) { + if (Array.isArray(documentTypesResponse.data.data)) { + documentTypesData = documentTypesResponse.data.data; + } + } else if (Array.isArray(documentTypesResponse.data)) { + documentTypesData = documentTypesResponse.data as Array<{ + id: number; + name: string; + evaluation_point_groups_ids: number[]; + }>; + } + + if (documentTypesData.length === 0) { + console.warn('getRuleTypes: 未找到对应的文档类型数据'); + return { data: [] }; + } + + // 2️⃣ 提取并组合所有的 evaluation_point_groups_ids + const allGroupIds = new Set(); + documentTypesData.forEach(docType => { + if (Array.isArray(docType.evaluation_point_groups_ids)) { + docType.evaluation_point_groups_ids.forEach(id => allGroupIds.add(id)); + } + }); + + if (allGroupIds.size === 0) { + console.warn('getRuleTypes: 未找到评查点组 ID'); + return { data: [] }; + } + + // console.log('📋 [getRuleTypes] 提取的评查点组 IDs:', Array.from(allGroupIds)); + + // 3️⃣ 根据组合后的 ID 查询 evaluation_point_groups 表 + const groupIdsStr = Array.from(allGroupIds).join(','); + const groupsParams: PostgrestParams = { select: ` id, pid, code, name, description, - is_enabled, - m_type + is_enabled `, - // 查询父ID为0的类型(顶级类型) filter: { - 'pid': 'eq.0' + 'id': `in.(${groupIdsStr})`, + 'pid': 'eq.0' }, token }; - - // 根据 reviewType 添加过滤条件 - if (reviewType === 'contract') { - // 如果是合同类型,只加载id=3的评查点类型 2025/10/29: 修改为通过m_type = 0 去查找评查点组 - postgrestParams.filter!['m_type'] = 'eq.0'; - } else if (reviewType === 'record') { - // 如果是卷宗类型,只加载id=1和id=2的评查点类型 2025/10/29: 修改为通过m_type = 1 去查找评查点组 - postgrestParams.filter!['m_type'] = 'eq.1'; - } - - // 发送请求获取评查点类型列表 - const response = await postgrestGet<{code: number; msg: string; data: Array<{ - id: number; - pid: number; - code: string; - name: string; - description: string; - is_enabled: boolean; - }>; - }>('evaluation_point_groups', postgrestParams); - + + const response = await postgrestGet<{ + code: number; + msg: string; + data: Array<{ + id: number; + pid: number; + code: string; + name: string; + description: string; + is_enabled: boolean; + }> + }>('evaluation_point_groups', groupsParams); + // 检查是否有错误响应 if (response.error) { return { error: response.error, status: response.status }; } - - if(response.data && 'code' in response.data && response.data.data){ - if(Array.isArray(response.data.data) && response.data.data.length > 0){ - // 将API返回的数据映射到前端模型 - const ruleTypes = response.data.data.map(item => ({ - id: item.id.toString(), - pid: item.pid.toString(), - code: item.code, - name: item.name, - description: item.description, - isEnabled: item.is_enabled - })); - return { data: ruleTypes }; - }else{ - return { error: '9000接口返回数据格式不正确', status: 500 }; + + // 处理响应数据 + if (response.data && 'code' in response.data && response.data.data) { + if (Array.isArray(response.data.data)) { + const ruleTypes = response.data.data.map(item => ({ + id: item.id.toString(), + pid: item.pid.toString(), + code: item.code, + name: item.name, + description: item.description, + isEnabled: item.is_enabled + })); + // console.log('📋 [getRuleTypes] 返回评查点类型:', ruleTypes); + return { data: ruleTypes }; + } else { + return { data: [] }; } - }else if(Array.isArray(response.data) && response.data.length > 0){ - // console.log("评查点类型列表",response.data); + } else if (Array.isArray(response.data)) { const ruleTypes = response.data.map(item => ({ id: item.id.toString(), pid: item.pid.toString(), @@ -922,13 +932,14 @@ export async function getRuleTypes(reviewType?: string, token?: string): Promise description: item.description, isEnabled: item.is_enabled })); + // console.log('📋 [getRuleTypes] 返回评查点类型:', ruleTypes); return { data: ruleTypes }; - }else{ - return { error: '3000接口返回数据格式不正确', status: 500 }; + } else { + return { data: [] }; } } catch (error) { console.error('获取评查点类型出错:', error); - return { + return { error: error instanceof Error ? error.message : '获取评查点类型失败', status: 500 }; diff --git a/app/api/files/files-upload.ts b/app/api/files/files-upload.ts index b91e68d..3dd658c 100644 --- a/app/api/files/files-upload.ts +++ b/app/api/files/files-upload.ts @@ -12,9 +12,9 @@ function extractApiData(responseData: unknown): T | null { if (!responseData) return null; // 格式1: { code: number, msg: string, data: T } - if (typeof responseData === 'object' && responseData !== null && - 'code' in responseData && - 'data' in responseData && + if (typeof responseData === 'object' && responseData !== null && + 'code' in responseData && + 'data' in responseData && (responseData as { data: unknown }).data) { return (responseData as { data: T }).data; } @@ -23,6 +23,34 @@ function extractApiData(responseData: unknown): T | null { return responseData as T; } +/** + * 从 sessionStorage 获取文档类型 ID 列表(客户端专用) + * @returns 文档类型 ID 数组,如果不存在则返回 null + */ +function getDocumentTypeIdsFromSession(): number[] | null { + if (typeof window === 'undefined') { + return null; // 服务端环境返回 null + } + + try { + const typeIdsStr = sessionStorage.getItem('documentTypeIds'); + if (!typeIdsStr) { + return null; + } + + const typeIds = JSON.parse(typeIdsStr); + if (Array.isArray(typeIds) && typeIds.every(id => typeof id === 'number')) { + return typeIds; + } + + console.warn('⚠️ [getDocumentTypeIds] documentTypeIds 格式不正确:', typeIds); + return null; + } catch (error) { + console.error('❌ [getDocumentTypeIds] 解析 documentTypeIds 失败:', error); + return null; + } +} + // 文档状态枚举 export enum DocumentStatus { waiting = 'waiting', @@ -226,6 +254,7 @@ export async function uploadContractTemplate( * @param mergeMode 合并模式:'overwrite'(覆盖原文档)或 'new'(新建文档记录) * @param isReprocess 是否触发重新处理 * @param remark 备注 + * @param token JWT token(可选) * @returns 上传结果 */ export async function appendContractAttachments( @@ -233,7 +262,8 @@ export async function appendContractAttachments( files: File[], mergeMode: 'overwrite' | 'new' = 'overwrite', isReprocess: boolean = true, - remark?: string + remark?: string, + token?: string ): Promise<{data: FileUploadResponse; error?: never} | {data?: never; error: string; status?: number}> { try { console.log('【合同附件追加】开始追加附件:', { documentId, fileCount: files.length, mergeMode }); @@ -256,20 +286,18 @@ export async function appendContractAttachments( // 构建请求URL const uploadUrl = `${UPLOAD_URL}/contracts/${documentId}/append_attachments`; console.log('【合同附件追加】准备发送请求到服务器:', uploadUrl); - + // 设置请求头 const headers: HeadersInit = { 'Accept': 'application/json' }; - // 从 localStorage 获取 token - if (typeof window !== 'undefined') { - const token = localStorage.getItem('access_token'); - if (token) { - headers['Authorization'] = `Bearer ${token}`; - } + // 使用传入的 token 或从 localStorage 获取 + const authToken = token || (typeof window !== 'undefined' ? localStorage.getItem('access_token') : null); + if (authToken) { + headers['Authorization'] = `Bearer ${authToken}`; } - + // 发送请求 const response = await fetch(uploadUrl, { method: 'POST', @@ -440,10 +468,15 @@ export async function uploadDocumentToServer( /** * 获取当天的文档列表 * @param userInfo 用户信息(必需) - * @param reviewType 审核类型(可选) + * @param token JWT token + * @param documentTypeIds 文档类型 ID 列表(可选) * @returns 文档列表 */ -export async function getTodayDocuments(userInfo?: { user_id?: number; [key: string]: unknown }, reviewType?: string, token?: string): Promise<{data: Document[]; error?: never} | {data?: never; error: string; status?: number}> { +export async function getTodayDocuments( + userInfo?: { user_id?: number; [key: string]: unknown }, + token?: string, + documentTypeIds?: number[] +): Promise<{data: Document[]; error?: never} | {data?: never; error: string; status?: number}> { try { // 检查用户信息是否存在 if (!userInfo?.user_id) { @@ -455,129 +488,11 @@ export async function getTodayDocuments(userInfo?: { user_id?: number; [key: str const today = dayjs().startOf('day').format('YYYY-MM-DD'); // console.log('查询当天文档,日期范围:', today); - - // 如果是合同类型,需要合并查询documents表和contract_structure_comparison表 - if (reviewType === 'contract') { - try { - // 查询documents表中的合同数据 - const documentsParams: PostgrestParams = { - select: ` - id, - name, - type_id, - file_size, - status, - created_at, - document_number, - path, - storage_type, - is_test_document, - evaluation_level, - ocr_result, - extracted_results, - sumary, - remark, - audit_status - `, - order: 'created_at.desc', - filter: { - 'created_at': `gte.${today}`, - 'type_id': 'eq.1', - 'user_id': `eq.${userInfo.user_id}` - } - }; + // 🔑 优先使用传入的 documentTypeIds,否则从 sessionStorage 读取(客户端) + const typeIds = documentTypeIds || getDocumentTypeIdsFromSession(); + // console.log('📋 [getTodayDocuments] 文档类型 IDs:', typeIds, '来源:', documentTypeIds ? 'URL参数' : 'sessionStorage'); - // 查询contract_structure_comparison表中的数据 - // const comparisonParams: PostgrestParams = { - // select: ` - // id, - // template_contract_name, - // file_size, - // status, - // created_at, - // document_id, - // template_contract_path, - // ocr_results, - // comparison_results - // `, - // order: 'created_at.desc', - // filter: { - // 'created_at': `gte.${today}` - // } - // }; - - // 并行查询两个表 - // const [documentsResponse, comparisonResponse] = await Promise.all([ - // postgrestGet('documents', documentsParams), - // postgrestGet('contract_structure_comparison', comparisonParams) - // ]); - - const documentsResponse = await postgrestGet('documents', { ...documentsParams, token }); - - // console.log('documents表响应:', documentsResponse); - // console.log('contract_structure_comparison表响应:', comparisonResponse); - - // if (documentsResponse.error && comparisonResponse.error) { - // console.error('两个表查询都失败:', documentsResponse.error, comparisonResponse.error); - // return { error: documentsResponse.error || comparisonResponse.error, status: documentsResponse.status || comparisonResponse.status }; - // } - if (documentsResponse.error) { - console.error('documents表查询失败:', documentsResponse.error); - return { error: documentsResponse.error, status: documentsResponse.status }; - } - - // 提取documents表数据 - let documentsData: Document[] = []; - if (!documentsResponse.error && documentsResponse.data) { - const extractedDocuments = extractApiData(documentsResponse.data); - if (extractedDocuments) { - documentsData = extractedDocuments; - } - } - - // 提取contract_structure_comparison表数据并转换为Document格式 - // let comparisonData: Document[] = []; - // if (!comparisonResponse.error && comparisonResponse.data) { - // const extractedComparison = extractApiData(comparisonResponse.data); - // if (extractedComparison) { - // // 将ContractStructureComparison转换为Document格式 - // console.log('extractedComparison:', extractedComparison); - // comparisonData = extractedComparison.map(item => ({ - // 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, - // document_id: item.document_id, - // template_contract_path: item.template_contract_path, - // ocr_results: item.ocr_results, - // comparison_results: item.comparison_results - // })); - // } - // } - - // 合并两个数据源 - // const allData = [...documentsData, ...comparisonData]; - const allData = [...documentsData]; - - // 按created_at降序排序 - allData.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()); - - // console.log('合并后的数据:', allData); - return { data: allData }; - - } catch (contractError) { - console.error('合同类型查询失败:', contractError); - return { - error: contractError instanceof Error ? contractError.message : '合同类型查询失败', - status: 500 - }; - } - } - - // 非合同类型的原有逻辑 const params: PostgrestParams = { select: ` id, @@ -604,14 +519,16 @@ export async function getTodayDocuments(userInfo?: { user_id?: number; [key: str } }; - // 根据reviewType添加过滤条件 - if (reviewType === 'record') { - // 如果是卷宗类型,只显示type_id=2或type_id=3的文档 + // 🔑 根据 documentTypeIds 添加过滤条件 + if (typeIds && typeIds.length > 0) { + // 使用动态的文档类型 ID 列表 + const typeIdsStr = typeIds.join(','); if (params.filter) { - params.filter['type_id'] = 'in.(2,3,155)'; + params.filter['type_id'] = `in.(${typeIdsStr})`; } else { - params.filter = { 'type_id': 'in.(2,3,155)' }; + params.filter = { 'type_id': `in.(${typeIdsStr})` }; } + console.log('📋 [getTodayDocuments] 使用文档类型 IDs 查询:', typeIdsStr); } // console.log('发送请求参数:', params); @@ -643,33 +560,32 @@ export async function getTodayDocuments(userInfo?: { user_id?: number; [key: str /** * 获取文档类型列表 - * @param reviewType 审核类型(可选) * @param token JWT token (可选) * @returns 文档类型列表 */ -export async function getDocumentTypes(reviewType?: string, token?: string): Promise<{data: DocumentType[]; error?: never} | {data?: never; error: string; status?: number}> { +export async function getDocumentTypes(token?: string): Promise<{data: DocumentType[]; error?: never} | {data?: never; error: string; status?: number}> { try { const params: PostgrestParams = { select: 'id, name', filter: {} // 初始化为空对象 }; - // 根据reviewType添加过滤条件 - if (reviewType === 'contract') { - // 如果是合同类型,只显示id=1的文档类型 + // 🔑 从 sessionStorage 获取文档类型 ID 列表(动态方式) + const documentTypeIds = getDocumentTypeIdsFromSession(); + // console.log('📋 [getDocumentTypes] 文档类型 IDs:', documentTypeIds); + + // 根据 documentTypeIds 添加过滤条件 + if (documentTypeIds && documentTypeIds.length > 0) { + // 使用动态的文档类型 ID 列表 + const typeIdsStr = documentTypeIds.join(','); if (params.filter) { - params.filter['id'] = 'eq.1'; + params.filter['id'] = `in.(${typeIdsStr})`; } else { - params.filter = { 'id': 'eq.1' }; - } - } else if (reviewType === 'record') { - // 如果是卷宗类型,只显示id=2或id=3的文档类型 - if (params.filter) { - params.filter['id'] = 'in.(2,3,155)'; - } else { - params.filter = { 'id': 'in.(2,3,155)' }; + params.filter = { 'id': `in.(${typeIdsStr})` }; } + console.log('📋 [getDocumentTypes] 使用动态类型 IDs 查询:', typeIdsStr); } + // 如果没有 documentTypeIds,返回所有文档类型(不添加过滤条件) const response = await postgrestGet('document_types', { ...params, token }); diff --git a/app/api/home/home.ts b/app/api/home/home.ts index 88e4851..92306a6 100644 --- a/app/api/home/home.ts +++ b/app/api/home/home.ts @@ -485,3 +485,125 @@ export async function getHomeData(reviewType?: string | null,userId?: string | n } } +/** + * 入口模块类型定义 + */ +export interface EntryModule { + id: number; + name: string; + description: string | null; + path: string | null; + areas: string[]; + created_at: string; + updated_at: string; + document_types?: Array<{ + id: number; + name: string; + code: string | null; + }>; +} + +/** + * 获取用户可访问的入口模块 + * @param userArea 用户所属地区 + * @param token JWT token + * @returns 入口模块列表 + */ +export async function getEntryModules(userRole: string | null | undefined, userArea: string | null | undefined, token?: string): Promise { + try { + if (!userRole || !userArea) { + console.warn('⚠️ [getEntryModules] 用户角色或地区为空,返回空模块列表'); + return []; + } + + // console.log('🔍 [getEntryModules] 查询地区:', userArea); + + // 查询 entry_modules 表,筛选 areas 数组中包含用户地区的模块 + // 使用 PostgreSQL JSONB 操作符 @> 检查数组是否包含值 + const params: PostgrestParams = { + select: 'id,name,description,path,areas,created_at,updated_at', + filter: { + // areas 数组中包含用户的 area + // areas: `cs.["${userArea}"]` // cs = contains (PostgreSQL @> 操作符) + } + }; + if (userRole != 'provincial_admin'){ + params.filter = { + areas: `cs.["${userArea}"]` + } + } + + const modulesResponse = await postgrestGet('entry_modules', { ...params, token }); + + if (modulesResponse.error) { + console.error('❌ [getEntryModules] 查询入口模块失败:', modulesResponse.error); + return []; + } + + const modules = extractApiData(modulesResponse.data); + if (!modules || modules.length === 0) { + console.warn('⚠️ [getEntryModules] 未找到匹配的入口模块'); + return []; + } + + console.log(`✅ [getEntryModules] 找到 ${modules.length} 个入口模块`); + + // 为每个模块查询关联的 document_types + const modulesWithTypes = await Promise.all( + modules.map(async (module) => { + try { + const typesParams: PostgrestParams = { + select: 'id,name,code', + filter: { + entry_module_id: `eq.${module.id}` + } + }; + + const typesResponse = await postgrestGet('document_types', { ...typesParams, token }); + + if (typesResponse.error) { + console.error(`❌ [getEntryModules] 查询模块 ${module.id} 的文档类型失败:`, typesResponse.error); + return { ...module, document_types: [] }; + } + + const documentTypes = extractApiData>(typesResponse.data); + + return { + ...module, + document_types: documentTypes || [] + }; + } catch (error) { + console.error(`❌ [getEntryModules] 处理模块 ${module.id} 时出错:`, error); + return { ...module, document_types: [] }; + } + }) + ); + + console.log('✅ [getEntryModules] 入口模块数据(含文档类型):', JSON.stringify(modulesWithTypes)); + + // 默认会多加一个 智慧法务大模型 入口 默认所有人都可以用,看到 + modulesWithTypes.push({ + "id": 0, + "name": "智慧法务大模型", + "description": "智慧法务大模型", + "path": "entryModule/assistant", + "areas": [], + "created_at": "2025-11-18T21:33:33.857417+08:00", + "updated_at": "2025-11-18T22:28:51.819722+08:00", + "document_types": [ + { + "id": 0, + "name": "空", + "code": "空" + } + ] + }) + + + return modulesWithTypes; + } catch (error) { + console.error('❌ [getEntryModules] 获取入口模块失败:', error instanceof Error ? error.message : String(error)); + return []; + } +} + diff --git a/app/api/login/auth.server.ts b/app/api/login/auth.server.ts index 9f4432e..20c2c81 100644 --- a/app/api/login/auth.server.ts +++ b/app/api/login/auth.server.ts @@ -29,7 +29,7 @@ import { OAUTH_CONFIG, API_BASE_URL } from "~/config/api-config"; * @property {'common'} common - 普通用户,有基本的系统访问权限 * @property {'developer'} developer - 开发者/管理员,有完整的系统管理权限 */ -export type UserRole = 'common' | 'admin' | 'deptLeader' | 'groupLeader' | string; +export type UserRole = string; /** * 用户信息接口,对应 sso_users 表结构 @@ -144,22 +144,22 @@ export async function getSession(request: Request) { * @param expiresIn OAuth token过期时间(秒) * @returns JWT字符串 */ -async function generateFrontendJWT(userInfo: UserInfo, savedUserData: SsoUser, userRole: UserRole, expiresIn: number): Promise { - const jwtUserInfo: UserInfoForJWT = { - sub: userInfo.sub, - user_id: savedUserData.id!, - username: savedUserData.username, - nick_name: savedUserData.nick_name, - email: savedUserData.email, - phone_number: savedUserData.phone_number, - ou_id: savedUserData.ou_id, - ou_name: savedUserData.ou_name, - is_leader: savedUserData.is_leader, - user_role: userRole - }; +// async function generateFrontendJWT(userInfo: UserInfo, savedUserData: SsoUser, userRole: UserRole, expiresIn: number): Promise { +// const jwtUserInfo: UserInfoForJWT = { +// sub: userInfo.sub, +// user_id: savedUserData.id!, +// username: savedUserData.username, +// nick_name: savedUserData.nick_name, +// email: savedUserData.email, +// phone_number: savedUserData.phone_number, +// ou_id: savedUserData.ou_id, +// ou_name: savedUserData.ou_name, +// is_leader: savedUserData.is_leader, +// user_role: userRole +// }; - return JWTUtils.generateJWT(jwtUserInfo, expiresIn); -} +// return JWTUtils.generateJWT(jwtUserInfo, expiresIn); +// } /** * 创建包含JWT的用户信息对象 @@ -169,38 +169,44 @@ async function generateFrontendJWT(userInfo: UserInfo, savedUserData: SsoUser, u * @param frontendJWT 前端JWT * @returns 完整的用户信息对象 */ -function createUserInfoWithJWT(userInfo: UserInfo, savedUserData: SsoUser, userRole: UserRole, frontendJWT: string) { - return { - // 保持与callback.tsx中enhancedUserInfo相同的数据结构 - sub: userInfo.sub, - username: savedUserData.username, - nick_name: savedUserData.nick_name, - phone_number: savedUserData.phone_number, - email: savedUserData.email, - ou_id: savedUserData.ou_id, - ou_name: savedUserData.ou_name, - status: savedUserData.status, - is_leader: savedUserData.is_leader, - // 增强字段,与OAuth登录保持一致 - user_id: savedUserData.id, - user_role: userRole, - frontend_jwt: frontendJWT - }; -} +// function createUserInfoWithJWT(userInfo: UserInfo, savedUserData: SsoUser, userRole: UserRole, frontendJWT: string) { +// return { +// // 保持与callback.tsx中enhancedUserInfo相同的数据结构 +// sub: userInfo.sub, +// username: savedUserData.username, +// nick_name: savedUserData.nick_name, +// phone_number: savedUserData.phone_number, +// email: savedUserData.email, +// ou_id: savedUserData.ou_id, +// ou_name: savedUserData.ou_name, +// status: savedUserData.status, +// is_leader: savedUserData.is_leader, +// // 增强字段,与OAuth登录保持一致 +// user_id: savedUserData.id, +// user_role: userRole, +// frontend_jwt: frontendJWT +// }; +// } export async function getUserSession(request: Request) { const session = await getSession(request); const isAuthenticated = session.get("isAuthenticated") === true; const userRole = session.get("userRole") as UserRole; - let accessToken = session.get("accessToken"); + const accessToken = session.get("accessToken"); const refreshToken = session.get("refreshToken"); - let tokenIssuedAt = session.get("tokenIssuedAt"); + const tokenIssuedAt = session.get("tokenIssuedAt"); let tokenExpiresIn = session.get("tokenExpiresIn"); const userInfo = session.get("userInfo"); - let frontendJWT = session.get("frontendJWT"); + const frontendJWT = session.get("frontendJWT"); + + // 🔑 检查是否是公共路径(不需要认证的路径) + const url = new URL(request.url); + const pathname = url.pathname; + const publicPaths = ['/login', '/favicon.ico', '/callback']; + const isPublicPath = publicPaths.some(path => pathname.startsWith(path)); let refreshedSession = null; - let shouldRegenerateJWT = false; + // let shouldRegenerateJWT = false; // 🔑 新的统一过期检查逻辑 // 不区分 admin 和 OAuth 用户,所有用户都使用同样的过期检查 @@ -251,20 +257,44 @@ export async function getUserSession(request: Request) { } } - // 🚨 如果 session 无效(包括 token 过期),自动重定向到登录页 - if (!finalIsAuthenticated && isAuthenticated) { - console.error("❌ [getUserSession] Session 已失效,清除 session 并重定向到登录页"); + // 🚨 统一的认证检查和重定向逻辑 + if (!finalIsAuthenticated) { + // 如果是公共路径,不重定向,直接返回未认证状态 + if (isPublicPath) { + // console.log("ℹ️ [getUserSession] 公共路径,允许未认证访问:", pathname); + return { + isAuthenticated: false, + userRole, + accessToken, + refreshToken, + userInfo, + refreshedSession, + frontendJWT + }; + } - // 销毁服务端 session - const { redirect } = await import("@remix-run/node"); - const destroyedSession = await sessionStorage.destroySession(session); + // 非公共路径且未认证,重定向到登录页 + if (isAuthenticated) { + // Session 存在但已失效(token 过期或数据不完整) + console.error("❌ [getUserSession] Session 已失效,清除 session 并重定向到登录页"); + const { redirect } = await import("@remix-run/node"); + const destroyedSession = await sessionStorage.destroySession(session); - // 重定向到登录页,添加 expired=true 参数标识是因为过期重定向 - throw redirect("/login?expired=true", { - headers: { - "Set-Cookie": destroyedSession - } - }); + // 重定向到登录页,添加 expired=true 参数 + throw redirect("/login?expired=true", { + headers: { + "Set-Cookie": destroyedSession + } + }); + } else { + // Session 不存在(首次访问或已登出) + console.warn("⚠️ [getUserSession] 未登录,重定向到登录页"); + const { redirect } = await import("@remix-run/node"); + + // 保存当前路径,登录后可以跳转回来 + const redirectTo = pathname !== '/login' ? pathname : '/'; + throw redirect(`/login?redirect=${encodeURIComponent(redirectTo)}`); + } } return { diff --git a/app/api/login/login-client.ts b/app/api/login/login-client.ts index 9f661c8..08572c9 100644 --- a/app/api/login/login-client.ts +++ b/app/api/login/login-client.ts @@ -45,6 +45,7 @@ export interface LoginResponse { is_leader: boolean; user_role: string; sub: string; + area?: string; // 🔑 用户所属地区 }; }; error?: string; diff --git a/app/api/postgrest-client.ts b/app/api/postgrest-client.ts index 09b5be2..92617bc 100644 --- a/app/api/postgrest-client.ts +++ b/app/api/postgrest-client.ts @@ -96,24 +96,29 @@ function mergeAuthHeaders( explicitToken?: string ): Record { const headers = { ...existingHeaders }; - + // 如果已经有 Authorization 头部(不区分大小写),不覆盖 const hasAuth = Object.keys(headers).some( key => key.toLowerCase() === 'authorization' ); - + if (hasAuth) { + console.log('🔑 [mergeAuthHeaders] 已存在 Authorization 头,不覆盖'); return headers; } - + // 优先使用显式传入的 token,否则尝试从客户端 localStorage 获取 const token = explicitToken || (typeof window !== 'undefined' ? localStorage.getItem('access_token') : undefined); // 如果有有效的 token(显式传入或从客户端获取),添加到 Authorization 头部 if (token && token !== 'undefined') { + // console.log('🔑 [mergeAuthHeaders] 添加 Authorization 头,token 来源:', explicitToken ? 'explicitToken' : 'localStorage'); + // console.log('🔑 [mergeAuthHeaders] Token 前10位:', token.substring(0, 10)); headers['Authorization'] = `Bearer ${token}`; + } else { + console.warn('⚠️ [mergeAuthHeaders] 没有可用的 token!explicitToken:', explicitToken, 'window:', typeof window); } - + return headers; } diff --git a/app/components/layout/Breadcrumb.tsx b/app/components/layout/Breadcrumb.tsx index 905447d..33cd8dc 100644 --- a/app/components/layout/Breadcrumb.tsx +++ b/app/components/layout/Breadcrumb.tsx @@ -96,9 +96,11 @@ export function Breadcrumb({ items = [], className = '' }: BreadcrumbProps) { {index === breadcrumbs.length - 1 ? ( {item.title} ) : ( - {item.title} diff --git a/app/components/layout/Layout.tsx b/app/components/layout/Layout.tsx index afe5d7f..ec4a9dd 100644 --- a/app/components/layout/Layout.tsx +++ b/app/components/layout/Layout.tsx @@ -5,16 +5,6 @@ import { Breadcrumb } from './Breadcrumb'; import { useMatches, useLocation } from '@remix-run/react'; import type { UserRole } from '~/root'; -// 定义应用模块类型 -type AppModule = 'contract' | 'record' | 'model' | ''; - -// 应用模块与reviewType的映射 -const REVIEW_TYPE_TO_APP: Record = { - 'contract': 'contract', // 合同管理 - 'record': 'record', // 案卷智能评查 - 'model': 'model' // 智慧法务大模型 -}; - interface LayoutProps { children: React.ReactNode; userRole?: UserRole; @@ -35,7 +25,6 @@ interface Match { export function Layout({ children, userRole = 'developer' as UserRole, frontendJWT = '' }: LayoutProps) { const [sidebarCollapsed, setSidebarCollapsed] = useState(false); - const [selectedApp, setSelectedApp] = useState(''); const [effectiveUserRole, setEffectiveUserRole] = useState(userRole); const [effectiveFrontendJWT, setEffectiveFrontendJWT] = useState(frontendJWT); const matches = useMatches() as Match[]; @@ -83,7 +72,7 @@ export function Layout({ children, userRole = 'developer' as UserRole, frontendJ } }, [userRole, frontendJWT]); - // 从sessionStorage中获取侧边栏状态和reviewType + // 从localStorage中获取侧边栏状态 useEffect(() => { // 检查是否为移动端 const isMobile = window.innerWidth <= 768; @@ -97,35 +86,8 @@ export function Layout({ children, userRole = 'developer' as UserRole, frontendJ } else if (savedState) { setSidebarCollapsed(savedState === 'true'); } - - // 从sessionStorage获取reviewType并设置对应的应用模块 - if (typeof window !== 'undefined') { - try { - const reviewType = sessionStorage.getItem('reviewType'); - if (reviewType && REVIEW_TYPE_TO_APP[reviewType]) { - setSelectedApp(REVIEW_TYPE_TO_APP[reviewType]); - } - } catch (error) { - console.error('获取reviewType失败:', error); - } - } }, []); - // 路由变化时,检查并更新应用模块 - useEffect(() => { - if (typeof window !== 'undefined') { - try { - const reviewType = sessionStorage.getItem('reviewType'); - // console.log('Layout 路由变化, reviewType:', reviewType, '路径:', location.pathname); - if (reviewType && REVIEW_TYPE_TO_APP[reviewType]) { - setSelectedApp(REVIEW_TYPE_TO_APP[reviewType]); - } - } catch (error) { - console.error('路由变化时获取reviewType失败:', error); - } - } - }, [location.pathname]); - const toggleSidebar = () => { const newState = !sidebarCollapsed; setSidebarCollapsed(newState); @@ -150,7 +112,6 @@ export function Layout({ children, userRole = 'developer' as UserRole, frontendJ collapsed={sidebarCollapsed} onToggle={toggleSidebar} userRole={effectiveUserRole} - selectedApp={selectedApp} frontendJWT={effectiveFrontendJWT} /> diff --git a/app/components/layout/Sidebar.tsx b/app/components/layout/Sidebar.tsx index f961046..7ed4553 100644 --- a/app/components/layout/Sidebar.tsx +++ b/app/components/layout/Sidebar.tsx @@ -8,36 +8,15 @@ interface SidebarProps { collapsed: boolean; userRole: UserRole; frontendJWT?: string; - selectedApp?: string; // 添加所选应用模块参数 } -// 已移除 APP_MENU_MAP:路由的显示/隐藏由后端 is_hidden 字段控制 -// 只保留特殊规则: -// - /chat-with-llm 只在 model 模块中显示 -// - /contract-template 只在 contract 模块中显示 - -// 应用模块名称映射 -const APP_NAME_MAP: Record = { - 'contract': '合同管理', - 'record': '案卷智能评查', - 'model': '智慧法务大模型' -}; - -// 应用模块图标映射 -const APP_ICON_MAP: Record = { - 'contract': '/images/icon_hetong.png', - 'record': '/images/icon_anjuan.png', - 'model': '/images/icon_assistant.png' -}; - -export function Sidebar({ onToggle, collapsed, userRole, frontendJWT = '', selectedApp = '' }: SidebarProps) { +export function Sidebar({ onToggle, collapsed, userRole, frontendJWT = '' }: SidebarProps) { const location = useLocation(); const [expandedMenus, setExpandedMenus] = useState>({}); - const [currentApp, setCurrentApp] = useState(''); // 初始设置为空字符串而不是selectedApp - const [isLoading, setIsLoading] = useState(true); // 添加加载状态 const [menuItems, setMenuItems] = useState([]); // 动态菜单项 const [isLoadingRoutes, setIsLoadingRoutes] = useState(true); // 路由加载状态 const [isMobile, setIsMobile] = useState(false); // 移动端检测 + const [selectedModuleName, setSelectedModuleName] = useState(''); // 当前选中的模块名称 const navigate = useNavigate(); // 移动端检测 @@ -57,6 +36,8 @@ export function Sidebar({ onToggle, collapsed, userRole, frontendJWT = '', selec // 获取用户路由权限 useEffect(() => { + console.log('🔍 [Sidebar] useEffect 触发,开始获取路由权限'); + const fetchUserRoutes = async () => { setIsLoadingRoutes(true); try { @@ -69,19 +50,19 @@ export function Sidebar({ onToggle, collapsed, userRole, frontendJWT = '', selec } if (!jwt) { - console.error('❌ [Sidebar] JWT token 未找到'); + console.error('❌ [Sidebar] JWT token 未找到,props.frontendJWT:', frontendJWT, 'localStorage.access_token:', localStorage.getItem('access_token')); setMenuItems([]); setIsLoadingRoutes(false); return; } - console.log('🔍 [Sidebar] 当前用户角色:', userRole); - const roleKey = mapUserRoleToRoleKey(userRole); - const result = await getUserRoutesByRole(roleKey, jwt); + // console.log('🔍 [Sidebar] 当前用户角色:', userRole, 'JWT前20字符:', jwt.substring(0, 20)); + // console.log('🔍 [Sidebar] 映射后的角色key:', roleKey); + const result = await getUserRoutesByRole(userRole, jwt); if (result.success && result.data) { setMenuItems(result.data); - console.log('✅ [Sidebar] 用户路由权限加载成功:', result.data); + // console.log('✅ [Sidebar] 用户路由权限加载成功:', result.data); } else { console.error('❌ [Sidebar] 获取用户路由权限失败:', result.error); @@ -108,93 +89,20 @@ export function Sidebar({ onToggle, collapsed, userRole, frontendJWT = '', selec fetchUserRoutes(); }, [userRole, frontendJWT, navigate]); - // 组件挂载后从 sessionStorage 读取初始 reviewType + // 从 sessionStorage 读取当前选中的模块名称 useEffect(() => { - let mounted = true; - setIsLoading(true); // 设置加载状态 - - try { - const reviewType = sessionStorage.getItem('reviewType'); - // console.log('初始 reviewType:', reviewType); - if (reviewType && mounted) { - setCurrentApp(reviewType); - } else if (selectedApp && mounted) { - // 如果没有reviewType,但有selectedApp,使用selectedApp - setCurrentApp(selectedApp); - } - } catch (error) { - console.error('读取 reviewType 失败:', error); - } finally { - // 使用setTimeout确保状态在DOM更新后再变化,避免闪烁 - setTimeout(() => { - if (mounted) { - setIsLoading(false); // 数据加载完成 + if (typeof window !== 'undefined') { + try { + const moduleName = sessionStorage.getItem('selectedModuleName'); + if (moduleName) { + setSelectedModuleName(moduleName); + console.log('📌 [Sidebar] 当前选中模块:', moduleName); } - }, 0); - } - - return () => { - mounted = false; - }; - }, [selectedApp]); - - // 从 sessionStorage 获取 reviewType 并设置当前应用模块 - useEffect(() => { - // 监听 sessionStorage 变化(主要用于多标签页情况) - const handleStorageChange = (e: StorageEvent) => { - if (e.key === 'reviewType' && e.newValue) { - setCurrentApp(e.newValue); - } - }; - - // 添加事件监听器 - window.addEventListener('storage', handleStorageChange); - - return () => { - window.removeEventListener('storage', handleStorageChange); - }; - }, []); - - // 监听路由变化,重新检查 reviewType - useEffect(() => { - let mounted = true; - - try { - const reviewType = sessionStorage.getItem('reviewType'); - // 只有当reviewType变化时才设置加载状态和更新currentApp - if (reviewType && reviewType !== currentApp && mounted) { - setIsLoading(true); // 路由变化时设置加载状态 - setCurrentApp(reviewType); - // 使用setTimeout确保状态在DOM更新后再变化,避免闪烁 - setTimeout(() => { - if (mounted) { - setIsLoading(false); - } - }, 0); - } - } catch (error) { - console.error('路由变化时读取 reviewType 失败:', error); - if (mounted) { - setIsLoading(false); + } catch (error) { + console.error('❌ [Sidebar] 读取 selectedModuleName 失败:', error); } } - - return () => { - mounted = false; - }; - }, [location.pathname, currentApp]); - - // 监听 selectedApp 属性变化 - useEffect(() => { - if (selectedApp && selectedApp !== currentApp) { - setIsLoading(true); // 设置加载状态 - setCurrentApp(selectedApp); - // 使用setTimeout确保状态在DOM更新后再变化,避免闪烁 - setTimeout(() => { - setIsLoading(false); // 数据加载完成 - }, 0); - } - }, [selectedApp, currentApp]); + }, [location.pathname]); // 路由变化时重新读取 // 初始化展开状态,默认全部展开 useEffect(() => { @@ -238,73 +146,72 @@ export function Sidebar({ onToggle, collapsed, userRole, frontendJWT = '', selec // console.log('子菜单点击:', child.title, '路径:', child.path); }; - // 检查是否通过51707端口访问(省局) - const isPort51707 = typeof window !== 'undefined' && window.location.port === '51707'; + const isPort51707 = typeof window !== 'undefined' && window.location.port === '51707' - // 根据当前应用模式过滤菜单项 - const filteredMenuItems = menuItems - .filter(item => { - // 如果是51707端口,只显示交叉评查相关菜单 - if (isPort51707) { - // 如果当前应用是智慧法务大模型,只显示AI对话菜单 - if (currentApp === 'model') { - return item.path && item.path.startsWith('/chat-with-llm'); - } else { - return item.path && item.path.startsWith('/cross-checking'); - } + // 处理菜单项:清理子菜单结构 + const processedMenuItems: MenuItem[] = menuItems.filter(item =>{ + // 如果是省局访问 + if(isPort51707){ + if (selectedModuleName === '智慧法务大模型'){ + return item.path && item.path.startsWith('/chat-with-llm') } + return item.path && item.path.startsWith('/cross-checking') + } - // 特殊规则1:/chat-with-llm 只在 model 模块中显示 + // 🔑 如果选择了"智慧法务大模型",只显示 /chat-with-llm 相关菜单 + if (selectedModuleName === '智慧法务大模型') { + return item.path === '/chat-with-llm' || item.path?.startsWith('/chat-with-llm/'); + } + + // 🔑 如果选择了包含"合同"的模块 + if (selectedModuleName.includes('合同')) { + // 排除智慧法务大模型专属菜单 if (item.path === '/chat-with-llm' || item.path?.startsWith('/chat-with-llm/')) { - return currentApp === 'model'; + return false; } - - // 特殊规则2:/contract-template 只在 contract 模块中显示 - if (item.path === '/contract-template' || item.path?.startsWith('/contract-template/')) { - return currentApp === 'contract'; - } - - // 其他路由:后端已通过 is_hidden 控制显示/隐藏,这里全部保留 + // 保留其他所有菜单(包括 /contract-template) return true; - }) - .map(item => { - // 处理子菜单:过滤隐藏的子菜单 - if (item.children && item.children.length > 0) { - // 过滤掉 hideBreadcrumb=true 的子菜单(这些通常是隐藏菜单) - const visibleChildren = item.children.filter(child => !child.hideBreadcrumb); + } - // 如果过滤后没有可见的子菜单,返回不带子菜单的父级(变成可点击的单级菜单) - if (visibleChildren.length === 0) { - const { children, ...itemWithoutChildren } = item; - return itemWithoutChildren; - } + // 🔑 其他模块:排除特殊菜单 + // 排除智慧法务大模型专属菜单 + if (item.path === '/chat-with-llm' || item.path?.startsWith('/chat-with-llm/')) { + return false; + } + // 排除合同专属菜单 + if (item.path === '/contract-template' || item.path?.startsWith('/contract-template/')) { + return false; + } - // 如果还有可见的子菜单,返回带过滤后子菜单的项 - return { ...item, children: visibleChildren }; - } + // 保留其他菜单 + return true; - // 处理空 children 数组或 undefined 的情况 - if (item.children !== undefined) { - // 如果 children 存在但为空数组,移除它(让父级变成可点击的单级菜单) + }).map((item): MenuItem => { + // 处理子菜单:过滤隐藏的子菜单 + if (item.children && item.children.length > 0) { + // 过滤掉 hideBreadcrumb=true 的子菜单(这些通常是隐藏菜单) + const visibleChildren = item.children.filter(child => !child.hideBreadcrumb); + + // 如果过滤后没有可见的子菜单,返回不带子菜单的父级(变成可点击的单级菜单) + if (visibleChildren.length === 0) { const { children, ...itemWithoutChildren } = item; - return itemWithoutChildren; + return itemWithoutChildren as MenuItem; } - // 没有子菜单的项直接返回 - return item; - }); + // 如果还有可见的子菜单,返回带过滤后子菜单的项 + return { ...item, children: visibleChildren }; + } - // filteredMenuItems = filteredMenuItems.map(item => { - // if(item.children && item.children.length > 0){ - // const children = item.children.filter(child => { - // const isUploadByPath = child.path === '/files/upload' || child.path?.startsWith('/files/upload') - // const isUploadByTitle = child.title === '文件上传' - // return !(isUploadByPath || isUploadByTitle) - // }) - // return { ...item, children} - // } - // return item - // }) + // 处理空 children 数组或 undefined 的情况 + if (item.children !== undefined) { + // 如果 children 存在但为空数组,移除它(让父级变成可点击的单级菜单) + const { children, ...itemWithoutChildren } = item; + return itemWithoutChildren as MenuItem; + } + + // 没有子菜单的项直接返回 + return item; + }); return ( <> @@ -353,27 +260,8 @@ export function Sidebar({ onToggle, collapsed, userRole, frontendJWT = '', selec - {!collapsed && ( -
-
- {isLoading ? ( - // 加载中状态,只显示加载图标,保留布局 -
-
- 加载中... -
- ) : ( - <> - {APP_NAME_MAP[currentApp] - {APP_NAME_MAP[currentApp] || ''} - - )} -
-
- )} -
- {isLoading || isLoadingRoutes ? ( + {isLoadingRoutes ? ( // 加载中状态显示,保留菜单布局结构
{Array(5).fill(0).map((_, index) => ( @@ -387,7 +275,7 @@ export function Sidebar({ onToggle, collapsed, userRole, frontendJWT = '', selec
) : ( // 数据加载完成后显示菜单 - filteredMenuItems.map((item) => ( + processedMenuItems.map((item) => (
{!item.children ? ( { if (!formData.evaluation_point_groups_pid) return ""; - + const typeGroup = evaluationPointGroups.find( - group => group.id === formData.evaluation_point_groups_pid && group.pid === 0 + group => group.id === formData.evaluation_point_groups_pid && (!group.pid || group.pid === 0) // 🆕 NULL或0都表示顶级分组 ); - + return typeGroup?.code || ""; }; @@ -45,7 +45,7 @@ export function BasicInfo({ onChange, initialData, evaluationPointGroups = [], r group.is_enabled ); - // 获取评查点类型选项(pid=0的数据) + // 🆕 获取评查点类型选项(pid为NULL或0的数据) const getCheckpointTypeOptions = () => { if (!evaluationPointGroups || evaluationPointGroups.length === 0) { return ( @@ -54,8 +54,8 @@ export function BasicInfo({ onChange, initialData, evaluationPointGroups = [], r ); } - - const typeGroups = evaluationPointGroups.filter(group => group.pid === 0 && group.is_enabled); + + const typeGroups = evaluationPointGroups.filter(group => (!group.pid || group.pid === 0) && group.is_enabled); return ( <> @@ -113,8 +113,8 @@ export function BasicInfo({ onChange, initialData, evaluationPointGroups = [], r case 'checkpoint-type': // 处理评查点类型选择 if (value) { - // 找到选中的类型组 - const selectedType = evaluationPointGroups.find(group => group.code === value && group.pid === 0); + // 🆕 找到选中的类型组(pid为NULL或0表示顶级分组) + const selectedType = evaluationPointGroups.find(group => group.code === value && (!group.pid || group.pid === 0)); if (selectedType) { newData.evaluation_point_groups_pid = selectedType.id; } diff --git a/app/components/rules/new/ExtractionSettings.tsx b/app/components/rules/new/ExtractionSettings.tsx index 75f95d0..cd115a2 100644 --- a/app/components/rules/new/ExtractionSettings.tsx +++ b/app/components/rules/new/ExtractionSettings.tsx @@ -1029,51 +1029,55 @@ export function ExtractionSettings({ ))}
-
- -
- {[ - { - label: "日期格式:yyyy-mm-dd", - regex: - "\\d{4}[-/年](0?[1-9]|1[0-2])[-/月](0?[1-9]|[12][0-9]|3[01])[日]?", - }, - { label: "合同编号格式", regex: "[A-Z]{2,5}-\\d{4,10}" }, - { - label: "金额格式", - regex: - "(人民币|RMB)?\\s?(\\d{1,3}(,\\d{3})*(\\.\\d{2})?)\\s?[万元]?", - }, - { - label: "座机号码格式", - regex: "\\d{3}-\\d{8}|\\d{4}-\\d{7,8}", - }, - { label: "手机号码格式", regex: "1[3-9]\\d{9}" }, - ].map(({ label, regex }) => ( -
applyRegexTemplate(regex)} - role="button" - tabIndex={0} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") - applyRegexTemplate(regex); - }} - > - {label} -
- ))} + + {/* 🔑 只有在添加字段后或本来就有字段时才显示常用正则模板 */} + {regexFields.length > 0 && ( +
+ +
+ {[ + { + label: "日期格式:yyyy-mm-dd", + regex: + "\\d{4}[-/年](0?[1-9]|1[0-2])[-/月](0?[1-9]|[12][0-9]|3[01])[日]?", + }, + { label: "合同编号格式", regex: "[A-Z]{2,5}-\\d{4,10}" }, + { + label: "金额格式", + regex: + "(人民币|RMB)?\\s?(\\d{1,3}(,\\d{3})*(\\.\\d{2})?)\\s?[万元]?", + }, + { + label: "座机号码格式", + regex: "\\d{3}-\\d{8}|\\d{4}-\\d{7,8}", + }, + { label: "手机号码格式", regex: "1[3-9]\\d{9}" }, + ].map(({ label, regex }) => ( +
applyRegexTemplate(regex)} + role="button" + tabIndex={0} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") + applyRegexTemplate(regex); + }} + > + {label} +
+ ))} +
-
+ )}
diff --git a/app/root.tsx b/app/root.tsx index d97725c..54f5d8a 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -126,33 +126,47 @@ export async function loader({ request }: LoaderFunctionArgs) { try { const { getUserSession } = await import("~/api/login/auth.server"); const session = await getUserSession(request); - userRole = session.userRole || 'common'; + userRole = session.userRole; frontendJWT = session.frontendJWT || null; - // console.log("🔑 [Root Loader] 用户角色:", userRole); + + // 🔑 检查用户角色和JWT是否为空 + if (!userRole || userRole === '') { + console.error("❌ [Root Loader] 用户角色为空,session数据异常"); + // 保存当前路径,登录后可以跳转回来 + const redirectTo = pathname !== '/login' ? pathname : '/'; + return redirect(`/login?redirect=${encodeURIComponent(redirectTo)}&error=no_role`); + } + + if (!frontendJWT) { + console.error("❌ [Root Loader] JWT token为空,session数据异常"); + // 保存当前路径,登录后可以跳转回来 + const redirectTo = pathname !== '/login' ? pathname : '/'; + return redirect(`/login?redirect=${encodeURIComponent(redirectTo)}&error=no_token`); + } + + // console.log("🔑 [Root Loader] 用户角色:", userRole, "JWT前20字符:", frontendJWT.substring(0, 20)); // 🔒 RBAC 路由权限检查 - if (frontendJWT) { - const { getUserRoutesByRole } = await import("~/api/auth/user-routes"); - // 权限校验需要包含隐藏路由,确保用户可以访问隐藏的功能页面 - const routesResult = await getUserRoutesByRole(userRole, frontendJWT, true); + const { getUserRoutesByRole } = await import("~/api/auth/user-routes"); + // 权限校验需要包含隐藏路由,确保用户可以访问隐藏的功能页面 + const routesResult = await getUserRoutesByRole(userRole, frontendJWT, true); - if (routesResult.success && routesResult.data) { - // 从菜单数据中提取所有允许的路径 - allowedPaths = extractAllPaths(routesResult.data); - // console.log("🔑 [Root Loader] 用户允许的路由:", allowedPaths); + if (routesResult.success && routesResult.data) { + // 从菜单数据中提取所有允许的路径 + allowedPaths = extractAllPaths(routesResult.data); + // console.log("🔑 [Root Loader] 用户允许的路由:", allowedPaths); - // 检查当前路径是否在允许列表中 - const isAllowedPath = isPathAllowed(pathname, allowedPaths); + // 检查当前路径是否在允许列表中 + const isAllowedPath = isPathAllowed(pathname, allowedPaths); - if (!isAllowedPath) { - console.warn(`⚠️ [Root Loader] 用户尝试访问未授权路由: ${pathname}`); - // 返回 403 错误,而不是 redirect(避免循环) - throw new Response("无权访问此页面", { status: 403 }); - } - } else { - // 获取路由权限失败,只记录警告,不阻止访问(避免影响正常使用) - console.warn("⚠️ [Root Loader] 获取用户路由权限失败,跳过权限检查"); + if (!isAllowedPath) { + console.warn(`⚠️ [Root Loader] 用户尝试访问未授权路由: ${pathname}`); + // 返回 403 错误,而不是 redirect(避免循环) + throw new Response("无权访问此页面", { status: 403 }); } + } else { + // 获取路由权限失败,只记录警告,不阻止访问(避免影响正常使用) + console.warn("⚠️ [Root Loader] 获取用户路由权限失败,跳过权限检查"); } } catch (error) { // 如果是 Response 对象(403 错误),直接抛出 @@ -171,13 +185,9 @@ export async function loader({ request }: LoaderFunctionArgs) { console.warn("⚠️ [Root Loader] 获取用户会话失败:", error); // 保持默认值 'common' } - } - // ⚠️ 重要:现在使用客户端 localStorage 存储 token,服务端不再检查 session - // 认证检查改为在客户端进行(通过 ClientAuthGuard 组件) - - if (!frontendJWT) { - frontendJWT = getAccessToken(); + // 注意:认证检查和重定向已在 getUserSession() 中统一处理 + // 如果执行到这里,说明已通过认证或是公共路径 } // 检查51707端口访问控制 diff --git a/app/routes/_index.tsx b/app/routes/_index.tsx index af0c85a..44a104d 100644 --- a/app/routes/_index.tsx +++ b/app/routes/_index.tsx @@ -4,6 +4,7 @@ import { type MetaFunction, type ActionFunctionArgs, LoaderFunctionArgs, redirec import styles from "~/styles/pages/home.css?url"; import dayjs from 'dayjs'; import { getUserSession, logout } from "~/api/login/auth.server"; +import { toastService } from '~/components/ui'; export const links = () => [ { rel: "stylesheet", href: styles } @@ -28,15 +29,28 @@ export async function action({ request }: ActionFunctionArgs) { return null; } -// 获取用户信息(不再检查服务端认证) +// 获取用户信息 export async function loader({ request }: LoaderFunctionArgs) { - // ⚠️ 不再检查服务端 session 认证 - // 认证检查由 ClientAuthGuard 在客户端进行 + // 🔒 认证检查已在 getUserSession() 中统一处理 + // 如果未认证,会自动重定向到登录页,不会执行到这里 + const { userRole, userInfo, frontendJWT } = await getUserSession(request); - const { userRole, userInfo } = await getUserSession(request); + // 🔑 获取用户地区并查询入口模块 + const userArea = userInfo?.area || null; + // console.log('🔍 [Index Loader] 用户地区:', userArea); + // console.log('🔍 [Index Loader] 用户角色:', userRole); - // 返回用户信息给客户端(可能为空) - return Response.json({ userRole, userInfo }); + let entryModules = []; + if (userRole && frontendJWT) { + const { getEntryModules } = await import('~/api/home/home'); + entryModules = await getEntryModules(userRole,userArea, frontendJWT); + console.log(`📦 [Index Loader] 获取到 ${entryModules.length} 个入口模块`); + } else { + console.warn('⚠️ [Index Loader] 用户角色为空,返回空模块列表'); + } + + // 返回用户信息和入口模块给客户端 + return Response.json({ userRole, userInfo, entryModules }); } export default function Index() { @@ -102,21 +116,73 @@ export default function Index() { }, []); // 处理模块点击 - const handleModuleClick = (path: string, reviewType: string) => { - // 将reviewType存入sessionStorage - if (typeof window !== 'undefined') { - sessionStorage.setItem('reviewType', reviewType); + const handleModuleClick = (module: typeof loaderData.entryModules[0]) => { + // 提取文档类型 IDs + const typeIds = module.document_types?.map(dt => dt.id) || []; + + // 🔑 验证文档类型(智慧法务大模型除外) + if (module.name !== '智慧法务大模型' && typeIds.length === 0) { + toastService.error('该入口尚未关联文档类型,无法进入'); + console.warn('⚠️ [Index] 模块未关联文档类型:', module.name); + return; // 阻止进入 } - navigate(path); + + if (typeof window !== 'undefined') { + // 🔑 存储到 sessionStorage(用于客户端请求) + if (typeIds.length > 0) { + sessionStorage.setItem('documentTypeIds', JSON.stringify(typeIds)); + + // console.log('📝 [Index] 存储到客户端 sessionStorage:', typeIds); + } else { + // 清空文档类型数据 + sessionStorage.removeItem('documentTypeIds'); + } + + // 存储模块信息 + sessionStorage.setItem('selectedModuleId', String(module.id)); + sessionStorage.setItem('selectedModuleName', module.name); + sessionStorage.setItem('selectedModulePicPath', module.path) + } + + // 🔑 根据模块名称决定跳转路径 + let targetPath = '/home'; // 默认跳转到首页 + + if (module.name.includes('合同')) { + // 合同相关模块 → 跳转到合同模板搜索 + targetPath = '/contract-template/search'; + // console.log('📌 [Index] 合同模块,跳转到:', targetPath); + } else if (module.name === '智慧法务大模型') { + // 智慧法务大模型 → 跳转到 AI 对话 + targetPath = '/chat-with-llm'; + // console.log('📌 [Index] 智慧法务大模型,跳转到:', targetPath); + } else { + // console.log('📌 [Index] 其他模块,跳转到:', targetPath); + } + + navigate(targetPath); }; // 处理键盘事件 - const handleKeyDown = (path: string, reviewType: string, e: React.KeyboardEvent) => { + const handleKeyDown = (module: typeof loaderData.entryModules[0], e: React.KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { - handleModuleClick(path, reviewType); + handleModuleClick(module); } }; + // 获取模块图标(根据模块 path 或 id) + const getModuleIcon = (module: typeof loaderData.entryModules[0]) => { + // 根据 path 判断图标 + if (module.path?.includes('ht')) { + return '/images/icon_hetong.png'; + } else if (module.path?.includes('aj')) { + return '/images/icon_anjuan.png'; + } else if (module.path?.includes('nw')) { + return '/images/icon_assistant.png'; + } + // 默认图标 + return '/images/icon_assistant.png'; + }; + // 处理登出 const handleLogout = () => { // 清除sessionStorage中的所有数据 @@ -182,46 +248,31 @@ export default function Index() {

- 欢迎来到智慧法务平台 -

- {/* 合同管理模块 - 51708端口时隐藏 */} - {!isPort51707 && ( -
handleModuleClick('/contract-template/search', 'contract')} - onKeyDown={(e) => handleKeyDown('/contract-template/search', 'contract', e)} - role="button" - tabIndex={0} - aria-label="合同管理" - > - 合同管理 - 合同管理 + {/* 动态渲染入口模块 */} + {loaderData.entryModules && loaderData.entryModules.length > 0 ? ( + loaderData.entryModules.map((module) => ( +
handleModuleClick(module)} + onKeyDown={(e) => handleKeyDown(module, e)} + role="button" + tabIndex={0} + aria-label={module.name} + > + {module.name} + {module.name} +
+ )) + ) : ( +
+ 暂无可用模块
)} - - {/* 案卷智能评查模块 */} -
handleModuleClick('/home', 'record')} - onKeyDown={(e) => handleKeyDown('/home', 'record', e)} - role="button" - tabIndex={0} - aria-label="案卷智能评查" - > - 案卷智能评查 - 案卷智能评查 -
- - {/* 智慧法务大模型模块 */} -
handleModuleClick('/chat-with-llm', 'model')} - onKeyDown={(e) => handleKeyDown('/chat-with-llm', 'model', e)} - role="button" - tabIndex={0} - aria-label="智慧法务大模型" - > - 智慧法务大模型 - 智慧法务大模型 -
diff --git a/app/routes/callback.tsx b/app/routes/callback.tsx index 20b4b82..d3c4a77 100644 --- a/app/routes/callback.tsx +++ b/app/routes/callback.tsx @@ -205,6 +205,7 @@ export async function loader({ request }: LoaderFunctionArgs) { is_leader: savedUserInfo.is_leader, user_id: savedUserInfo.user_id, user_role: savedUserInfo.user_role, // 使用后端返回的角色 + area: savedUserInfo.area, // 🔑 用户所属地区 frontend_jwt: frontendJWT }; @@ -222,6 +223,7 @@ export async function loader({ request }: LoaderFunctionArgs) { ou_name: savedUserInfo.ou_name, is_leader: savedUserInfo.is_leader, user_role: savedUserInfo.user_role, + area: savedUserInfo.area, // 🔑 用户所属地区 sub: userInfo.data.sub }))); callbackUrl.searchParams.set('redirectTo', redirectTo); diff --git a/app/routes/documents.list.tsx b/app/routes/documents.list.tsx index 53e6385..f44956e 100644 --- a/app/routes/documents.list.tsx +++ b/app/routes/documents.list.tsx @@ -56,7 +56,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { label: type.name })); - // 初始返回空数据,将在客户端根据 sessionStorage 中的 reviewType 加载实际数据 + // 初始返回空数据,将在客户端根据 sessionStorage 中的 documentTypeIds 加载实际数据 return Response.json({ documents: [], total: 0, @@ -64,6 +64,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { pageSize, documentTypeOptions, userInfo, // 传递用户信息到客户端 + frontendJWT, // 传递 JWT 到客户端 initialLoad: true // 标记这是初始加载 }); }; @@ -295,16 +296,22 @@ export default function DocumentsIndex() { throw new Error(documentsResponse.error); } + // 🔑 从 sessionStorage 读取文档类型 IDs + const typeIdsStr = sessionStorage.getItem('documentTypeIds'); + const documentTypeIds = typeIdsStr ? JSON.parse(typeIdsStr) : undefined; + // 获取经过过滤的文档类型列表(token 由 axios 拦截器自动获取) const filteredTypesResponse = await getDocumentTypes({ pageSize: 500, - reviewType: storedReviewType || undefined + documentTypeIds: documentTypeIds // 使用动态的文档类型 IDs }); const filteredDocumentTypes = filteredTypesResponse.data?.types || []; const filteredOptions = filteredDocumentTypes.map(type => ({ value: type.id, label: type.name })); + + // console.log('文档列表',documentsResponse) // 更新状态 setDocuments(documentsResponse.data?.documents || []); @@ -824,7 +831,8 @@ export default function DocumentsIndex() { attachmentFiles, attachmentMergeMode, true, // isReprocess - attachmentRemark || undefined + attachmentRemark || undefined, + loaderData.frontendJWT ); if (result.error) { @@ -1153,7 +1161,7 @@ export default function DocumentsIndex() { {record.historyCount !== undefined && record.historyCount > 0 ? - v{record.historyCount + 1} {record.historyCount !== undefined && `(共${record.historyCount}个历史版本)`} + v{record.historyCount + 1} {record.historyCount !== undefined && `(有${record.historyCount}个历史版本)`} : "" } diff --git a/app/routes/files.upload.tsx b/app/routes/files.upload.tsx index f1363f1..18a7eaf 100644 --- a/app/routes/files.upload.tsx +++ b/app/routes/files.upload.tsx @@ -259,11 +259,11 @@ export async function loader({ request }: LoaderFunctionArgs) { const url = new URL(request.url); const mode = url.searchParams.get("mode") || "create"; - // 我们不能在服务器端访问 sessionStorage,所以在客户端组件中处理 reviewType 过滤 + // 我们不能在服务器端访问 sessionStorage,所以在客户端组件中处理 documentTypeIds 过滤 // 并行加载文档和文档类型 const [documentsResponse, typesResponse] = await Promise.all([ - getTodayDocuments(userInfo, undefined, frontendJWT), - getDocumentTypes(undefined, frontendJWT) + getTodayDocuments(userInfo, frontendJWT), + getDocumentTypes(frontendJWT) ]); // console.log('loader: 文档加载结果:', documentsResponse); @@ -308,9 +308,9 @@ export default function FilesUpload() { const [isNavigating, setIsNavigating] = useState(false) const revalidator = useRevalidator() - // 获取 sessionStorage 中的 reviewType 值 + // 获取 sessionStorage 中的 documentTypeIds 值 // eslint-disable-next-line @typescript-eslint/no-unused-vars - const [reviewType, setReviewType] = useState(null); + const [documentTypeIds, setDocumentTypeIds] = useState(null); // 使用 useLoaderData 获取初始数据 const loaderData = useLoaderData(); @@ -361,19 +361,20 @@ export default function FilesUpload() { const [queueFiles, setQueueFiles] = useState([]); const [documentTypesState, setDocumentTypesState] = useState([]); - // 在组件挂载时从 sessionStorage 获取 reviewType + // 在组件挂载时从 sessionStorage 获取 documentTypeIds useEffect(() => { try { // 在客户端环境中执行 if (typeof window !== 'undefined') { - const storedReviewType = sessionStorage.getItem('reviewType'); - setReviewType(storedReviewType); - // 根据 reviewType 过滤文档类型和文档列表 - filterDocumentTypes(storedReviewType, loaderData.documentTypes); - filterDocuments(storedReviewType); - - // 如果reviewType是contract,自动选择合同文档类型 - if (storedReviewType === 'contract') { + const typeIdsStr = sessionStorage.getItem('documentTypeIds'); + const typeIds = typeIdsStr ? JSON.parse(typeIdsStr) : null; + setDocumentTypeIds(typeIds); + // 根据 documentTypeIds 过滤文档类型和文档列表 + filterDocumentTypes(typeIds, loaderData.documentTypes); + filterDocuments(typeIds); + + // 如果包含合同类型(ID=1),自动选择合同文档类型 + if (typeIds && typeIds.includes(1)) { setIsContractType(true); // 查找ID为1的合同文档类型 const contractType = loaderData.documentTypes.find(type => type.id === 1); @@ -385,49 +386,39 @@ export default function FilesUpload() { } } } catch (error) { - console.error('获取 sessionStorage 中的 reviewType 失败:', error); + console.error('获取 sessionStorage 中的 documentTypeIds 失败:', error); } }, [loaderData]); // 过滤文档类型列表 - const filterDocumentTypes = (reviewType: string | null, types: DocumentType[]) => { - if (!reviewType) { - // 如果没有特定的 reviewType,使用原始数据 + const filterDocumentTypes = (documentTypeIds: number[] | null, types: DocumentType[]) => { + if (!documentTypeIds || documentTypeIds.length === 0) { + // 如果没有特定的 documentTypeIds,使用原始数据 setDocumentTypesState(types); return; } - - let filteredTypes: DocumentType[] = []; - - if (reviewType === 'contract') { - // 只保留 id=1 的选项 - filteredTypes = types.filter(type => type.id === 1); - } else if (reviewType === 'record') { - // 只保留 id=2 和 id=3 的选项 - filteredTypes = types.filter(type => type.id === 2 || type.id === 3 || type.id === 155); - } else { - // 如果reviewType不匹配任何条件,使用原始数据 - filteredTypes = types; - } - + + // 根据 documentTypeIds 过滤文档类型 + const filteredTypes = types.filter(type => documentTypeIds.includes(type.id)); + setDocumentTypesState(filteredTypes); }; // 过滤文档列表 - const filterDocuments = async (reviewType: string | null) => { - if (!reviewType) { - // 如果没有特定的 reviewType,使用原始数据 + const filterDocuments = async (documentTypeIds: number[] | null) => { + if (!documentTypeIds || documentTypeIds.length === 0) { + // 如果没有特定的 documentTypeIds,使用原始数据 const documents = loaderData.documents; setQueueFiles(documents); - + // 启动状态检查定时器 startStatusChecker(documents); return; } - + try { - // 使用 reviewType 获取过滤后的文档列表 - const response = await getTodayDocuments(loaderData.userInfo || undefined, reviewType, loaderData.frontendJWT || undefined); + // 使用 documentTypeIds 获取过滤后的文档列表 + const response = await getTodayDocuments(loaderData.userInfo || undefined, loaderData.frontendJWT || undefined, documentTypeIds); if (response.error) { console.error('过滤文档列表失败:', response.error); @@ -559,45 +550,48 @@ export default function FilesUpload() { const checkQueueStatusWithFiles = async (files: Document[]) => { try { // console.log('开始检查队列状态,当前队列文件:', files); - - // 直接从sessionStorage读取reviewType,避免异步状态更新问题 - const currentReviewType = typeof window !== 'undefined' ? sessionStorage.getItem('reviewType') : null; - // console.log('从sessionStorage读取的reviewType:', currentReviewType); - + + // 直接从sessionStorage读取documentTypeIds,避免异步状态更新问题 + const typeIdsStr = typeof window !== 'undefined' ? sessionStorage.getItem('documentTypeIds') : null; + const currentDocumentTypeIds = typeIdsStr ? JSON.parse(typeIdsStr) : null; + // console.log('从sessionStorage读取的documentTypeIds:', currentDocumentTypeIds); + // 获取所有未完成的文档 - const incompleteFiles = files.filter(file => + const incompleteFiles = files.filter(file => file.status !== DocumentStatus.PROCESSED && file.id ); - + if (incompleteFiles.length === 0) { console.log('没有未完成的文档,跳过状态检查'); return; } - + let statusResponse; - - // 如果是合同类型,需要分类处理 - console.log('当前reviewType:', currentReviewType); - if (currentReviewType === 'contract') { - // 分类文档ID - const mainDocumentIds: number[] = []; - const attachmentIds: number[] = []; + + // 如果是合同类型(ID=1),需要分类处理 + // console.log('当前documentTypeIds:', currentDocumentTypeIds); + // if (currentDocumentTypeIds && currentDocumentTypeIds.includes(1)) { + // // 分类文档ID + // const mainDocumentIds: number[] = []; + // const attachmentIds: number[] = []; - incompleteFiles.forEach(file => { - // 检查是否存在template_contract_path属性来判断是否为合同附件 - if ('template_contract_path' in file && file.template_contract_path) { - attachmentIds.push(file.id); - } else { - mainDocumentIds.push(file.id); - } - }); + // incompleteFiles.forEach(file => { + // // 检查是否存在template_contract_path属性来判断是否为合同附件 + // if ('template_contract_path' in file && file.template_contract_path) { + // attachmentIds.push(file.id); + // } else { + // mainDocumentIds.push(file.id); + // } + // }); - // console.log('合同主文件ID:', mainDocumentIds); - // console.log('合同附件ID:', attachmentIds); + // // console.log('合同主文件ID:', mainDocumentIds); + // // console.log('合同附件ID:', attachmentIds); - // 分别查询状态 - statusResponse = await getDocumentsStatus(mainDocumentIds, attachmentIds, loaderData.frontendJWT || undefined); - } else { + // // 分别查询状态 + // statusResponse = await getDocumentsStatus(mainDocumentIds, attachmentIds, loaderData.frontendJWT || undefined); + // } + // else + { // 非合同类型,使用原有逻辑 const incompleteIds = incompleteFiles.map(file => file.id); // console.log('未完成的文档ID:', incompleteIds); @@ -959,9 +953,9 @@ export default function FilesUpload() { setAttachmentRemark(""); setShowAttachmentUpload(false); setSelectedDocumentId(null); - + // 刷新文档列表 - await filterDocuments(reviewType); + await filterDocuments(documentTypeIds); } catch (error) { console.error('【附件追加】上传失败:', error); @@ -1028,9 +1022,9 @@ export default function FilesUpload() { setTemplateFile(null); setShowTemplateUpload(false); setSelectedDocumentId(null); - + // 刷新文档列表 - await filterDocuments(reviewType); + await filterDocuments(documentTypeIds); } catch (error) { console.error('【合同模板上传】上传失败:', error); @@ -1163,7 +1157,7 @@ export default function FilesUpload() { setCompletedFiles(uploadedFiles); startProcessing(uploadedFiles); // 刷新队列 - await filterDocuments(reviewType); + await filterDocuments(documentTypeIds); } catch (error) { console.error('合同首传上传失败:', error); messageService.error(`合同上传失败:${error instanceof Error ? error.message : '未知错误'}`); diff --git a/app/routes/login.tsx b/app/routes/login.tsx index 6df8c06..33a9f83 100644 --- a/app/routes/login.tsx +++ b/app/routes/login.tsx @@ -24,21 +24,41 @@ export async function loader({ request }: LoaderFunctionArgs) { // ⚠️ 不再检查服务端 session 认证 // 认证检查改为在客户端通过 localStorage 进行 - // 获取重定向URL + // 获取重定向URL和错误参数 const url = new URL(request.url); const redirectTo = url.searchParams.get("redirect") || "/"; + const urlError = url.searchParams.get("error"); const session = await getSession(request); // 读取 flash 消息(来自 callback 的错误) const loginError = session.get("loginError"); + // 将URL错误参数转换为友好的错误消息 + let urlErrorMessage: string | null = null; + if (urlError) { + switch (urlError) { + case 'no_role': + urlErrorMessage = '用户角色信息缺失,请重新登录'; + break; + case 'no_token': + urlErrorMessage = '认证令牌缺失,请重新登录'; + break; + case 'session_expired': + urlErrorMessage = '会话已过期,请重新登录'; + break; + default: + urlErrorMessage = '登录状态异常,请重新登录'; + } + } + // 提交 session 以清除 flash 消息 if (loginError) { const { sessionStorage } = await import("~/api/login/auth.server"); return Response.json({ redirectTo, - flashError: loginError + flashError: loginError, + urlError: urlErrorMessage }, { headers: { "Set-Cookie": await sessionStorage.commitSession(session) @@ -48,7 +68,8 @@ export async function loader({ request }: LoaderFunctionArgs) { return Response.json({ redirectTo, - flashError: null + flashError: null, + urlError: urlErrorMessage }); } @@ -107,6 +128,11 @@ export async function action({ request }: ActionFunctionArgs) { }, { status: 500 }); } + // 测试,管理员账密返回的时候默认给没有area信息 + // if(!user_info.area){ + // user_info.area = '梅州' + // } + // 🔑 将后端返回的 issued_time 转换为时间戳(毫秒) let tokenIssuedAt = Date.now(); // 默认使用当前时间 if (issued_time) { @@ -141,6 +167,7 @@ export async function action({ request }: ActionFunctionArgs) { ou_name: user_info.ou_name, is_leader: user_info.is_leader, user_role: user_info.user_role, + area: user_info.area, // 🔑 用户所属地区 sub: user_info.sub }))); callbackUrl.searchParams.set('redirectTo', redirectTo); @@ -163,6 +190,7 @@ export async function action({ request }: ActionFunctionArgs) { ou_name: user_info.ou_name, is_leader: user_info.is_leader, user_role: user_info.user_role, + area: user_info.area, // 🔑 用户所属地区 sub: user_info.sub } }); @@ -185,11 +213,12 @@ export default function Login() { const [password, setPassword] = useState(""); const [passwordLoginError, setPasswordLoginError] = useState(null); - // 从 loaderData 中获取 OAuth 回调的错误信息 + // 从 loaderData 中获取错误信息 const oauthError = loaderData?.flashError; + const urlError = loaderData?.urlError; - // 显示的错误信息:密码登录错误优先,其次是 OAuth 错误 - const error = passwordLoginError || oauthError; + // 显示的错误信息:密码登录错误优先,其次是 URL 错误,最后是 OAuth 错误 + const error = passwordLoginError || urlError || oauthError; const isLocked = false; // 可以从后端响应中获取 const retryCount = 0; const remainingAttempts = 5; @@ -283,6 +312,14 @@ export default function Login() { } }, [fetcher.data]); + // 显示URL错误参数的Toast提示 + useEffect(() => { + if (urlError) { + console.warn("⚠️ [Login] 检测到URL错误参数:", urlError); + toastService.error(urlError); + } + }, [urlError]); + useEffect(() => { // 🔑 只在 token 过期时清理客户端存储 // 检查 URL 参数中是否有 expired=true 标识 diff --git a/app/routes/rules-files.tsx b/app/routes/rules-files.tsx deleted file mode 100644 index 5930db7..0000000 --- a/app/routes/rules-files.tsx +++ /dev/null @@ -1,985 +0,0 @@ -import { type MetaFunction, type LoaderFunctionArgs } from "@remix-run/node"; -import { useLoaderData, useSearchParams, useNavigate } from "@remix-run/react"; -import { useEffect, useState, useCallback } from "react"; -import { Button } from "~/components/ui/Button"; -import { Card } from "~/components/ui/Card"; -import { FileIcon } from "~/components/ui/FileIcon"; -import { FilterPanel, FilterSelect, SearchFilter, DateRangeFilter } from "~/components/ui/FilterPanel"; -import { Pagination } from "~/components/ui/Pagination"; -import { Table } from "~/components/ui/Table"; -import { StatusBadge } from "~/components/ui/StatusBadge"; -import { FileTypeTag, links as fileTypeTagLinks } from "~/components/ui/FileTypeTag"; -import { NumberSkeleton, TableRowSkeleton, LoadingIndicator } from "~/components/ui/SkeletonScreen"; -import rulesFilesStyles from "~/styles/pages/rules-files.css?url"; -import { - getReviewFiles, - type ReviewFileUI, - updateDocumentAuditStatus, - type DocumentSearchParams -} from "~/api/evaluation_points/rules-files"; -import { getDocumentTypes } from "~/api/document-types/document-types"; -import { toastService } from "~/components/ui/Toast"; -// 导入axios下载文件方法 -import { downloadFile } from "~/api/axios-client"; -import { appendContractAttachments } from "~/api/files/files-upload"; -import { messageService } from "~/components/ui/MessageModal"; - -export const links = () => [ - { rel: "stylesheet", href: rulesFilesStyles }, - ...fileTypeTagLinks() -]; - -export const handle = { - breadcrumb: "评查文件列表" -}; - -export const meta: MetaFunction = () => { - return [ - { title: "评查文件列表 - 中国烟草AI合同及卷宗审核系统" }, - { name: "description", content: "管理系统中所有上传的评查文件,支持按文件类型、评查状态进行筛选" }, - { name: "keywords", content: "评查文件,合同审核,中国烟草,文件管理" } - ]; -}; - -// 日期范围枚举 -export enum DateRange { - ALL = 'all', - TODAY = 'today', - WEEK = 'week', - MONTH = 'month', - CUSTOM = 'custom' -} - -// 评查状态标签映射 -export const REVIEW_STATUS_LABELS: Record = { - 'pass': '通过', - 'warning': '警告', - 'fail': '不通过', - 'pending': '待人工确认' -}; - -// 加载评查文件列表 -export async function loader({ request }: LoaderFunctionArgs) { - // 获取用户会话信息 - const { getUserSession } = await import("~/api/login/auth.server"); - const { userInfo, frontendJWT } = await getUserSession(request); - - // 获取分页参数 - const url = new URL(request.url); - const currentPage = parseInt(url.searchParams.get("page") || "1", 10); - const pageSize = parseInt(url.searchParams.get("pageSize") || "10", 10); - - try { - // 获取文档类型列表(服务端需要显式传递 token,客户端依赖 axios 拦截器) - const typesResponse = await getDocumentTypes({pageSize:500}, frontendJWT); - const documentTypes = typesResponse.data?.types || []; - - // 返回初始空数据,客户端将根据 sessionStorage 中的 reviewType 加载实际数据 - return Response.json({ - files: [], - documentTypes, - totalCount: 0, - currentPage, - pageSize, - userInfo, // 传递用户信息到客户端 - initialLoad: true - }); - } catch (error) { - console.error('加载评查文件列表失败:', error); - return Response.json({ result: false, message: error instanceof Error ? error.message : '加载评查文件列表失败' }, { status: 500 }); - } -} - -export default function RulesFiles() { - const navigate = useNavigate(); - const { files: initialFiles, documentTypes: allDocumentTypes, totalCount: initialTotal, currentPage, pageSize, userInfo, result, message } = useLoaderData(); - const [searchParams, setSearchParams] = useSearchParams(); - const dateFrom = searchParams.get('dateFrom') || ''; - const dateTo = searchParams.get('dateTo') || ''; - - // 添加状态管理 - const [files, setFiles] = useState(initialFiles); - const [documentTypes, setDocumentTypes] = useState(allDocumentTypes); - const [totalCount, setTotalCount] = useState(initialTotal); - const [isLoading, setIsLoading] = useState(true); - const [reviewType, setReviewType] = useState(null); - - // 附件追加相关状态 - const [showAttachmentUpload, setShowAttachmentUpload] = useState(false); - const [selectedDocumentId, setSelectedDocumentId] = useState(null); - const [attachmentFiles, setAttachmentFiles] = useState([]); - const [attachmentMergeMode, setAttachmentMergeMode] = useState<'overwrite' | 'new'>('overwrite'); - const [attachmentRemark, setAttachmentRemark] = useState(""); - const [attachmentUploading, setAttachmentUploading] = useState(false); - - // 保存/恢复 查询参数 的 sessionStorage key - const SEARCH_PARAMS_STORAGE_KEY = 'rulesFiles.searchParams'; - - const persistSearchParams = useCallback((params: URLSearchParams) => { - if (typeof window !== 'undefined') { - sessionStorage.setItem(SEARCH_PARAMS_STORAGE_KEY, params.toString()); - } - }, []); - - // 首次进入列表页且 URL 无查询参数时,尝试恢复上次保存的参数 - useEffect(() => { - if (typeof window === 'undefined') return; - const hasAnyParam = Array.from(searchParams.keys()).length > 0; - const stored = sessionStorage.getItem(SEARCH_PARAMS_STORAGE_KEY); - if (!hasAnyParam && stored) { - setSearchParams(new URLSearchParams(stored)); - } - // 仅在初始渲染时检查 - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - // 处理初始加载数据loader的错误 - useEffect(() => { - if(result === false && message) { - toastService.error(message); - } - }, [result, message]); - - // 辅助函数:从 localStorage 获取用户ID - const getUserId = useCallback((): string | undefined => { - if (typeof window === 'undefined') return undefined; - - const userInfoStr = localStorage.getItem('user_info'); - if (!userInfoStr) return undefined; - - try { - const userInfoData = JSON.parse(userInfoStr); - return userInfoData.user_id?.toString(); - } catch (error) { - console.error('解析 localStorage 用户信息失败:', error); - return undefined; - } - }, []); - - // 客户端数据请求 - const fetchData = useCallback(async (params: Record) => { - setIsLoading(true); - - try { - // 构建搜索参数(token 由 axios 拦截器自动从 localStorage 获取) - const searchParams: DocumentSearchParams = { - fileType: params.fileType || undefined, - reviewStatus: params.reviewStatus || undefined, - dateFrom: params.dateFrom || undefined, - dateTo: params.dateTo || undefined, - keyword: params.keyword || undefined, - sortOrder: params.sortOrder || 'upload_time_desc', - page: parseInt(params.page || "1", 10), - pageSize: parseInt(params.pageSize || "10", 10) - }; - - // 根据 reviewType 添加类型过滤 - if (reviewType === 'contract') { - searchParams.fileType = 'contract'; - } else if (reviewType === 'record') { - // 在 API 层处理 type_id 为 2 或 3 的过滤 - searchParams.fileType = 'record'; - } - - // 如果用户手动选择了文件类型,优先使用用户选择的 - if (params.fileType) { - searchParams.fileType = params.fileType; - } - - // 从 localStorage 获取用户ID(与 token 管理保持一致) - const userId = getUserId(); - if (!userId) { - throw new Error('用户身份验证失败,无法获取评查文件列表'); - } - - // 获取文件列表(token 由 axios 拦截器自动添加) - const filesResponse = await getReviewFiles(searchParams, null, userId); - if (filesResponse.error) { - throw new Error(filesResponse.error); - } - - setFiles(filesResponse.data?.files || []); - setTotalCount(filesResponse.data?.total || 0); - } catch (error) { - console.error('获取评查文件列表失败:', error); - toastService.error('获取评查文件列表失败: ' + (error instanceof Error ? error.message : '未知错误')); - } finally { - setIsLoading(false); - } - }, [reviewType, getUserId]); // 使用 getUserId 辅助函数 - - // 在组件挂载时从 sessionStorage 获取 reviewType 并加载数据 - useEffect(() => { - try { - if (typeof window !== 'undefined') { - const storedReviewType = sessionStorage.getItem('reviewType'); - - // 根据 reviewType 过滤文档类型选项 - if (storedReviewType) { - setReviewType(storedReviewType); - - if (storedReviewType === 'contract') { - // 只保留 id=1 的选项 - const filteredTypes = allDocumentTypes.filter((type: {id: number}) => type.id === 1); - setDocumentTypes(filteredTypes); - } else if (storedReviewType === 'record') { - // 只保留 id=2 和 id=3 的选项 - const filteredTypes = allDocumentTypes.filter((type: {id: number}) => type.id === 2 || type.id === 3 || type.id === 155); - setDocumentTypes(filteredTypes); - } - - // 直接使用 storedReviewType 构建搜索参数 - const currentParams = Object.fromEntries(searchParams.entries()); - const apiSearchParams: DocumentSearchParams = { - fileType: currentParams.fileType || undefined, - reviewStatus: currentParams.reviewStatus || undefined, - dateFrom: currentParams.dateFrom || undefined, - dateTo: currentParams.dateTo || undefined, - keyword: currentParams.keyword || undefined, - sortOrder: currentParams.sortOrder || 'upload_time_desc', - page: parseInt(currentParams.page || "1", 10), - pageSize: parseInt(currentParams.pageSize || "10", 10) - }; - - // 根据 storedReviewType 添加类型过滤 - if (storedReviewType === 'contract') { - apiSearchParams.fileType = '1'; - } else if (storedReviewType === 'record') { - apiSearchParams.fileType = 'record'; - } - - // 如果用户手动选择了文件类型,优先使用用户选择的 - if (currentParams.fileType) { - apiSearchParams.fileType = currentParams.fileType; - } - - // 设置加载状态 - setIsLoading(true); - - // 从 localStorage 获取用户ID - const userId = getUserId(); - if (!userId) { - toastService.error('用户身份验证失败,无法获取评查文件列表'); - setIsLoading(false); - return; - } - - // 获取文件列表(token 由 axios 拦截器自动添加) - getReviewFiles(apiSearchParams, null, userId) - .then(filesResponse => { - if (filesResponse.error) { - throw new Error(filesResponse.error); - } - - setFiles(filesResponse.data?.files || []); - setTotalCount(filesResponse.data?.total || 0); - }) - .catch(error => { - console.error('获取评查文件列表失败:', error); - toastService.error('获取评查文件列表失败: ' + (error instanceof Error ? error.message : '未知错误')); - }) - .finally(() => { - setIsLoading(false); - }); - } - } - } catch (error) { - console.error('获取 sessionStorage 中的 reviewType 失败:', error); - } - }, [allDocumentTypes, searchParams]); - - // 监听 URL 参数变化,重新获取数据 - useEffect(() => { - if (reviewType) { - fetchData(Object.fromEntries(searchParams.entries())); - } - }, [searchParams, fetchData, reviewType]); - - // 处理筛选条件变更 - const handleFilterChange = (e: React.ChangeEvent) => { - const { name, value } = e.target; - const newParams = new URLSearchParams(searchParams); - - if (value) { - newParams.set(name, value); - } else { - newParams.delete(name); - } - - // 切换筛选条件时,重置到第一页 - newParams.set('page', '1'); - - persistSearchParams(newParams); - setSearchParams(newParams); - }; - - // 处理搜索操作 - const handleSearch = (keyword: string) => { - const newParams = new URLSearchParams(searchParams); - if (keyword) { - newParams.set('keyword', keyword); - } else { - newParams.delete('keyword'); - } - - // 搜索时,重置到第一页 - newParams.set('page', '1'); - - persistSearchParams(newParams); - setSearchParams(newParams); - }; - - // 处理页码变更 - const handlePageChange = (page: number) => { - const newParams = new URLSearchParams(searchParams); - newParams.set('page', page.toString()); - persistSearchParams(newParams); - setSearchParams(newParams); - }; - - // 处理每页条数变更 - const handlePageSizeChange = (size: number) => { - const newParams = new URLSearchParams(searchParams); - newParams.set('pageSize', size.toString()); - newParams.set('page', '1'); // 改变每页条数时重置为第一页 - persistSearchParams(newParams); - setSearchParams(newParams); - }; - - // 查看评查文件 - const handleReviewFileClick = async (fileId: string, auditStatus: number | null) => { - // 检查audit_status是否为0,如果是则更新为2 - if (auditStatus === 0 || auditStatus === null) { - try { - // 从 localStorage 获取用户ID - const userId = getUserId(); - if (!userId) { - toastService.error('用户身份验证失败'); - return; - } - - // token 由 axios 拦截器自动添加 - const response = await updateDocumentAuditStatus(fileId, 2, userId); - if (response.error) { - throw new Error(response.error); - } - } catch (error) { - console.error('更新文件审核状态时出错:', error); - toastService.error(`更新文件审核状态时出错:${error instanceof Error ? error.message : '未知错误'}`); - return; - } - } - - // 导航到评查详情页 - // 在离开当前页前保存当前查询参数,返回时可恢复 - if (typeof window !== 'undefined') { - sessionStorage.setItem(SEARCH_PARAMS_STORAGE_KEY, searchParams.toString()); - } - navigate(`/reviews?id=${fileId}&previousRoute=rulesFiles`); - }; - - // 渲染问题摘要 - const renderIssues = (file: ReviewFileUI) => { - // 如果文件状态为完成 - if (file.status === 'Processed') { - // 如果没有问题,显示"所有评查点均通过" - if (file.warningCount <= 0 && file.failCount <= 0) { - return ( -
- 所有评查点均通过 -
- ); - } - - // 如果评查状态为不通过,显示"统计分数为:{file.score || 0}。分数低于80分。" - // if (file.reviewStatus === 'fail') { - // return ( - //
- // 统计分数为:{file.score || 0}。分数低于80分。 - //
- // ); - // } - - // 显示问题列表 - if (file.issues && file.issues.length > 0) { - // 最多显示2个问题 - const displayIssues = file.issues.slice(0, 2); - - return ( -
- {displayIssues.map((issue, index) => ( -
- - {issue.message} -
- ))} - - {file.issues.length > 2 && ( -
- 还有 {file.issues.length - 2} 个问题... -
- )} -
- ); - } - } - // 其他状态显示占位符 - return
-
; - }; - - - // 下载文件 - const handleDownload = async (path: string) => { - try { - // 使用axios封装的下载方法 - const blob = await downloadFile(path); - - // 创建Blob URL - const blobUrl = URL.createObjectURL(blob); - - // 创建一个隐藏的a标签并点击它 - const a = document.createElement('a'); - a.style.display = 'none'; - a.href = blobUrl; - // 从路径中获取文件名 - const fileName = path.split('/').pop() || 'document'; - a.download = decodeURIComponent(fileName); - document.body.appendChild(a); - a.click(); - - // 清理 - setTimeout(() => { - document.body.removeChild(a); - URL.revokeObjectURL(blobUrl); - }, 100); - } catch (error) { - console.error('下载文件失败:', error); - toastService.error(`下载文件失败: ${error instanceof Error ? error.message : '未知错误'}`); - } - }; - - // 处理时间范围变更 - const handleDateChange = (field: 'dateFrom' | 'dateTo', value: string) => { - const newParams = new URLSearchParams(searchParams); - if(value) { - newParams.set(field, value); - } else { - newParams.delete(field); - } - newParams.set('page', '1'); - setSearchParams(newParams); - }; - - const handleReset = () => { - const newParams = new URLSearchParams(); - const searchInput = document.querySelector('input[name="keyword"]'); - if(searchInput) { - (searchInput as HTMLInputElement).value = ''; - } - if (typeof window !== 'undefined') { - sessionStorage.removeItem(SEARCH_PARAMS_STORAGE_KEY); - } - setSearchParams(newParams); - }; - - // 辅助:格式化文件大小 - const formatFileSize = (bytes: number) => { - if (bytes === 0) return "0 Bytes"; - const k = 1024; - const sizes = ["Bytes", "KB", "MB", "GB", "TB"]; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]; - }; - - // 选择附件文件 - const handleAttachmentFilesSelected = (files: FileList) => { - try { - if (files.length > 0) { - const validFiles: File[] = []; - let hasInvalidFiles = false; - Array.from(files).forEach(file => { - const fileName = file.name.toLowerCase(); - const isValidType = - file.type === 'application/pdf' || fileName.endsWith('.pdf') || - file.type === 'application/msword' || fileName.endsWith('.doc') || - file.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' || fileName.endsWith('.docx') || - file.type === 'application/zip' || fileName.endsWith('.zip') || - file.type === 'application/x-rar-compressed' || fileName.endsWith('.rar'); - if (isValidType) { - validFiles.push(file); - } else { - hasInvalidFiles = true; - } - }); - if (hasInvalidFiles) { - messageService.error('只支持PDF、Word、ZIP、RAR格式的文件', { - title: '文件类型错误', - confirmText: '确定', - cancelText: '', - }); - } - if (validFiles.length > 0) { - setAttachmentFiles(validFiles); - } - } - } catch (error) { - console.error('【附件追加】处理文件选择时发生错误:', error); - } - }; - - // 执行附件追加 - const handleAttachmentUpload = async () => { - if (!selectedDocumentId || attachmentFiles.length === 0) { - toastService.error('请选择文档和附件文件'); - return; - } - try { - setAttachmentUploading(true); - const docId = parseInt(selectedDocumentId, 10); - const result = await appendContractAttachments( - docId, - attachmentFiles, - attachmentMergeMode, - true, - attachmentRemark || undefined - ); - if (result.error) { - throw new Error(result.error); - } - toastService.success('附件追加成功!'); - // 重置并关闭 - setAttachmentFiles([]); - setAttachmentRemark(""); - setShowAttachmentUpload(false); - setSelectedDocumentId(null); - // 刷新列表 - fetchData(Object.fromEntries(searchParams.entries())); - } catch (error) { - console.error('【附件追加】上传失败:', error); - toastService.error(error instanceof Error ? error.message : '附件追加失败'); - } finally { - setAttachmentUploading(false); - } - }; - - // 文件类型选项 - const fileTypeOptions = documentTypes.map((type: {id: number, name: string}) => ({ - value: type.id.toString(), - label: type.name - })); - - // 定义表格列配置 - const columns = [ - { - title: "文件名称", - key: "fileName", - width: "30%", - render: (_: unknown, file: ReviewFileUI) => ( -
-
- -
-
-
{file.fileName}
-
- 文件编号:{file.fileCode} -
-
-
- ) - }, - { - title: "文件类型", - key: "fileType", - width: "12%", - render: (_: unknown, file: ReviewFileUI) => ( - - ) - }, - { - title: "上传时间", - key: "uploadTime", - width: "12%", - render: (_: unknown, file: ReviewFileUI) => { - const [date, time] = file.uploadTime.split(' '); - return ( -
- {date} -
- {time} -
- ); - } - }, - { - title: "评查统计", - key: "reviewStatus", - width: "12%", - render: (_: unknown, file: ReviewFileUI) => - // 要文件切分处理完之后,再显示评查统计 - file.status === 'Processed' ? ( -
- {file.passCount > 0 && ( - - )} - {file.warningCount > 0 && ( - - )} - {file.failCount > 0 && ( - - )} - {file.manualCount > 0 && ( - - )} -
- - ) : ( -
- - -
- ) - }, - { - title: "问题摘要", - key: "issues", - width: "20%", - render: (_: unknown, file: ReviewFileUI) => renderIssues(file) - }, - { - title: "操作", - key: "operation", - width: "20%", - render: (_: unknown, file: ReviewFileUI) => ( -
- - {file.fileTypeId === 1 && file.status === 'Processed' && ( - - )} - -
- ) - } - ]; - - return ( -
- {/* 页面头部 */} -
-
-

评查文件列表

-
- - 总文件数: - {isLoading ? ( - - ) : ( - {totalCount} - )} -
-
- -
- - {/* 筛选区域 */} - - - - } - > - - - {/* */} - - {/* */} - - handleDateChange('dateFrom', value)} - onEndDateChange={(value) => handleDateChange('dateTo', value)} - simple={true} - colorMode="light" - /> - - - - - - {/* 文件列表 */} - -
- {isLoading && } - - {isLoading && files.length === 0 ? ( - - ) : ( - - )} - - {/* 分页组件 */} - {totalCount > 0 && ( - - )} - - {/* 附件追加模态框 */} - {showAttachmentUpload && ( -
-
-
-

追加合同附件

- -
- -
-
-

- 目标文档ID: {selectedDocumentId} -

-

- 支持PDF、Word、ZIP、RAR格式,ZIP/RAR内仅合并其中的PDF文件 -

-
- -
- -
- e.target.files && handleAttachmentFilesSelected(e.target.files)} - className="hidden" - id="attachment-file-input" - /> - -
- {attachmentFiles.length > 0 && ( -
-

- 已选择 {attachmentFiles.length} 个文件 -

-
- {attachmentFiles.map((file, index) => ( -
- - {file.name} ({formatFileSize(file.size)}) -
- ))} -
-
- )} -
- -
- -
- - -
-
- -
- -