diff --git a/.gitignore b/.gitignore index 983c8be..0c300d9 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,7 @@ docreview-frontend-deploy.tar.gz # Claude Code local settings .claude/ + +.doc/ +.database/ +.auth_doc/ diff --git a/app/api/auth/user-routes.ts b/app/api/auth/user-routes.ts index b564fe8..f450bdc 100644 --- a/app/api/auth/user-routes.ts +++ b/app/api/auth/user-routes.ts @@ -493,11 +493,11 @@ 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}, JWT前20字符: ${jwt?.substring(0, 20)}`); if (!jwt) { console.error('❌ [User Routes] JWT token 未提供'); - toastService.error("认证信息缺失,请重新登录"); + // 不显示 toast,让 root loader 处理重定向 return { success: false, error: "JWT token 未提供", shouldRedirectToHome: true }; } @@ -519,15 +519,34 @@ export async function getUserRoutesByRole(roleKey: string, jwt?: string, include // 检查响应是否成功 if (response.error) { console.error('❌ [User Routes] API 请求失败:', response.error); - toastService.error(response.error); - return { success: false, error: response.error, shouldRedirectToHome: true }; + // 🔑 如果是令牌过期错误,标记需要重定向到登录页 + const isTokenExpired = response.error.includes('令牌已过期') || + response.error.includes('令牌') || + response.error.includes('token') || + response.error.includes('expired') || + response.error.includes('认证') || + response.error.includes('401'); + + console.log('🔍 [User Routes] 错误检测:', { + error: response.error, + isTokenExpired, + willRedirect: isTokenExpired + }); + + // 只在客户端显示toast(服务端调用时跳过) + if (!isTokenExpired && typeof window !== 'undefined') { + toastService.error(response.error); + } + return { success: false, error: response.error, shouldRedirectToHome: isTokenExpired }; } // 检查响应数据 if (!response.data) { console.error('❌ [User Routes] 后端未返回数据'); - toastService.error("获取路由数据失败"); - return { success: false, error: "后端未返回数据", shouldRedirectToHome: true }; + if (typeof window !== 'undefined') { + toastService.error("获取路由数据失败"); + } + return { success: false, error: "后端未返回数据", shouldRedirectToHome: false }; } const backendResponse = response.data; @@ -535,23 +554,45 @@ export async function getUserRoutesByRole(roleKey: string, jwt?: string, include // 检查业务状态码(后端使用 code: 0 表示成功) if (backendResponse.code !== 0 && backendResponse.code !== 200) { console.error(`❌ [User Routes] 后端返回错误: ${backendResponse.msg}`); - toastService.error(backendResponse.msg || "获取路由权限失败"); - return { success: false, error: backendResponse.msg || "获取路由权限失败", shouldRedirectToHome: true }; + // 🔑 如果是令牌过期错误,标记需要重定向到登录页 + const isTokenExpired = backendResponse.msg?.includes('令牌已过期') || + backendResponse.msg?.includes('令牌') || + backendResponse.msg?.includes('token') || + backendResponse.msg?.includes('expired') || + backendResponse.msg?.includes('认证') || + backendResponse.msg?.includes('401'); + + console.log('🔍 [User Routes] 业务错误检测:', { + msg: backendResponse.msg, + code: backendResponse.code, + isTokenExpired, + willRedirect: isTokenExpired + }); + + // 只在客户端显示toast + if (!isTokenExpired && typeof window !== 'undefined') { + toastService.error(backendResponse.msg || "获取路由权限失败"); + } + return { success: false, error: backendResponse.msg || "获取路由权限失败", shouldRedirectToHome: isTokenExpired }; } // 检查数据完整性 if (!backendResponse.data || !Array.isArray(backendResponse.data.routes)) { console.error('❌ [User Routes] 后端未返回路由数据'); - toastService.error("未获取到路由权限,请联系管理员配置"); - return { success: false, error: "后端未返回路由数据", shouldRedirectToHome: true }; + if (typeof window !== 'undefined') { + toastService.error("未获取到路由权限,请联系管理员配置"); + } + return { success: false, error: "后端未返回路由数据", shouldRedirectToHome: false }; } const routes = backendResponse.data.routes; if (routes.length === 0) { console.log(`⚠️ [User Routes] 用户没有分配任何路由权限`); - toastService.error("您的角色没有分配任何路由权限,请联系管理员配置"); - return { success: false, error: "用户没有分配任何路由权限", shouldRedirectToHome: true }; + if (typeof window !== 'undefined') { + toastService.error("您的角色没有分配任何路由权限,请联系管理员配置"); + } + return { success: false, error: "用户没有分配任何路由权限", shouldRedirectToHome: false }; } // console.log('🔍 [User Routes] 后端返回的原始路由数据:', JSON.stringify(routes, null, 2)); @@ -568,11 +609,31 @@ export async function getUserRoutesByRole(roleKey: string, jwt?: string, include } catch (error) { console.error("❌ [User Routes] 获取用户路由时发生错误:", error); - toastService.error("获取用户路由时发生错误,请稍后再试"); + const errorMessage = error instanceof Error ? error.message : String(error); + + // 🔑 如果是认证相关错误,标记需要重定向到登录页 + const isAuthError = errorMessage.includes('令牌') || + errorMessage.includes('token') || + errorMessage.includes('expired') || + errorMessage.includes('认证') || + errorMessage.includes('401') || + errorMessage.includes('403'); + + console.log('🔍 [User Routes] 异常错误检测:', { + errorMessage, + isAuthError, + willRedirect: isAuthError + }); + + // 只在客户端显示toast + if (!isAuthError && typeof window !== 'undefined') { + toastService.error("获取用户路由时发生错误,请稍后再试"); + } + return { success: false, - error: `获取用户路由失败: ${error instanceof Error ? error.message : String(error)}`, - shouldRedirectToHome: true + error: `获取用户路由失败: ${errorMessage}`, + shouldRedirectToHome: isAuthError }; } } diff --git a/app/api/axios-client.ts b/app/api/axios-client.ts index 0aade96..a6c7eda 100644 --- a/app/api/axios-client.ts +++ b/app/api/axios-client.ts @@ -12,7 +12,7 @@ export type ApiResponse = { headers?: Record; }; -export type QueryParams = Record; +export type QueryParams = Record; // 获取 API 基础 URL (从配置文件导入) // const API_BASE_URL = 'http://172.16.0.58:8008'; @@ -52,6 +52,12 @@ const AUTH_WHITELIST = [ '/oauth/userinfo' ]; +// 错误容忍白名单 - 这些接口即使返回 401/403 也不触发强制登出 +const ERROR_TOLERANT_WHITELIST = [ + '/admin/statistics/top-error-points', + '/admin/statistics/top-risk-users' +]; + /** * 检查请求URL是否在白名单中 */ @@ -60,6 +66,14 @@ function isInAuthWhitelist(url?: string): boolean { return AUTH_WHITELIST.some(path => url.includes(path)); } +/** + * 检查请求URL是否在错误容忍白名单中 + */ +function isInErrorTolerantWhitelist(url?: string): boolean { + if (!url) return false; + return ERROR_TOLERANT_WHITELIST.some(path => url.includes(path)); +} + /** * 请求拦截器 - 自动添加 Authorization 头 */ @@ -67,6 +81,7 @@ axiosInstance.interceptors.request.use( (config) => { // 检查是否在白名单中 if (isInAuthWhitelist(config.url)) { + console.log('🔓 [Request Interceptor] URL在白名单中,跳过Authorization:', config.url); return config; } @@ -75,12 +90,24 @@ axiosInstance.interceptors.request.use( const token = localStorage.getItem('access_token'); if (token) { config.headers.Authorization = `Bearer ${token}`; + console.log('🔑 [Request Interceptor] 添加Authorization头:', { + url: config.url, + method: config.method, + hasToken: !!token, + tokenPreview: token.substring(0, 20) + '...' + }); + } else { + console.warn('⚠️ [Request Interceptor] 没有找到access_token:', { + url: config.url, + localStorage: Object.keys(localStorage) + }); } } return config; }, (error) => { + console.error('❌ [Request Interceptor] 请求拦截器错误:', error); return Promise.reject(error); } ); @@ -100,14 +127,34 @@ export class AuthenticationError extends Error { */ axiosInstance.interceptors.response.use( (response) => { + console.log('✅ [Response Interceptor] 请求成功:', { + url: response.config.url, + status: response.status, + statusText: response.statusText + }); return response; }, (error) => { + console.error('❌ [Response Interceptor] 请求失败:', { + url: error.config?.url, + status: error.response?.status, + statusText: error.response?.statusText, + data: error.response?.data + }); + if (isAxiosError(error) && error.response?.status === 401) { + // 检查是否在错误容忍白名单中 + const requestUrl = error.config?.url; + if (isInErrorTolerantWhitelist(requestUrl)) { + console.warn('⚠️ [容错白名单] 接口返回 401,但不触发强制登出:', requestUrl); + // 直接返回错误,不触发登出 + return Promise.reject(error); + } + // Token 过期或无效 console.warn('⚠️ Token 已过期或无效,请重新登录'); console.warn('⚠️ 401 错误详情:', { - url: error.config?.url, + url: requestUrl, status: error.response?.status, statusText: error.response?.statusText, data: error.response?.data, @@ -208,7 +255,12 @@ function buildUrl(endpoint: string, params?: QueryParams): string { if (params) { Object.entries(params).forEach(([key, value]) => { if (value !== undefined) { - url.searchParams.append(key, String(value)); + // 处理数组参数:使用逗号分隔 + if (Array.isArray(value)) { + url.searchParams.append(key, value.join(',')); + } else { + url.searchParams.append(key, String(value)); + } } }); } @@ -292,16 +344,19 @@ export async function apiRequest( try { // 构建 URL const url = buildUrl(endpoint, params); - - // 设置默认请求头 - const headers = options.headers || {}; - if (!headers['Content-Type'] && options.method !== 'GET') { - headers['Content-Type'] = 'application/json'; + + // 只有在 options.headers 存在时才处理,否则让拦截器处理 + let headers = options.headers; + if (headers) { + // 设置默认请求头(仅当 headers 已存在时) + if (!headers['Content-Type'] && options.method !== 'GET') { + headers['Content-Type'] = 'application/json'; + } + if (!headers['Accept']) { + headers['Accept'] = 'application/json'; + } } - if (!headers['Accept']) { - headers['Accept'] = 'application/json'; - } - + // 针对 PostgREST 的额外处理 if (endpoint.includes('evaluation_point_groups') && (options.method === 'POST' || options.method === 'PATCH')) { // console.log('使用 PostgREST 特定配置处理请求'); @@ -315,10 +370,10 @@ export async function apiRequest( } } } - + // console.log(`📦 axios-client.ts->请求URL: ${url}`); // console.log(`axios-client.ts->发送 ${options.method || 'GET'} 请求到: ${url}`); - + // 处理body参数,转换为data if (options.body) { // console.log(`axios-client.ts->请求体: \n${options.body}`); @@ -327,20 +382,25 @@ export async function apiRequest( // console.log(`axios-client.ts->请求体: \n${typeof options.data === 'string' ? options.data : JSON.stringify(options.data)}`); } - // 发送请求 + // 构建请求配置 + // 如果没有传入 headers,就不设置 headers,让拦截器自动添加 const config: AxiosRequestConfig = { ...options, url, - headers, // 确保使用默认超时时间 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)); + // 只有在 headers 存在时才设置 + if (headers) { + config.headers = headers; + + // 🔍 调试:打印 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)}`); @@ -407,10 +467,37 @@ export async function apiRequest( // 检查API返回的状态码 const data = response.data; - if (data && typeof data === 'object' && 'code' in data && data.code !== 0) { - console.error(`API请求失败: ${data.message || data.msg || '未知错误'} - ${url}`); + // 修复:支持code=0(PostgREST)和code=200(RBAC API)两种成功响应 + if (data && typeof data === 'object' && 'code' in data && data.code !== 0 && data.code !== 200) { + const errorMessage = data.message || data.msg || '未知错误'; + console.error(`API请求失败: ${errorMessage} - ${url}`); + + // 🔑 检测令牌过期错误 + const isTokenExpired = errorMessage.includes('令牌已过期') || + errorMessage.includes('令牌') || + errorMessage.includes('token') || + errorMessage.includes('expired') || + errorMessage.includes('认证') || + errorMessage.includes('未授权'); + + if (isTokenExpired) { + console.error('🔑 [API Client] 检测到令牌过期,准备清除会话并重定向...'); + + // 只在客户端执行重定向 + if (typeof window !== 'undefined') { + console.error('🔑 [API Client] 客户端环境,清除 localStorage 并重定向到登录页'); + // 清除所有认证相关数据 + localStorage.removeItem('access_token'); + localStorage.removeItem('user_info'); + sessionStorage.clear(); + + // 重定向到登录页 + window.location.href = '/login?expired=true'; + } + } + return { - error: data.message || data.msg || '请求失败', + error: errorMessage, status: response.status, headers: responseHeaders }; diff --git a/app/api/entry-modules/entry-modules.ts b/app/api/entry-modules/entry-modules.ts new file mode 100644 index 0000000..d5e6508 --- /dev/null +++ b/app/api/entry-modules/entry-modules.ts @@ -0,0 +1,242 @@ +/** + * 入口模块管理 API 客户端 + * 提供入口模块的增删改查功能 + */ + +import { postgrestGet, postgrestPost, postgrestPut, postgrestDelete } from "../postgrest-client"; + +/** + * 入口模块数据接口 + */ +export interface EntryModule { + id?: number; + name: string; + description?: string; + path?: string; // logo图片路径 + areas?: string[]; // 地区数组 + created_at?: string; + updated_at?: string; +} + +/** + * 入口模块搜索参数 + */ +export interface EntryModuleSearchParams { + name?: string; + area?: string; + page?: number; + pageSize?: number; +} + +/** + * 入口模块列表响应 + */ +export interface EntryModulesResponse { + modules: EntryModule[]; + total: number; +} + +/** + * 获取入口模块列表 + * @param searchParams 搜索参数 + * @param jwtToken JWT令牌 + * @returns 入口模块列表和总数 + */ +export async function getEntryModules( + searchParams: EntryModuleSearchParams = {}, + jwtToken?: string | null +): Promise<{ data?: EntryModulesResponse; error?: string }> { + try { + const { name, area, page = 1, pageSize = 10 } = searchParams; + + // 构建过滤条件 + const filter: Record = {}; + + if (name) { + filter.name = `ilike.*${name}*`; + } + + // 如果有地区筛选,使用 JSONB 查询 + if (area) { + filter.areas = `cs.{"${area}"}`; // cs = contains (JSONB数组包含) + } + + // 计算分页 + const offset = (page - 1) * pageSize; + + // 构建查询参数(一次请求获取数据和总数) + const queryParams: any = { + select: "*", + order: "created_at.desc", + limit: pageSize, + offset: offset, + headers: { + 'Prefer': 'count=exact' + }, + token: jwtToken + }; + + // 只在有过滤条件时添加 filter + if (Object.keys(filter).length > 0) { + queryParams.filter = filter; + } + + // 获取分页数据 + const result = await postgrestGet("entry_modules", queryParams); + + if (result.error) { + return { error: result.error }; + } + + // 从 Content-Range 头获取总数 + let totalCount = 0; + const responseWithHeaders = result as { + data: unknown; + headers?: Record; + }; + + if (responseWithHeaders.headers) { + const rangeHeader = responseWithHeaders.headers['content-range']; + if (rangeHeader) { + const total = rangeHeader.split('/')[1]; + if (total !== '*') { + totalCount = parseInt(total, 10); + } + } + } + + return { + data: { + modules: result.data || [], + total: totalCount || (result.data?.length || 0) + } + }; + } catch (error) { + console.error("获取入口模块列表失败:", error); + return { error: error instanceof Error ? error.message : "获取入口模块列表失败" }; + } +} + +/** + * 根据ID获取入口模块 + * @param id 入口模块ID + * @param jwtToken JWT令牌 + * @returns 入口模块数据 + */ +export async function getEntryModuleById( + id: number, + jwtToken?: string | null +): Promise<{ data?: EntryModule; error?: string }> { + try { + const result = await postgrestGet("entry_modules", { + filter: { id: `eq.${id}` }, + token: jwtToken + }); + + if (result.error) { + return { error: result.error }; + } + + const module = result.data?.[0]; + if (!module) { + return { error: "入口模块不存在" }; + } + + return { data: module }; + } catch (error) { + console.error("获取入口模块失败:", error); + return { error: error instanceof Error ? error.message : "获取入口模块失败" }; + } +} + +/** + * 创建入口模块 + * @param module 入口模块数据 + * @param jwtToken JWT令牌 + * @returns 创建的入口模块 + */ +export async function createEntryModule( + module: Omit, + jwtToken?: string | null +): Promise<{ data?: EntryModule; error?: string }> { + try { + const result = await postgrestPost( + "entry_modules", + module as EntryModule, + jwtToken + ); + + if (result.error) { + return { error: result.error }; + } + + const createdModule = Array.isArray(result.data) ? result.data[0] : result.data; + return { data: createdModule as EntryModule }; + } catch (error) { + console.error("创建入口模块失败:", error); + return { error: error instanceof Error ? error.message : "创建入口模块失败" }; + } +} + +/** + * 更新入口模块 + * @param id 入口模块ID + * @param module 更新的入口模块数据 + * @param jwtToken JWT令牌 + * @returns 更新的入口模块 + */ +export async function updateEntryModule( + id: number, + module: Partial>, + jwtToken?: string | null +): Promise<{ data?: EntryModule; error?: string }> { + try { + const result = await postgrestPut>( + "entry_modules", + module, + { id: `eq.${id}` }, + jwtToken + ); + + if (result.error) { + return { error: result.error }; + } + + const updatedModule = Array.isArray(result.data) ? result.data[0] : result.data; + return { data: updatedModule as EntryModule }; + } catch (error) { + console.error("更新入口模块失败:", error); + return { error: error instanceof Error ? error.message : "更新入口模块失败" }; + } +} + +/** + * 删除入口模块 + * @param id 入口模块ID + * @param jwtToken JWT令牌 + * @returns 是否成功 + */ +export async function deleteEntryModule( + id: number, + jwtToken?: string | null +): Promise<{ success: boolean; error?: string }> { + try { + const result = await postgrestDelete( + "entry_modules", + { id: `eq.${id}` }, + jwtToken + ); + + if (result.error) { + return { success: false, error: result.error }; + } + + return { success: true }; + } catch (error) { + console.error("删除入口模块失败:", error); + return { + success: false, + error: error instanceof Error ? error.message : "删除入口模块失败" + }; + } +} diff --git a/app/api/files/documents.ts b/app/api/files/documents.ts index 29462f4..8590f5f 100644 --- a/app/api/files/documents.ts +++ b/app/api/files/documents.ts @@ -507,12 +507,12 @@ export async function getDocumentsListFromAPI(searchParams: { if (dateFrom) params.start_time = dateFrom; if (dateTo) params.end_time = dateTo; - // 处理文档类型ID数组 - 传递为数组或单个值 + // 处理文档类型ID数组 - 转换为逗号分隔的字符串 if (documentTypeIds && documentTypeIds.length > 0) { - params.type_id = documentTypeIds; + params.type_id = documentTypeIds.join(','); } - console.log('📤 [getDocumentsListFromAPI] 请求参数:', params); + // console.log('📤 [getDocumentsListFromAPI] 请求参数:', params); // 调用后端API const axios = await import('axios').then(m => m.default); @@ -529,7 +529,7 @@ export async function getDocumentsListFromAPI(searchParams: { const totalCount = data.total || 0; const totalPages = data.total_pages || 0; - console.log(`📥 [getDocumentsListFromAPI] 获取到 ${backendDocuments.length} 个文档,总数: ${totalCount}`); + // console.log(`📥 [getDocumentsListFromAPI] 获取到 ${backendDocuments.length} 个文档,总数: ${totalCount}`); // 转换后端数据为前端 DocumentUI 格式 const convertedDocuments: DocumentUI[] = backendDocuments.map((doc: any) => { diff --git a/app/api/home/home.ts b/app/api/home/home.ts index 92306a6..e3c4814 100644 --- a/app/api/home/home.ts +++ b/app/api/home/home.ts @@ -1,5 +1,6 @@ -import { postgrestGet, postgrestPost, type PostgrestParams } from "../postgrest-client"; -import dayjs from 'dayjs'; +import { postgrestGet, type PostgrestParams } from "../postgrest-client"; +import { apiRequest } from "../axios-client"; +// import dayjs from 'dayjs'; /** * 从不同格式的 API 响应中提取数据 @@ -77,397 +78,107 @@ interface HomeStatistics { } /** - * 通过传入的 reviewType 参数构建类型过滤条件 - * @param reviewType 文档类型 - * @returns 过滤条件字符串 + * 后端统计接口响应类型(蛇形命名) */ -function buildTypeFilter(reviewType: string | null): string { - let typeFilter = ''; - if (reviewType === 'contract') { - typeFilter = 'type_id.eq.1'; - } else if (reviewType === 'record') { - typeFilter = '(type_id.eq.2,type_id.eq.3)'; - } - return typeFilter; +interface BackendStatisticsResponse { + today_pending_files: number; + monthly_reviewed_files: number; + monthly_review_growth: { + value: number; + is_up: boolean; + }; + monthly_pass_rate: number; + pass_rate_growth: { + value: number; + is_up: boolean; + }; + issues_detected: number; + issues_growth: { + value: number; + is_up: boolean; + }; } /** * 获取主页数据 - * @param reviewType 从客户端传入的 reviewType 值 - * @param userId 用户ID + * @param reviewType 从客户端传入的 reviewType 值(已废弃,现在从sessionStorage读取) + * @param userId 用户ID(已废弃,后端通过JWT自动识别) * @param token JWT token * @returns 主页数据 */ -export async function getHomeData(reviewType?: string | null,userId?: string | number, token?: string): Promise { +export async function getHomeData(reviewType?: string | null, userId?: string | number, token?: string): Promise { try { - // 获取当前日期和时间相关值 - const startOfToday = dayjs().startOf('day').format('YYYY-MM-DD HH:mm:ss'); - const startOfThisMonth = dayjs().startOf('month').format('YYYY-MM-DD HH:mm:ss'); - const endOfThisMonth = dayjs().endOf('month').format('YYYY-MM-DD HH:mm:ss'); - const startOfLastMonth = dayjs().subtract(1, 'month').startOf('month').format('YYYY-MM-DD HH:mm:ss'); - const endOfLastMonth = dayjs().subtract(1, 'month').endOf('month').format('YYYY-MM-DD HH:mm:ss'); - - // console.log('传入的 reviewType', reviewType); - // console.log('传入的 userId', userId); - - // 基于 reviewType 构建类型过滤条件 - const typeFilter = buildTypeFilter(reviewType || null); - // console.log('构建的 typeFilter', typeFilter); - - // 通用API响应处理函数 - const handleApiResponse = async ( - apiCall: Promise<{ - data?: unknown; - headers?: Record; - error?: string; - status?: number - }>, - errorMessage: string, - defaultValue: T - ): Promise => { - try { - const response = await apiCall; - if (response.error) { - console.error(`${errorMessage}: ${response.error}`); - return defaultValue; + // 🔑 从 sessionStorage 获取文档类型IDs + let typeIds: string | null = null; + if (typeof window !== 'undefined') { + const storedTypeIds = sessionStorage.getItem('documentTypeIds'); + if (storedTypeIds) { + try { + const typeIdsArray = JSON.parse(storedTypeIds) as number[]; + if (Array.isArray(typeIdsArray) && typeIdsArray.length > 0) { + typeIds = typeIdsArray.join(','); + console.log('📊 [getHomeData] 从 sessionStorage 获取文档类型:', typeIds); + } + } catch (error) { + console.error('❌ [getHomeData] 解析 documentTypeIds 失败:', error); } - const data = extractApiData(response.data); - if (!data) { - console.warn(`${errorMessage}: 无法提取有效数据`); - return defaultValue; - } - return data; - } catch (error) { - console.error(`${errorMessage}: ${error instanceof Error ? error.message : '未知错误'}`); - return defaultValue; - } - }; - - // 1. 今日待审核文件 - 获取今天的待审核文件数量 (audit_status = 0 或 2) - const todayPendingParams: PostgrestParams = { - select: 'count', - filter: { - or: `(audit_status.eq.0,audit_status.eq.2,audit_status.is.null)`, - created_at: `gte.${startOfToday}`, - is_test_document: `eq.false`, - user_id: `eq.${userId}` - } - }; - - // 添加类型过滤条件 - if (typeFilter) { - if (typeFilter.startsWith('(')) { - // 确保 filter 已初始化 - if (!todayPendingParams.filter) { - todayPendingParams.filter = {}; - } - todayPendingParams.filter.or = typeFilter + ',' + todayPendingParams.filter.or; - } else { - const [field, op, value] = typeFilter.split('.'); - if (!todayPendingParams.filter) { - todayPendingParams.filter = {}; - } - todayPendingParams.filter[field] = `${op}.${value}`; } } - const todayPendingCount = await handleApiResponse<{ count: number }[]>( - postgrestGet('documents', { ...todayPendingParams, token }), - '获取今日待审核文件数量失败', - [] + // 🔑 构建请求参数 + const params: Record = { + time_range: '30days' // 默认近30天 + }; + + // 如果有文档类型,添加到参数 + if (typeIds) { + params.type_ids = typeIds; + } + + console.log('📊 [getHomeData] 请求参数:', params); + + // 🔑 调用后端统计接口 + const response = await apiRequest( + '/admin/statistics/home-data', + { + method: 'GET', + headers: token ? { + 'Authorization': `Bearer ${token}` + } : undefined + }, + params // 查询参数 ); - const todayPendingFiles = todayPendingCount[0]?.count || 0; - // 2. 本月已审核文件 - 获取本月已审核文件数量 (audit_status != 0 且 != 2) - const thisMonthReviewedParams: PostgrestParams = { - select: 'count', - filter: { - and: `(audit_status.neq.0,audit_status.neq.2)`, - upload_time: `gte.${startOfThisMonth}`, - is_test_document: `eq.false`, - user_id: `eq.${userId}` - } - }; - - // 添加类型过滤条件 - if (typeFilter) { - if (typeFilter.startsWith('(')) { - thisMonthReviewedParams.or = typeFilter; - } else { - const [field, op, value] = typeFilter.split('.'); - if (!thisMonthReviewedParams.filter) { - thisMonthReviewedParams.filter = {}; - } - thisMonthReviewedParams.filter[field] = `${op}.${value}`; - } + if (response.error) { + console.error('❌ [getHomeData] 获取统计数据失败:', response.error); + throw new Error(response.error); } - const thisMonthReviewedCount = await handleApiResponse<{ count: number }[]>( - postgrestGet('documents', { ...thisMonthReviewedParams, token }), - '获取本月已审核文件数量失败', - [] - ); - // 本月已审核文件数量 - const monthlyReviewedFiles = thisMonthReviewedCount[0]?.count || 0; - - // 上月已审核文件 - const lastMonthReviewedParams: PostgrestParams = { - select: 'count', - filter: { - // or: `(audit_status.eq.1,audit_status.eq.-1)`, - and: `(upload_time.gte.${startOfLastMonth},upload_time.lte.${endOfLastMonth},audit_status.neq.0,audit_status.neq.2)`, - is_test_document: `eq.false`, - user_id: `eq.${userId}` - } - }; - - // 添加类型过滤条件 - if (typeFilter) { - if (typeFilter.startsWith('(')) { - // 确保 filter 已初始化 - if (!lastMonthReviewedParams.filter) { - lastMonthReviewedParams.filter = {}; - } - lastMonthReviewedParams.filter.or = typeFilter; - } else { - const [field, op, value] = typeFilter.split('.'); - if (!lastMonthReviewedParams.filter) { - lastMonthReviewedParams.filter = {}; - } - lastMonthReviewedParams.filter[field] = `${op}.${value}`; - } + const backendData = response.data; + if (!backendData) { + console.error('❌ [getHomeData] 后端未返回数据'); + throw new Error('后端未返回统计数据'); } - const lastMonthReviewedCount = await handleApiResponse<{ count: number }[]>( - postgrestGet('documents', { ...lastMonthReviewedParams, token }), - '获取上月已审核文件数量失败', - [] - ); - // 上月已审核文件数量 - const lastMonthReviewed = lastMonthReviewedCount[0]?.count || 0; - // console.log('上月已审核文件查询参数', lastMonthReviewedParams); - // console.log('上月已审核文件数量', lastMonthReviewed); - - // 计算同比增长 - let reviewGrowthValue = 0; - let reviewGrowthIsUp = true; - if (lastMonthReviewed > 0) { - const growthRate = ((monthlyReviewedFiles - lastMonthReviewed) / lastMonthReviewed) * 100; - reviewGrowthValue = Math.abs(parseFloat(growthRate.toFixed(1))); - reviewGrowthIsUp = growthRate >= 0; - } else if (lastMonthReviewed == 0 && monthlyReviewedFiles > 0) { - reviewGrowthValue = 100; - reviewGrowthIsUp = true; - } + console.log('✅ [getHomeData] 获取统计数据成功:', backendData); - // 3. 审核通过率 - 本月审核通过率 - const thisMonthTotalParams: PostgrestParams = { - select: 'count', - filter: { - audit_status: `eq.1`, - created_at: `gte.${startOfThisMonth}`, - is_test_document: `eq.false`, - user_id: `eq.${userId}` - } - }; - - // 添加类型过滤条件 - if (typeFilter) { - if (typeFilter.startsWith('(')) { - thisMonthTotalParams.or = typeFilter; - } else { - const [field, op, value] = typeFilter.split('.'); - if (!thisMonthTotalParams.filter) { - thisMonthTotalParams.filter = {}; - } - thisMonthTotalParams.filter[field] = `${op}.${value}`; - } - } - - const thisMonthTotalCount = await handleApiResponse<{ count: number }[]>( - postgrestGet('documents', { ...thisMonthTotalParams, token }), - '获取本月审核通过数量失败', - [] - ); - // console.log('本月审核通过数量查询参数', thisMonthTotalParams); - // 本月审核通过数量 - const thisMonthPassTotal = thisMonthTotalCount[0]?.count || 0; - // console.log('本月审核通过数量', thisMonthPassTotal); - // console.log('本月已审核文件数量', monthlyReviewedFiles); - - // 本月审核通过率 - const monthlyPassRate = (thisMonthPassTotal > 0 && monthlyReviewedFiles > 0) - ? parseFloat(((thisMonthPassTotal / monthlyReviewedFiles) * 100).toFixed(1)) - : 0; - - // 上月审核通过率 - const lastMonthTotalParams: PostgrestParams = { - select: 'count', - filter: { - audit_status: `eq.1`, - and: `(upload_time.gte.${startOfLastMonth},upload_time.lte.${endOfLastMonth})`, - is_test_document: `eq.false`, - user_id: `eq.${userId}` - } - }; - - // 添加类型过滤条件 - if (typeFilter) { - if (typeFilter.startsWith('(')) { - lastMonthTotalParams.or = typeFilter; - } else { - const [field, op, value] = typeFilter.split('.'); - if (!lastMonthTotalParams.filter) { - lastMonthTotalParams.filter = {}; - } - lastMonthTotalParams.filter[field] = `${op}.${value}`; - } - } - - const lastMonthTotalCount = await handleApiResponse<{ count: number }[]>( - postgrestGet('documents', { ...lastMonthTotalParams, token }), - '获取上月审核通过数量失败', - [] - ); - // 上月审核通过数量 - const lastMonthTotal = lastMonthTotalCount[0]?.count || 0; - - // 上月审核通过率 - const lastMonthPassRate = (lastMonthTotal > 0 && lastMonthReviewed > 0) - ? parseFloat(((lastMonthTotal / lastMonthReviewed) * 100).toFixed(1)) - : 0; - - // console.log('上个月-------', lastMonthPassRate); - - // 计算通过率同比增长 - let passRateGrowthValue = 0; - let passRateGrowthIsUp = true; - - - - if (lastMonthPassRate > 0) { - const passRateGrowth = ((monthlyPassRate - lastMonthPassRate) / lastMonthPassRate) * 100; - passRateGrowthValue = Math.abs(parseFloat(passRateGrowth.toFixed(1))); - passRateGrowthIsUp = passRateGrowth >= 0; - } else if (lastMonthPassRate == 0 && monthlyPassRate > 0) { - passRateGrowthValue = 100; - passRateGrowthIsUp = true; - } - - // console.log('上月通过率-------', lastMonthPassRate); - // console.log('本月通过率-------', monthlyPassRate); - - // 4. 检查出的问题总数(从评估结果表中统计) - // 使用新的数据库函数 count_evaluation_results_by_type 获取指定类型文档的问题数量 - let thisMonthIssuesCount = 0; - let lastMonthIssuesCount = 0; - - // 根据 reviewType 设置要查询的文档类型 - if (reviewType === 'contract') { - // 合同类型 - 直接查询类型 1 - const typeToQuery = [1]; - - // 调用数据库函数获取本月指定类型的问题数量 - - const thisMonthIssuesResponse = await handleApiResponse<{ count: number }[]>( - postgrestPost('rpc/count_evaluation_results_by_type', { - start_time: startOfThisMonth, - end_time: endOfThisMonth, - type_val: typeToQuery, - userid: parseInt(userId as string) - }, token), - '获取合同本月问题数据失败', - [] - ); - - // 本月问题数量 - thisMonthIssuesCount = thisMonthIssuesResponse[0]?.count || 0; - - // 调用数据库函数获取上月指定类型的问题数量 - const lastMonthIssuesResponse = await handleApiResponse<{ count: number }[]>( - postgrestPost('rpc/count_evaluation_results_by_type', { - start_time: startOfLastMonth, - end_time: endOfLastMonth, - type_val: typeToQuery, - userid: parseInt(userId as string) - }, token), - '获取上月问题数据失败', - [] - ); - - // 上月问题数量 - lastMonthIssuesCount = lastMonthIssuesResponse[0]?.count || 0; - - } else if (reviewType === 'record') { - // 记录类型 - 需要查询类型 2 和类型 3,并合并结果 - const typeToQuery = [2,3]; - - const thisMonthType2Response = await handleApiResponse<{ count: number }[]>( - postgrestPost('rpc/count_evaluation_results_by_type', { - start_time: startOfThisMonth, - end_time: endOfThisMonth, - type_val: typeToQuery, - userid: parseInt(userId as string) - }, token), - '获取本月许可卷宗类型2问题数据失败', - [] - ); - - // 本月两种类型的问题数量 - const thisMonthType2Count = thisMonthType2Response[0]?.count || 0; - thisMonthIssuesCount = thisMonthType2Count - - // 上月两种类型的问题数量 - const lastMonthType2Response = await handleApiResponse<{ count: number }[]>( - postgrestPost('rpc/count_evaluation_results_by_type', { - start_time: startOfLastMonth, - end_time: endOfLastMonth, - type_val: typeToQuery, - userid: parseInt(userId as string) - }, token), - '获取上月许可卷宗类型2问题数据失败', - [] - ); - - - - // 上月两种类型的问题数量 - const lastMonthType2Count = lastMonthType2Response[0]?.count || 0; - lastMonthIssuesCount = lastMonthType2Count - - } - - - // 计算问题数量同比增长 - let issuesGrowthValue = 0; - let issuesGrowthIsUp = true; - - - if (lastMonthIssuesCount > 0) { - const issuesGrowth = ((thisMonthIssuesCount - lastMonthIssuesCount) / lastMonthIssuesCount) * 100; - issuesGrowthValue = Math.abs(parseFloat(issuesGrowth.toFixed(1))); - issuesGrowthIsUp = issuesGrowth >= 0; - }else if(lastMonthIssuesCount == 0 && thisMonthIssuesCount > 0){ - issuesGrowthValue = 100; - issuesGrowthIsUp = true; - } - // 返回统计结果 + // 🔑 将后端响应(蛇形命名)转换为前端格式(驼峰命名) return { - todayPendingFiles, - monthlyReviewedFiles, + todayPendingFiles: backendData.today_pending_files, + monthlyReviewedFiles: backendData.monthly_reviewed_files, monthlyReviewGrowth: { - value: reviewGrowthValue, - isUp: reviewGrowthIsUp + value: backendData.monthly_review_growth.value, + isUp: backendData.monthly_review_growth.is_up }, - monthlyPassRate, + monthlyPassRate: backendData.monthly_pass_rate, passRateGrowth: { - value: passRateGrowthValue, - isUp: passRateGrowthIsUp + value: backendData.pass_rate_growth.value, + isUp: backendData.pass_rate_growth.is_up }, - issuesDetected: thisMonthIssuesCount, + issuesDetected: backendData.issues_detected, issuesGrowth: { - value: issuesGrowthValue, - isUp: issuesGrowthIsUp + value: backendData.issues_growth.value, + isUp: backendData.issues_growth.is_up } }; } catch (error) { @@ -485,6 +196,15 @@ export async function getHomeData(reviewType?: string | null,userId?: string | n } } +/** + * 地区配置类型定义 + */ +export interface AreaConfig { + area: string; // 地区名称 + enabled: boolean; // 是否启用 + sort_order: number; // 排序顺序 +} + /** * 入口模块类型定义 */ @@ -493,7 +213,7 @@ export interface EntryModule { name: string; description: string | null; path: string | null; - areas: string[]; + areas: AreaConfig[]; // 修改为对象数组 created_at: string; updated_at: string; document_types?: Array<{ @@ -518,20 +238,11 @@ export async function getEntryModules(userRole: string | null | undefined, userA // console.log('🔍 [getEntryModules] 查询地区:', userArea); - // 查询 entry_modules 表,筛选 areas 数组中包含用户地区的模块 - // 使用 PostgreSQL JSONB 操作符 @> 检查数组是否包含值 + // 查询 entry_modules 表,获取所有模块(在客户端进行过滤) const params: PostgrestParams = { select: 'id,name,description,path,areas,created_at,updated_at', - filter: { - // areas 数组中包含用户的 area - // areas: `cs.["${userArea}"]` // cs = contains (PostgreSQL @> 操作符) - } + filter: {} }; - if (userRole != 'provincial_admin'){ - params.filter = { - areas: `cs.["${userArea}"]` - } - } const modulesResponse = await postgrestGet('entry_modules', { ...params, token }); @@ -540,13 +251,38 @@ export async function getEntryModules(userRole: string | null | undefined, userA return []; } - const modules = extractApiData(modulesResponse.data); - if (!modules || modules.length === 0) { - console.warn('⚠️ [getEntryModules] 未找到匹配的入口模块'); + const allModules = extractApiData(modulesResponse.data); + if (!allModules || allModules.length === 0) { + console.warn('⚠️ [getEntryModules] 未找到任何入口模块'); return []; } - console.log(`✅ [getEntryModules] 找到 ${modules.length} 个入口模块`); + // 🔑 在客户端过滤:只保留包含用户地区且已启用的模块 + const modules = allModules.filter(module => { + // 省级管理员可以看到所有模块 + if (userRole === 'provincial_admin') { + return true; + } + + // 检查 areas 数组中是否存在匹配的地区配置 + if (!module.areas || !Array.isArray(module.areas)) { + return false; + } + + // 查找用户地区的配置 + const areaConfig = module.areas.find(config => + config.area === userArea && config.enabled === true + ); + + return !!areaConfig; // 找到且启用才返回 true + }); + + if (modules.length === 0) { + console.warn('⚠️ [getEntryModules] 未找到已启用的入口模块'); + return []; + } + + // console.log(`✅ [getEntryModules] 找到 ${modules.length} 个已启用的入口模块`); // 为每个模块查询关联的 document_types const modulesWithTypes = await Promise.all( @@ -579,7 +315,7 @@ export async function getEntryModules(userRole: string | null | undefined, userA }) ); - console.log('✅ [getEntryModules] 入口模块数据(含文档类型):', JSON.stringify(modulesWithTypes)); + // console.log('✅ [getEntryModules] 入口模块数据(含文档类型):', JSON.stringify(modulesWithTypes)); // 默认会多加一个 智慧法务大模型 入口 默认所有人都可以用,看到 modulesWithTypes.push({ @@ -597,7 +333,8 @@ export async function getEntryModules(userRole: string | null | undefined, userA "code": "空" } ] - }) + } + ) return modulesWithTypes; @@ -607,3 +344,185 @@ export async function getEntryModules(userRole: string | null | undefined, userA } } +/** + * 高频错误评查点数据类型 + */ +export interface TopErrorPoint { + rank: number; + evaluation_point_id: number; + point_name: string; + error_user_count: number; +} + +export interface TopErrorPointsResponse { + available: boolean; // 标记该模块是否可用(是否有权限访问) + total: number; + items: TopErrorPoint[]; +} + +/** + * 获取高频错误评查点 Top N + * @param limit 返回 Top N 条记录,默认 10 + * @param startDate 开始时间(格式:YYYY-MM-DD) + * @param endDate 结束时间(格式:YYYY-MM-DD) + * @param typeId 文档类型ID数组 + * @param token JWT token + * @returns 高频错误评查点列表 + */ +export async function getTopErrorPoints( + limit: number = 10, + startDate?: string, + endDate?: string, + typeId?: number[], + token?: string +): Promise { + try { + console.log('🔍 [getTopErrorPoints] 请求参数:', { limit, startDate, endDate, typeId, hasToken: !!token }); + + // 构建查询参数 + const params: Record = { + limit: limit + }; + + if (startDate) { + params.start_date = startDate; + } + + if (endDate) { + params.end_date = endDate; + } + + if (typeId && typeId.length > 0) { + // 直接传递数组,axios 会自动处理序列化 + params.type_id = typeId; + } + + // 构建请求配置 + const requestOptions: { method: string; headers?: Record } = { + method: 'GET' + }; + + // 只有在显式传入 token 时才添加 Authorization header + // 否则让 axios 拦截器自动处理(从 localStorage 获取) + if (token) { + requestOptions.headers = { + 'Authorization': `Bearer ${token}` + }; + } + + // 调用 API + const response = await apiRequest( + '/admin/statistics/top-error-points', + requestOptions, + params + ); + + if (response.error) { + console.error('❌ [getTopErrorPoints] 获取高频错误评查点失败:', response.error); + // 请求失败(如权限不足),标记为不可用 + return { available: false, total: 0, items: [] }; + } + + console.log('✅ [getTopErrorPoints] 成功获取高频错误评查点数据:', response.data); + // 请求成功,标记为可用(即使数据为空) + const data = response.data || { total: 0, items: [] }; + return { available: true, ...data }; + } catch (error) { + console.error('❌ [getTopErrorPoints] 获取高频错误评查点异常:', error instanceof Error ? error.message : String(error)); + // 请求异常,标记为不可用 + return { available: false, total: 0, items: [] }; + } +} + +/** + * 高风险用户数据类型 + */ +export interface TopRiskUser { + rank: number; + user_id: number; + user_name: string; + department: string; + total_errors: number; + avg_errors_per_doc: number; +} + +export interface TopRiskUsersResponse { + available: boolean; // 标记该模块是否可用(是否有权限访问) + total: number; + items: TopRiskUser[]; +} + +/** + * 获取高风险用户 Top N + * @param limit 返回 Top N 条记录,默认 5 + * @param startDate 开始时间(格式:YYYY-MM-DD) + * @param endDate 结束时间(格式:YYYY-MM-DD) + * @param typeId 文档类型ID数组 + * @param token JWT token + * @returns 高风险用户列表 + */ +export async function getTopRiskUsers( + limit: number = 5, + startDate?: string, + endDate?: string, + typeId?: number[], + token?: string +): Promise { + try { + console.log('🔍 [getTopRiskUsers] 请求参数:', { limit, startDate, endDate, typeId, hasToken: !!token }); + + // 构建查询参数 + const params: Record = { + limit: limit + }; + + if (startDate) { + params.start_date = startDate; + } + + if (endDate) { + params.end_date = endDate; + } + + if (typeId && typeId.length > 0) { + // 直接传递数组,axios 会自动处理序列化 + params.type_id = typeId; + } + + // 构建请求配置 + const requestOptions: { method: string; headers?: Record } = { + method: 'GET' + }; + + // 只有在显式传入 token 时才添加 Authorization header + // 否则让 axios 拦截器自动处理(从 localStorage 获取) + if (token) { + requestOptions.headers = { + 'Authorization': `Bearer ${token}` + }; + } + + // 调用 API + const response = await apiRequest( + '/admin/statistics/top-risk-users', + requestOptions, + params + ); + + if (response.error) { + console.error('❌ [getTopRiskUsers] 获取高风险用户失败:', response.error); + // 请求失败(如权限不足),标记为不可用 + return { available: false, total: 0, items: [] }; + } + + console.log('✅ [getTopRiskUsers] 成功获取高风险用户数据:', response.data); + // 请求成功,标记为可用(即使数据为空) + const data = response.data || { total: 0, items: [] }; + return { available: true, ...data }; + } catch (error) { + console.error('❌ [getTopRiskUsers] 获取高风险用户异常:', error instanceof Error ? error.message : String(error)); + // 请求异常,标记为不可用 + return { available: false, total: 0, items: [] }; + } +} + diff --git a/app/api/login/auth.server.ts b/app/api/login/auth.server.ts index 3500b4c..c10563a 100644 --- a/app/api/login/auth.server.ts +++ b/app/api/login/auth.server.ts @@ -455,15 +455,27 @@ export async function logout(request: Request) { const accessToken = session.get("accessToken"); const appId = OAUTH_CONFIG.appId || 'idaasoauth2'; - // 如果存在访问令牌,调用IDaaS单点登出 + console.log("🚪 [Logout] 开始登出流程..."); + console.log("🔑 [Logout] accessToken 存在:", !!accessToken); + console.log("📱 [Logout] appId:", appId); + + // 如果存在访问令牌,调用IDaaS单点登出(仅 OAuth 登录用户) if (accessToken && appId) { + console.log("🌐 [Logout] OAuth 用户,准备调用 IDaaS 单点登出..."); try { await callIDaaSLogout(accessToken, appId); - console.log("IDaaS单点登出成功"); + console.log("✅ [Logout] IDaaS单点登出成功"); } catch (error) { - console.error("IDaaS单点登出失败:", error); + console.error("❌ [Logout] IDaaS单点登出失败:"); + console.error(" 错误详情:", error); + if (error instanceof Error) { + console.error(" 错误消息:", error.message); + console.error(" 错误堆栈:", error.stack); + } // 即使IDaaS登出失败,也继续清除本地会话 } + } else { + console.log("ℹ️ [Logout] 管理员登录用户,无需调用 IDaaS 登出"); } return new Response(null, { @@ -487,6 +499,11 @@ async function callIDaaSLogout(accessToken: string, appId: string): Promise= 5) { - errorMsg = "账户已被锁定,密码错误次数过多,请联系管理员"; - isLocked = true; - } else if (retryCount > 0) { - // 显示剩余尝试次数 - const remainingAttempts = 5 - retryCount; - errorMsg = `${loginResult.msg || "用户名或密码错误"},还有 ${remainingAttempts} 次尝试机会`; - } - - return new Response(JSON.stringify({ - success: false, - error: errorMsg, - retryCount: retryCount, - isLocked: isLocked, - remainingAttempts: isLocked ? 0 : (5 - retryCount) - }), { - status: isLocked ? 403 : 401, // 403 表示禁止访问(账户被锁) - headers: { "Content-Type": "application/json" } - }); - } - } catch (error) { - console.error("登录请求失败:", error); - return new Response(JSON.stringify({ - success: false, - error: "登录请求失败,请稍后重试" - }), { - status: 500, - headers: { "Content-Type": "application/json" } - }); - } -} \ No newline at end of file diff --git a/app/api/postgrest-client.ts b/app/api/postgrest-client.ts index 92617bc..dcd9d95 100644 --- a/app/api/postgrest-client.ts +++ b/app/api/postgrest-client.ts @@ -276,13 +276,29 @@ export async function postgrestGet(endpoint: string, params?: PostgrestParams }, queryParams ); - + if (response.error) { + // 🔑 检测令牌过期错误 + const isTokenExpired = response.error.includes('令牌已过期') || + response.error.includes('令牌') || + response.error.includes('token') || + response.error.includes('expired') || + response.error.includes('认证') || + response.error.includes('未授权'); + + if (isTokenExpired && typeof window !== 'undefined') { + console.error('🔑 [PostgREST Client - GET] 检测到令牌过期,清除会话并重定向到登录页'); + localStorage.removeItem('access_token'); + localStorage.removeItem('user_info'); + sessionStorage.clear(); + window.location.href = '/login?expired=true'; + } + throw new Error(response.error); } - + // 返回数据和响应头 - return { + return { data: response.data as T, headers: response.headers }; @@ -421,6 +437,23 @@ export async function postgrestPost>(endpoint: st if (response.error) { console.error(`POST请求失败: ${response.error}`); + + // 🔑 检测令牌过期错误 + const isTokenExpired = response.error.includes('令牌已过期') || + response.error.includes('令牌') || + response.error.includes('token') || + response.error.includes('expired') || + response.error.includes('认证') || + response.error.includes('未授权'); + + if (isTokenExpired && typeof window !== 'undefined') { + console.error('🔑 [PostgREST Client] 检测到令牌过期,清除会话并重定向到登录页'); + localStorage.removeItem('access_token'); + localStorage.removeItem('user_info'); + sessionStorage.clear(); + window.location.href = '/login?expired=true'; + } + throw new Error(response.error); } @@ -548,15 +581,31 @@ export async function postgrestPut( }, queryParams ); - + if (response.error) { + // 🔑 检测令牌过期错误 + const isTokenExpired = response.error.includes('令牌已过期') || + response.error.includes('令牌') || + response.error.includes('token') || + response.error.includes('expired') || + response.error.includes('认证') || + response.error.includes('未授权'); + + if (isTokenExpired && typeof window !== 'undefined') { + console.error('🔑 [PostgREST Client - PATCH] 检测到令牌过期,清除会话并重定向到登录页'); + localStorage.removeItem('access_token'); + localStorage.removeItem('user_info'); + sessionStorage.clear(); + window.location.href = '/login?expired=true'; + } + throw new Error(response.error); } - + if (!response.data) { throw new Error('更新成功但未返回数据'); } - + return { data: response.data }; } catch (error) { const apiError = handleApiError(error); @@ -595,11 +644,27 @@ export async function postgrestDelete(endpoint: string, params?: PostgrestPar }, queryParams ); - + if (response.error) { + // 🔑 检测令牌过期错误 + const isTokenExpired = response.error.includes('令牌已过期') || + response.error.includes('令牌') || + response.error.includes('token') || + response.error.includes('expired') || + response.error.includes('认证') || + response.error.includes('未授权'); + + if (isTokenExpired && typeof window !== 'undefined') { + console.error('🔑 [PostgREST Client - DELETE] 检测到令牌过期,清除会话并重定向到登录页'); + localStorage.removeItem('access_token'); + localStorage.removeItem('user_info'); + sessionStorage.clear(); + window.location.href = '/login?expired=true'; + } + throw new Error(response.error); } - + return { data: response.data as T }; } catch (error) { const apiError = handleApiError(error); diff --git a/app/api/role-permissions/role-permissions.ts b/app/api/role-permissions/role-permissions.ts index abc90ca..3edf319 100644 --- a/app/api/role-permissions/role-permissions.ts +++ b/app/api/role-permissions/role-permissions.ts @@ -3,6 +3,60 @@ * 用于角色、路由权限、用户角色的管理 */ +// ==================== 常量定义 ==================== + +/** + * RBAC API 基础路径 + * 注意:使用相对路径,会命中Remix API路由而不是后端服务器 + */ +const RBAC_API_BASE = '/api/v3/rbac'; + +/** + * RBAC专用API客户端 - 使用fetch直接请求Remix API路由 + */ +async function rbacFetch(url: string, options: RequestInit = {}): Promise { + console.log('🔗 [RBAC Fetch] 请求:', url, options.method || 'GET'); + + const response = await fetch(url, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } + }); + + console.log('📡 [RBAC Fetch] 响应状态:', response.status, response.statusText); + + const data = await response.json(); + console.log('📦 [RBAC Fetch] 响应数据:', data); + + if (!response.ok) { + throw new Error(data.detail || data.message || `HTTP ${response.status}`); + } + + return data; +} + +/** + * 统一响应处理函数 + * 处理后端返回的统一格式响应 + */ +function handleApiResponse(response: ApiResponse): T { + if (response.error) { + throw new Error(response.error); + } + + if (response.data && 'code' in response.data) { + if (response.data.code !== 200) { + throw new Error(response.data.message || '请求失败'); + } + return response.data.data as T; + } + + // 如果没有code字段,直接返回data + return response.data as T; +} + // ==================== 类型定义 ==================== /** @@ -74,6 +128,45 @@ export interface UserRoleRelation { created_at: string; } +/** + * RBAC权限信息 + */ +export interface Permission { + id: number; + permission_key: string; // 格式: module:resource:action + module: string; // 模块名 + resource: string; // 资源名 + action: string; // 操作名 + display_name: string; // 显示名称 + description: string | null; + permission_type: 'API' | 'MENU' | 'BUTTON'; + is_system: boolean; + parent_id: number | null; + sort_order: number; + children?: Permission[]; // 树形结构 +} + +/** + * 角色权限配置 + */ +export interface RolePermissionConfig { + permission_id: number; + grant_type?: 'GRANT' | 'DENY'; + data_scope?: 'ALL' | 'DEPT' | 'SELF'; +} + +/** + * 角色权限详情 + */ +export interface RolePermissionDetail { + id: number; + permission_id: number; + permission_key: string; + display_name: string; + grant_type: 'GRANT' | 'DENY'; + data_scope: 'ALL' | 'DEPT' | 'SELF'; +} + // ==================== 模拟数据 ==================== /** @@ -209,63 +302,42 @@ const mockRoutes: RouteInfo[] = [ ]; /** - * 模拟角色数据 + * 模拟角色数据(与数据库实际数据一致) + * 仅用于开发阶段API失败时的降级方案 */ const mockRoles: RoleInfo[] = [ { id: 1, role_key: 'admin', - role_name: '系统管理员', - data_scope: 'ALL', - description: '拥有系统所有权限', - priority: 1, - is_system_role: true, - created_at: '2024-01-01 10:00:00', - updated_at: '2024-01-01 10:00:00' + role_name: '市级管理员', + data_scope: 'DEPT', + description: '负责本地区的所有业务管理,不包括系统设置和角色权限管理', + priority: 0, + is_system_role: false, + created_at: '2025-07-18 10:35:39', + updated_at: '2025-07-18 10:35:39' }, { id: 2, - role_key: 'provincial', - role_name: '省级管理员', - data_scope: 'PROVINCE', - description: '省级权限,可管理文档类型和评查点', - priority: 2, - is_system_role: false, - created_at: '2024-01-02 10:00:00', - updated_at: '2024-01-02 10:00:00' - }, - { - id: 3, - role_key: 'city_admin', - role_name: '市级管理员', - data_scope: 'CITY', - description: '市级权限,可管理本市文档', - priority: 3, - is_system_role: false, - created_at: '2024-01-03 10:00:00', - updated_at: '2024-01-03 10:00:00' - }, - { - id: 4, - role_key: 'common_user', - role_name: '普通用户', + role_key: 'common', + role_name: '普通员工', data_scope: 'SELF', - description: '普通用户,只能查看自己的文档', - priority: 4, + description: '仅能操作自己的数据', + priority: 0, is_system_role: false, - created_at: '2024-01-04 10:00:00', - updated_at: '2024-01-04 10:00:00' + created_at: '2025-07-18 10:35:39', + updated_at: '2025-07-18 10:35:39' }, { - id: 5, - role_key: 'reviewer', - role_name: '评审员', - data_scope: 'DEPARTMENT', - description: '负责文档评审工作', - priority: 5, - is_system_role: false, - created_at: '2024-01-05 10:00:00', - updated_at: '2024-01-05 10:00:00' + id: 52, + role_key: 'provincial_admin', + role_name: '省级管理员', + data_scope: 'ALL', + description: '拥有全部权限,可以管理所有地区的评查点规则、提示词、动态按钮、评查组', + priority: 1, + is_system_role: true, + created_at: '2025-11-19 17:25:45', + updated_at: '2025-11-19 17:25:45' } ]; @@ -369,19 +441,140 @@ const mockUserRoles: UserRoleRelation[] = [ /** * 获取所有角色列表 + * @param params 查询参数 */ -export async function getRoles(): Promise { - // 模拟网络延迟 - await new Promise(resolve => setTimeout(resolve, 300)); - return mockRoles; +export async function getRoles(params?: { + page?: number; + page_size?: number; + role_key?: string; + role_name?: string; + include_system?: boolean; +}): Promise { + try { + // 导入 axios-client 的 get 函数 + const { get } = await import('~/api/axios-client'); + + console.log('🔍 [getRoles] 开始调用后端API:', `/api/v3/rbac/roles`, params); + + // 使用 axios-client 的 get 函数调用真实后端API + const response = await get(`/api/v3/rbac/roles`, params || {}); + console.log('📦 [getRoles] 后端API完整响应:', JSON.stringify(response, null, 2)); + + if (response.error) { + throw new Error(response.error); + } + + // 后端响应格式: { code: 200, message: "success", data: { total, page, page_size, items: [...] } } + let items: any[] = []; + if (response.data && response.data.data && Array.isArray(response.data.data.items)) { + items = response.data.data.items; + } else if (response.data && Array.isArray(response.data.items)) { + items = response.data.items; + } + + console.log('✅ [getRoles] 解析出的角色数组:', items); + + // 数据格式转换(后端字段 -> 前端字段) + const roles = items.map(role => ({ + id: role.id, + role_key: role.role_key, + role_name: role.role_name, + data_scope: role.data_scope, + description: role.description || '', + parent_role_id: role.parent_role_id || null, + priority: role.priority || 0, + is_system_role: role.is_system || false, + created_at: role.created_at, + updated_at: role.updated_at + })); + + console.log('✅ [getRoles] 最终返回的角色列表,共', roles.length, '个角色'); + return roles; + } catch (error) { + console.error('❌ [getRoles] 获取角色列表失败:', error); + // 失败时返回空数组 + return []; + } +} + +/** + * 获取角色详情 + * @param roleId 角色ID + */ +export async function getRoleDetail(roleId: number): Promise { + try { + const response = await get(`${RBAC_API_BASE}/roles/${roleId}`); + const role = handleApiResponse(response); + + return { + id: role.id, + role_key: role.role_key, + role_name: role.role_name, + data_scope: role.data_scope, + description: role.description || '', + parent_role_id: role.parent_role_id || null, + priority: role.priority || 0, + is_system_role: role.is_system, + created_at: role.created_at, + updated_at: role.updated_at + }; + } catch (error) { + console.error('获取角色详情失败:', error); + return null; + } } /** * 获取所有路由(树形结构) + * 从后端API获取当前用户可访问的所有路由 */ export async function getRoutes(): Promise { - await new Promise(resolve => setTimeout(resolve, 300)); - return mockRoutes; + try { + const { get } = await import('~/api/axios-client'); + + console.log('🔍 [getRoutes] 开始调用后端API: /rbac/user/routes'); + + // 调用后端API获取当前用户的路由(provincial_admin应该有所有路由权限) + const response = await get('/rbac/user/routes'); + + if (response.error) { + console.error('❌ [getRoutes] API调用失败:', response.error); + throw new Error(response.error); + } + + // 后端响应格式: { code: 200, msg: "success", data: { user_id, username, routes: [...], routes_flat: [...] } } + let routes: any[] = []; + if (response.data && response.data.data && Array.isArray(response.data.data.routes)) { + routes = response.data.data.routes; + } else if (response.data && Array.isArray(response.data.routes)) { + // 兼容可能的响应格式 + routes = response.data.routes; + } + + console.log('✅ [getRoutes] 成功获取路由数据,共', routes.length, '个顶级路由'); + + // 将后端数据转换为前端RouteInfo格式 + const mapRouteData = (route: any): RouteInfo => ({ + id: route.id, + route_path: route.route_path, + route_name: route.route_name, + route_title: route.route_title, + icon: route.icon || '', + sort_order: route.sort_order || 0, + is_hidden: route.is_hidden || false, + is_cache: route.is_cache !== false, // 默认true + status: route.status || 1, + parent_id: route.parent_id || null, + component: route.component, + children: route.children ? route.children.map(mapRouteData) : undefined + }); + + return routes.map(mapRouteData); + } catch (error) { + console.error('❌ [getRoutes] 获取路由数据失败:', error); + // 失败时返回空数组,让前端显示错误提示 + return []; + } } /** @@ -389,8 +582,42 @@ export async function getRoutes(): Promise { * @param roleId 角色ID */ export async function getRoleRoutePermissions(roleId: number): Promise { - await new Promise(resolve => setTimeout(resolve, 200)); - return mockRoleRoutePermissions.filter(p => p.role_id === roleId); + try { + // 导入 axios-client 的 get 函数 + const { get } = await import('~/api/axios-client'); + + console.log('🔍 [getRoleRoutePermissions] 开始调用后端API:', `/rbac/roles/${roleId}/routes`); + + // 使用 axios-client 的 get 函数调用真实后端API + const response = await get(`/rbac/roles/${roleId}/routes`); + console.log('📦 [getRoleRoutePermissions] 后端API完整响应:', JSON.stringify(response, null, 2)); + + if (response.error) { + throw new Error(response.error); + } + + // 后端响应格式: { code: 200, msg: "success", data: { role_id, routes: [...] } } + let routes: any[] = []; + if (response.data && response.data.data && Array.isArray(response.data.data.routes)) { + routes = response.data.data.routes; + } + + // 将路由数据转换为RoleRoutePermission格式 + const permissions = routes.map((route, index) => ({ + id: index + 1, + role_id: roleId, + route_id: route.id, + permission: 'RW', // 默认读写权限 + created_at: new Date().toISOString() + })); + + console.log('✅ [getRoleRoutePermissions] 获取角色路由权限成功:', permissions); + return permissions; + } catch (error) { + console.error('❌ [getRoleRoutePermissions] 获取角色路由权限失败:', error); + // 失败时返回空数组,避免页面崩溃 + return []; + } } /** @@ -402,57 +629,174 @@ export async function updateRoleRoutePermissions( roleId: number, routeIds: number[] ): Promise<{ success: boolean; message: string }> { - await new Promise(resolve => setTimeout(resolve, 500)); + try { + // 导入 axios-client 的 put 函数 + const { put } = await import('~/api/axios-client'); - // 在实际应用中,这里会调用后端API - console.log('更新角色权限:', { roleId, routeIds }); + console.log('🔍 [updateRoleRoutePermissions] 开始调用后端API:', `/rbac/roles/${roleId}/routes`, routeIds); - // 模拟更新本地数据 - // 删除该角色的旧权限 - const oldPermissions = mockRoleRoutePermissions.filter(p => p.role_id === roleId); - oldPermissions.forEach(p => { - const index = mockRoleRoutePermissions.indexOf(p); - if (index > -1) { - mockRoleRoutePermissions.splice(index, 1); - } - }); - - // 添加新权限 - routeIds.forEach((routeId, index) => { - mockRoleRoutePermissions.push({ - id: Date.now() + index, - role_id: roleId, - route_id: routeId, - permission: 'RW', - created_at: new Date().toISOString() + // 使用 axios-client 的 put 函数调用真实后端API + const response = await put(`/rbac/roles/${roleId}/routes`, { + route_ids: routeIds, + permission: 'RW' }); - }); + console.log('📦 [updateRoleRoutePermissions] 后端API完整响应:', JSON.stringify(response, null, 2)); - return { success: true, message: '角色权限更新成功' }; + if (response.error) { + throw new Error(response.error); + } + + // 后端响应格式: { code: 200, msg: "success", data: { role_id, assigned_count, removed_count, route_ids } } + let message = '角色权限更新成功'; + if (response.data && response.data.msg) { + message = response.data.msg; + } else if (response.data && response.data.data) { + const { assigned_count, removed_count } = response.data.data; + message = `成功分配 ${assigned_count} 个路由,移除了 ${removed_count} 个旧路由`; + } + + console.log('✅ [updateRoleRoutePermissions] 角色权限更新成功'); + return { success: true, message }; + } catch (error) { + console.error('❌ [updateRoleRoutePermissions] 更新角色权限失败:', error); + return { + success: false, + message: error instanceof Error ? error.message : '更新角色权限失败' + }; + } } +// ==================== 用户角色管理 API ==================== + /** * 获取指定角色的用户列表 * @param roleId 角色ID + * @param params 查询参数 */ -export async function getRoleUsers(roleId: number): Promise { - await new Promise(resolve => setTimeout(resolve, 200)); +export async function getRoleUsers( + roleId: number, + params?: { + page?: number; + page_size?: number; + area?: string; + username?: string; + } +): Promise { + try { + // 导入 axios-client 的 get 函数 + const { get } = await import('~/api/axios-client'); - // 查找具有该角色的用户ID - const userIds = mockUserRoles - .filter(ur => ur.role_id === roleId) - .map(ur => ur.user_id); + console.log('🔍 [getRoleUsers] 开始调用后端API:', `/api/v3/rbac/roles/${roleId}/users`, params); - // 返回用户详细信息 - return mockUsers.filter(u => userIds.includes(u.id)); + // 构建查询参数对象 + const queryParams: Record = {}; + if (params?.page) queryParams.page = params.page; + if (params?.page_size) queryParams.page_size = params.page_size; + if (params?.area) queryParams.area = params.area; + if (params?.username) queryParams.username = params.username; + + // 使用 axios-client 的 get 函数调用真实后端API + const response = await get(`/api/v3/rbac/roles/${roleId}/users`, queryParams); + console.log('📦 [getRoleUsers] 后端API完整响应:', JSON.stringify(response, null, 2)); + + if (response.error) { + throw new Error(response.error); + } + + // 后端响应格式: { code: 200, message: "success", data: { total, page, page_size, items: [...] } } + let items: any[] = []; + if (response.data && response.data.data && Array.isArray(response.data.data.items)) { + items = response.data.data.items; + } else if (response.data && Array.isArray(response.data.items)) { + items = response.data.items; + } + + console.log('✅ [getRoleUsers] 解析出的用户数组:', items); + + const users = items.map((user: any) => ({ + id: user.user_id || user.id, + username: user.username, + nick_name: user.nick_name, + phone_number: user.phone_number || '', + email: user.email || '', + ou_name: user.ou_name, + status: user.status || 1, + is_leader: user.is_leader || false + })); + + console.log('✅ [getRoleUsers] 最终返回的用户列表:', users); + return users; + } catch (error) { + console.error('❌ [getRoleUsers] 获取角色用户列表失败:', error); + return []; + } } /** * 获取所有用户列表 + * @param params 查询参数 */ -export async function getAllUsers(): Promise { - await new Promise(resolve => setTimeout(resolve, 300)); - return mockUsers; +export async function getAllUsers(params?: { + page?: number; + page_size?: number; + area?: string; + username?: string; +}): Promise { + try { + // 导入 axios-client 的 get 函数 + const { get } = await import('~/api/axios-client'); + + console.log('🔍 [getAllUsers] 开始调用后端API:', '/admin/users/users', params); + + // 构建查询参数对象 + const queryParams: Record = {}; + if (params?.page) queryParams.page = params.page; + if (params?.page_size) queryParams.page_size = params.page_size; + if (params?.username) queryParams.search = params.username; + + // 使用 axios-client 的 get 函数,会自动添加 baseURL 和 Authorization + const response = await get('/admin/users/users', queryParams); + console.log('📦 [getAllUsers] 后端API完整响应:', JSON.stringify(response, null, 2)); + + if (response.error) { + throw new Error(response.error); + } + + // axios-client 返回格式: { data: { users: [...], total: number }, status: 200 } + // 后端实际返回: { users: [...], total: number } + let users: any[] = []; + + if (response.data) { + // 如果 response.data 是对象且包含 users 字段 + if (response.data.users && Array.isArray(response.data.users)) { + users = response.data.users; + } + // 如果 response.data 本身就是数组 + else if (Array.isArray(response.data)) { + users = response.data; + } + } + + console.log('✅ [getAllUsers] 解析出的用户数组:', users); + console.log('✅ [getAllUsers] 用户数量:', users.length); + + const userList = users.map(user => ({ + id: user.user_id || user.id, // 优先使用 user_id,兼容不同的后端响应格式 + username: user.username, + nick_name: user.nick_name, + phone_number: user.phone_number || '', + email: user.email || '', + ou_name: user.ou_name, + status: user.status || 1, + is_leader: user.is_leader || false + })); + + console.log('✅ [getAllUsers] 最终返回的用户列表:', userList); + return userList; + } catch (error) { + console.error('❌ [getAllUsers] 获取用户列表失败:', error); + return []; + } } /** @@ -464,31 +808,77 @@ export async function assignUserRoles( userId: number, roleIds: number[] ): Promise<{ success: boolean; message: string }> { - await new Promise(resolve => setTimeout(resolve, 500)); + try { + // 导入 axios-client 的 post 函数 + const { post } = await import('~/api/axios-client'); - console.log('为用户分配角色:', { userId, roleIds }); + console.log('🔍 [assignUserRoles] 开始调用后端API:', `/api/v3/rbac/users/${userId}/roles`, roleIds); - // 模拟更新本地数据 - // 删除该用户的旧角色 - const oldRoles = mockUserRoles.filter(ur => ur.user_id === userId); - oldRoles.forEach(ur => { - const index = mockUserRoles.indexOf(ur); - if (index > -1) { - mockUserRoles.splice(index, 1); - } - }); - - // 添加新角色 - roleIds.forEach((roleId, index) => { - mockUserRoles.push({ - id: Date.now() + index, - user_id: userId, - role_id: roleId, - created_at: new Date().toISOString() + // 使用 axios-client 的 post 函数调用真实后端API + const response = await post(`/api/v3/rbac/users/${userId}/roles`, { + role_ids: roleIds }); - }); + console.log('📦 [assignUserRoles] 后端API完整响应:', JSON.stringify(response, null, 2)); - return { success: true, message: '用户角色分配成功' }; + if (response.error) { + throw new Error(response.error); + } + + // 后端响应格式: { code: 200, message: "角色分配成功", data: { user_id, roles: [...] } } + let message = '用户角色分配成功'; + if (response.data && response.data.message) { + message = response.data.message; + } + + console.log('✅ [assignUserRoles] 角色分配成功'); + return { success: true, message }; + } catch (error) { + console.error('❌ [assignUserRoles] 分配用户角色失败:', error); + return { + success: false, + message: error instanceof Error ? error.message : '分配失败' + }; + } +} + +/** + * 移除用户角色 + * @param userId 用户ID + * @param roleId 角色ID + */ +export async function revokeUserRole( + userId: number, + roleId: number +): Promise<{ success: boolean; message: string }> { + try { + // 导入 axios-client 的 del 函数 + const { del } = await import('~/api/axios-client'); + + console.log('🔍 [revokeUserRole] 开始调用后端API:', `/api/v3/rbac/users/${userId}/roles/${roleId}`); + + // 使用 axios-client 的 del 函数调用真实后端API + const response = await del(`/api/v3/rbac/users/${userId}/roles/${roleId}`); + console.log('📦 [revokeUserRole] 后端API完整响应:', JSON.stringify(response, null, 2)); + + if (response.error) { + throw new Error(response.error); + } + + // 后端响应格式: { code: 200, message: "角色移除成功" } + let message = '用户角色移除成功'; + if (response.data && response.data.message) { + message = response.data.message; + } + + console.log('✅ [revokeUserRole] 角色移除成功'); + return { success: true, message }; + } catch (error) { + console.error('❌ [revokeUserRole] 移除用户角色失败:', error); + return { + success: false, + message: error instanceof Error ? error.message : '移除失败' + }; + } } /** @@ -498,18 +888,53 @@ export async function assignUserRoles( export async function createRole( roleData: Omit ): Promise<{ success: boolean; message: string; data?: RoleInfo }> { - await new Promise(resolve => setTimeout(resolve, 500)); + try { + // 导入 axios-client 的 post 函数 + const { post } = await import('~/api/axios-client'); - const newRole: RoleInfo = { - ...roleData, - id: Math.max(...mockRoles.map(r => r.id)) + 1, - created_at: new Date().toISOString(), - updated_at: new Date().toISOString() - }; + console.log('🔍 [createRole] 开始调用后端API:', `/api/v3/rbac/roles`, roleData); - mockRoles.push(newRole); + // 使用 axios-client 的 post 函数调用真实后端API + const response = await post(`/api/v3/rbac/roles`, { + role_key: roleData.role_key, + role_name: roleData.role_name, + description: roleData.description || '', + data_scope: roleData.data_scope || 'SELF', + metadata: {} + }); - return { success: true, message: '角色创建成功', data: newRole }; + console.log('📦 [createRole] 后端API完整响应:', JSON.stringify(response, null, 2)); + + if (response.error) { + throw new Error(response.error); + } + + // 后端响应格式: { code: 200, message: "角色创建成功", data: { id, role_key, ... } } + const data = response.data?.data || response.data; + + return { + success: true, + message: response.data?.message || '角色创建成功', + data: { + id: data.id, + role_key: data.role_key, + role_name: data.role_name, + data_scope: data.data_scope, + description: data.description || '', + parent_role_id: data.parent_role_id || null, + priority: data.priority || 0, + is_system_role: data.is_system || false, + created_at: data.created_at, + updated_at: data.updated_at + } + }; + } catch (error) { + console.error('❌ [createRole] 创建角色失败:', error); + return { + success: false, + message: error instanceof Error ? error.message : '创建角色失败' + }; + } } /** @@ -521,40 +946,355 @@ export async function updateRole( roleId: number, roleData: Partial> ): Promise<{ success: boolean; message: string }> { - await new Promise(resolve => setTimeout(resolve, 500)); + try { + // 导入 axios-client 的 put 函数 + const { put } = await import('~/api/axios-client'); - const roleIndex = mockRoles.findIndex(r => r.id === roleId); - if (roleIndex === -1) { - return { success: false, message: '角色不存在' }; + const updatePayload: any = {}; + + if (roleData.role_name !== undefined) updatePayload.role_name = roleData.role_name; + if (roleData.description !== undefined) updatePayload.description = roleData.description; + if (roleData.data_scope !== undefined) updatePayload.data_scope = roleData.data_scope; + if (roleData.priority !== undefined) updatePayload.priority = roleData.priority; + if (roleData.parent_role_id !== undefined) updatePayload.parent_role_id = roleData.parent_role_id; + + console.log('🔍 [updateRole] 开始调用后端API:', `/api/v3/rbac/roles/${roleId}`, updatePayload); + + const response = await put(`/api/v3/rbac/roles/${roleId}`, updatePayload); + + console.log('📦 [updateRole] 后端API完整响应:', JSON.stringify(response, null, 2)); + + if (response.error) { + throw new Error(response.error); + } + + return { success: true, message: response.data?.message || '角色更新成功' }; + } catch (error) { + console.error('❌ [updateRole] 更新角色失败:', error); + return { + success: false, + message: error instanceof Error ? error.message : '更新角色失败' + }; } - - mockRoles[roleIndex] = { - ...mockRoles[roleIndex], - ...roleData, - updated_at: new Date().toISOString() - }; - - return { success: true, message: '角色更新成功' }; } /** * 删除角色 * @param roleId 角色ID + * @param force 是否强制删除(会自动解除用户关联) */ -export async function deleteRole(roleId: number): Promise<{ success: boolean; message: string }> { - await new Promise(resolve => setTimeout(resolve, 500)); +export async function deleteRole( + roleId: number, + force = false +): Promise<{ success: boolean; message: string }> { + try { + // 导入 axios-client 的 del 函数 + const { del } = await import('~/api/axios-client'); - const role = mockRoles.find(r => r.id === roleId); - if (!role) { - return { success: false, message: '角色不存在' }; + const url = `/api/v3/rbac/roles/${roleId}${force ? '?force=true' : ''}`; + + console.log('🔍 [deleteRole] 开始调用后端API:', url); + + const response = await del(url); + + console.log('📦 [deleteRole] 后端API完整响应:', JSON.stringify(response, null, 2)); + + if (response.error) { + throw new Error(response.error); + } + + return { success: true, message: response.data?.message || '角色删除成功' }; + } catch (error) { + console.error('❌ [deleteRole] 删除角色失败:', error); + return { + success: false, + message: error instanceof Error ? error.message : '删除角色失败' + }; + } +} + +// ==================== 权限管理 API ==================== + +/** + * 获取权限列表(树形或平铺) + * @param format 格式:tree(树形)或 flat(平铺) + * @param params 查询参数 + */ +export async function getPermissions( + format: 'tree' | 'flat' = 'tree', + params?: { + module?: string; + permission_type?: 'API' | 'MENU' | 'BUTTON'; + include_system?: boolean; + } +): Promise { + try { + const response = await get(`${RBAC_API_BASE}/permissions`, { + format, + ...params + }); + const data = handleApiResponse(response); + + return data; + } catch (error) { + console.error('获取权限列表失败:', error); + return []; + } +} + +/** + * 获取权限详情 + * @param permissionId 权限ID + */ +export async function getPermissionDetail(permissionId: number): Promise { + try { + const response = await get(`${RBAC_API_BASE}/permissions/${permissionId}`); + const data = handleApiResponse(response); + + return data; + } catch (error) { + console.error('获取权限详情失败:', error); + return null; + } +} + +/** + * 创建权限 + * @param permissionData 权限数据 + */ +export async function createPermission( + permissionData: Omit +): Promise<{ success: boolean; message: string; data?: Permission }> { + try { + // 从 permission_key 解析 module, resource, action + const [module, resource, action] = permissionData.permission_key.split(':'); + + const response = await post(`${RBAC_API_BASE}/permissions`, { + permission_key: permissionData.permission_key, + display_name: permissionData.display_name, + description: permissionData.description || '', + module, + resource, + action, + permission_type: permissionData.permission_type || 'API', + parent_id: permissionData.parent_id || null, + sort_order: permissionData.sort_order || 0, + metadata: {} + }); + + const data = handleApiResponse(response); + + return { + success: true, + message: '权限创建成功', + data + }; + } catch (error) { + console.error('创建权限失败:', error); + return { + success: false, + message: error instanceof Error ? error.message : '创建权限失败' + }; + } +} + +/** + * 更新权限 + * @param permissionId 权限ID + * @param permissionData 权限数据 + */ +export async function updatePermission( + permissionId: number, + permissionData: Partial> +): Promise<{ success: boolean; message: string }> { + try { + const response = await put(`${RBAC_API_BASE}/permissions/${permissionId}`, permissionData); + handleApiResponse(response); + + return { success: true, message: '权限更新成功' }; + } catch (error) { + console.error('更新权限失败:', error); + return { + success: false, + message: error instanceof Error ? error.message : '更新权限失败' + }; + } +} + +/** + * 删除权限 + * @param permissionId 权限ID + */ +export async function deletePermission( + permissionId: number +): Promise<{ success: boolean; message: string }> { + try { + const response = await del(`${RBAC_API_BASE}/permissions/${permissionId}`); + handleApiResponse(response); + + return { success: true, message: '权限删除成功' }; + } catch (error) { + console.error('删除权限失败:', error); + return { + success: false, + message: error instanceof Error ? error.message : '删除权限失败' + }; + } +} + +// ==================== 角色权限关联 API ==================== + +/** + * 获取角色的所有权限 + * @param roleId 角色ID + */ +export async function getRolePermissions(roleId: number): Promise { + try { + const response = await get(`${RBAC_API_BASE}/roles/${roleId}/permissions`); + const data = handleApiResponse<{ permissions: any[] }>(response); + + return data.permissions.map(perm => ({ + id: perm.id, + permission_id: perm.permission_id, + permission_key: perm.permission_key, + display_name: perm.display_name, + grant_type: perm.grant_type || 'GRANT', + data_scope: perm.data_scope || 'ALL' + })); + } catch (error) { + console.error('获取角色权限失败:', error); + return []; + } +} + +/** + * 批量分配权限给角色 + * @param roleId 角色ID + * @param permissions 权限配置列表 + * @param replace 是否替换全部(true=替换,false=追加) + */ +export async function assignPermissionsToRole( + roleId: number, + permissions: RolePermissionConfig[], + replace = false +): Promise<{ success: boolean; message: string }> { + try { + const response = await post(`${RBAC_API_BASE}/roles/${roleId}/permissions`, { + permissions: permissions.map(p => ({ + permission_id: p.permission_id, + grant_type: p.grant_type || 'GRANT', + data_scope: p.data_scope || 'ALL' + })), + replace + }); + + handleApiResponse(response); + + return { success: true, message: '权限分配成功' }; + } catch (error) { + console.error('分配权限失败:', error); + return { + success: false, + message: error instanceof Error ? error.message : '分配权限失败' + }; + } +} + +/** + * 更新单个权限配置 + * @param roleId 角色ID + * @param permissionId 权限ID + * @param config 权限配置 + */ +export async function updateRolePermission( + roleId: number, + permissionId: number, + config: Partial +): Promise<{ success: boolean; message: string }> { + try { + const response = await put( + `${RBAC_API_BASE}/roles/${roleId}/permissions/${permissionId}`, + config + ); + handleApiResponse(response); + + return { success: true, message: '权限配置更新成功' }; + } catch (error) { + console.error('更新权限配置失败:', error); + return { + success: false, + message: error instanceof Error ? error.message : '更新权限配置失败' + }; + } +} + +/** + * 移除角色权限 + * @param roleId 角色ID + * @param permissionId 权限ID + */ +export async function revokeRolePermission( + roleId: number, + permissionId: number +): Promise<{ success: boolean; message: string }> { + try { + const response = await del(`${RBAC_API_BASE}/roles/${roleId}/permissions/${permissionId}`); + handleApiResponse(response); + + return { success: true, message: '权限移除成功' }; + } catch (error) { + console.error('移除权限失败:', error); + return { + success: false, + message: error instanceof Error ? error.message : '移除权限失败' + }; + } +} + +/** + * 获取指定用户的所有角色 + * @param userId 用户ID + * @returns 用户的角色列表 + */ +export async function getUserRoles(userId: number): Promise { + try { + const { get } = await import('~/api/axios-client'); + + console.log('🔍 [getUserRoles] 开始调用后端API:', `/api/v3/rbac/users/${userId}/roles`); + + const response = await get(`/api/v3/rbac/users/${userId}/roles`); + + if (response.error) { + console.error('❌ [getUserRoles] API调用失败:', response.error); + throw new Error(response.error); + } + + // 后端响应格式: { code: 200, msg: "success", data: { user_id, username, roles: [...] } } + let roles: any[] = []; + if (response.data && response.data.data && Array.isArray(response.data.data.roles)) { + roles = response.data.data.roles; + } else if (response.data && Array.isArray(response.data.roles)) { + // 兼容可能的响应格式 + roles = response.data.roles; + } + + console.log('✅ [getUserRoles] 成功获取用户角色,共', roles.length, '个角色'); + + // 将后端数据转换为RoleInfo格式 + return roles.map(role => ({ + id: role.id || role.role_id, + role_key: role.role_key, + role_name: role.role_name, + data_scope: role.data_scope, + description: role.description || '', + priority: role.priority || 0, + is_system_role: role.is_system || false, + created_at: role.created_at || '', + updated_at: role.updated_at || '' + })); + } catch (error) { + console.error('❌ [getUserRoles] 获取用户角色失败:', error); + // 失败时返回空数组 + return []; } - - if (role.is_system_role) { - return { success: false, message: '系统角色不能删除' }; - } - - const roleIndex = mockRoles.indexOf(role); - mockRoles.splice(roleIndex, 1); - - return { success: true, message: '角色删除成功' }; } diff --git a/app/components/layout/Sidebar.tsx b/app/components/layout/Sidebar.tsx index d946488..eb55325 100644 --- a/app/components/layout/Sidebar.tsx +++ b/app/components/layout/Sidebar.tsx @@ -90,12 +90,16 @@ export function Sidebar({ onToggle, collapsed, userRole, frontendJWT = '' }: Sid fetchUserRoutes(); }, [userRole, frontendJWT, navigate]); - // 从 sessionStorage 读取当前选中的模块名称和图片路径 + // 🔑 检查是否处于系统设置模式 + const [isSettingsMode, setIsSettingsMode] = useState(false); + + // 从 sessionStorage 读取当前选中的模块名称和图片路径,以及系统设置模式标志 useEffect(() => { if (typeof window !== 'undefined') { try { const moduleName = sessionStorage.getItem('selectedModuleName'); const modulePicPath = sessionStorage.getItem('selectedModulePicPath'); + const settingsMode = sessionStorage.getItem('settingsMode'); if (moduleName) { setSelectedModuleName(moduleName); @@ -106,6 +110,14 @@ export function Sidebar({ onToggle, collapsed, userRole, frontendJWT = '' }: Sid setSelectedModulePicPath(modulePicPath); console.log('🖼️ [Sidebar] 模块图片路径:', modulePicPath); } + + // 🔑 检查是否处于系统设置模式 + if (settingsMode === 'true') { + setIsSettingsMode(true); + console.log('⚙️ [Sidebar] 进入系统设置模式'); + } else { + setIsSettingsMode(false); + } } catch (error) { console.error('❌ [Sidebar] 读取 sessionStorage 失败:', error); } @@ -154,19 +166,30 @@ export function Sidebar({ onToggle, collapsed, userRole, frontendJWT = '' }: Sid // console.log('子菜单点击:', child.title, '路径:', child.path); }; - const isPort51707 = typeof window !== 'undefined' && window.location.port === '51707' + // const isPort51707 = typeof window !== 'undefined' && window.location.port === '51707' // 处理菜单项:清理子菜单结构 const processedMenuItems: MenuItem[] = menuItems.filter(item =>{ // console.log('菜单项:', item.title, 'Icon:', item.icon) - // 如果是省局访问 - if(isPort51707){ - if (selectedModuleName === '智慧法务大模型'){ - return item.path && item.path.startsWith('/chat-with-llm') - } - return item.path && item.path.startsWith('/cross-checking') + + // 🔑 优先检查:如果处于系统设置模式,只显示 /settings 及其子路由 + if (isSettingsMode) { + return item.path === '/settings' || item.path?.startsWith('/settings/'); } + // 🔑 重要:非系统设置模式下,隐藏所有 /settings 相关菜单 + if (item.path === '/settings' || item.path?.startsWith('/settings/')) { + return false; + } + + // 如果是省局访问 + // if(isPort51707){ + // if (selectedModuleName === '智慧法务大模型'){ + // return item.path && item.path.startsWith('/chat-with-llm') + // } + // return item.path && item.path.startsWith('/cross-checking') + // } + // 🔑 如果选择了"智慧法务大模型",只显示 /chat-with-llm 相关菜单 if (selectedModuleName === '智慧法务大模型') { return item.path === '/chat-with-llm' || item.path?.startsWith('/chat-with-llm/'); diff --git a/app/components/reviews/FilePreview.tsx b/app/components/reviews/FilePreview.tsx index a0ec862..c86c4ab 100644 --- a/app/components/reviews/FilePreview.tsx +++ b/app/components/reviews/FilePreview.tsx @@ -8,6 +8,10 @@ import { DOCUMENT_URL } from '~/api/axios-client'; import { CollaboraViewer, type CollaboraViewerHandle } from '~/components/collabora/CollaboraViewer'; import { requestPageInfo } from '~/components/collabora/lib/pageInfo'; +// 导入react-pdf的CSS样式(文本层和注释层必需) +import 'react-pdf/dist/esm/Page/TextLayer.css'; +import 'react-pdf/dist/esm/Page/AnnotationLayer.css'; + // 设置worker路径为public目录下的worker文件 // 使用已经下载的兼容版本 (pdfjs-dist v2.12.313) // 2025/09/28 使用新版本的pdfjs-dist v4.8.69 diff --git a/app/components/rules/new/BasicInfo.tsx b/app/components/rules/new/BasicInfo.tsx index a4da014..91e8f36 100644 --- a/app/components/rules/new/BasicInfo.tsx +++ b/app/components/rules/new/BasicInfo.tsx @@ -140,28 +140,26 @@ export function BasicInfo({ onChange, initialData, evaluationPointGroups = [], r // 处理条款号输入框失去焦点 const handleLawArticlesBlur = () => { - if (!lawArticlesText) return; - // 将输入的文本转换为数组 const articles = lawArticlesText .split(',') .map(article => article.trim()) .filter(article => article !== ''); - + // 创建一个新的引用法律对象,保留现有字段 const referencesLaws = { ...(formData.references_laws || {}), - articles: articles.length > 0 ? articles : [] + articles: articles // ✅ 清空时会是空数组 }; - + // 更新表单数据 const newData = { ...formData, references_laws: referencesLaws }; - + setFormData(newData); - + if (onChange) { onChange(newData); } @@ -171,6 +169,9 @@ export function BasicInfo({ onChange, initialData, evaluationPointGroups = [], r useEffect(() => { if (formData.references_laws?.articles && formData.references_laws.articles.length > 0) { setLawArticlesText(formData.references_laws.articles.join(',')); + } else { + // ✅ 当 articles 为空时,也清空输入框 + setLawArticlesText(''); } }, [formData.references_laws?.articles]); diff --git a/app/components/ui/Table.tsx b/app/components/ui/Table.tsx index 6d53da4..1a97354 100644 --- a/app/components/ui/Table.tsx +++ b/app/components/ui/Table.tsx @@ -31,20 +31,23 @@ export function Table>({ className = '', onRow, }: TableProps) { + // 防御性检查:确保 dataSource 始终是数组 + const safeDataSource = dataSource || []; + const getRowKey = (record: T, index: number): string => { if (typeof rowKey === 'function') { return rowKey(record); } return String(record[rowKey]); }; - + return (
{columns.map((column, index) => ( - - {dataSource.length > 0 ? ( - dataSource.map((record, index) => ( + {safeDataSource.length > 0 ? ( + safeDataSource.map((record, index) => ( > = { // 主要 // 梅州 '51703': { - baseUrl: 'http://127.0.0.1:8073', - documentUrl: 'http://127.0.0.1:8073/docauditai/', - uploadUrl: 'http://127.0.0.1:8073/admin/documents', + baseUrl: 'http://172.16.0.55:8073', + documentUrl: 'http://172.16.0.55:8073/docauditai/', + uploadUrl: 'http://172.16.0.55:8073/admin/documents', + collaboraUrl: 'http://172.16.0.81:9980', - appUrl: 'http://10.79.97.17:51703', + appUrl: 'http://172.16.0.34:51703', + oauth: { redirectUri: 'http://10.79.97.17:51703/callback' } - // baseUrl: 'http://nas.7bm.co:8873', - // documentUrl: 'http://nas.7bm.co:8873/docauditai/', - // uploadUrl: 'http://nas.7bm.co:8873/admin/documents' }, @@ -115,11 +114,13 @@ const portConfigs: Record> = { const configs: Record = { // 开发环境 development: { - baseUrl: 'http://172.16.0.78:8073', // FastAPI后端(包含/dify代理) - documentUrl: 'http://172.16.0.78:8073/docauditai/', - uploadUrl: 'http://172.16.0.78:8073/admin/documents', + baseUrl: 'http://172.16.0.55:8073', // FastAPI后端(包含/dify代理) + documentUrl: 'http://172.16.0.55:8073/docauditai/', + uploadUrl: 'http://172.16.0.55:8073/admin/documents', + collaboraUrl: 'http://172.16.0.81:9980', - appUrl: 'http://172.16.0.78:51703', + appUrl: 'http://172.16.0.34:51703', + oauth: { serverUrl: 'http://10.79.112.85', // IDaaS服务器地址 clientId: 'none', @@ -216,6 +217,10 @@ const getConfigFromEnv = (defaultConfig: ApiConfig): ApiConfig => { baseUrl: process.env.NEXT_PUBLIC_API_BASE_URL || defaultConfig.baseUrl, documentUrl: process.env.NEXT_PUBLIC_DOCUMENT_URL || defaultConfig.documentUrl, uploadUrl: process.env.NEXT_PUBLIC_UPLOAD_URL || defaultConfig.uploadUrl, + + collaboraUrl: defaultConfig.collaboraUrl || '', + appUrl: defaultConfig.appUrl || '', + oauth: { serverUrl: process.env.NEXT_PUBLIC_OAUTH_SERVER_URL || defaultConfig.oauth.serverUrl, clientId: process.env.NEXT_PUBLIC_OAUTH_CLIENT_ID || defaultConfig.oauth.clientId, @@ -360,10 +365,10 @@ export const { * 可以安全地在客户端代码中使用 */ export const CLIENT_OAUTH_CONFIG = { - serverUrl: OAUTH_CONFIG.serverUrl, - clientId: OAUTH_CONFIG.clientId, - redirectUri: OAUTH_CONFIG.redirectUri, - appId: OAUTH_CONFIG.appId, + serverUrl: OAUTH_CONFIG.serverUrl as string, + clientId: OAUTH_CONFIG.clientId as string, + redirectUri: OAUTH_CONFIG.redirectUri as string, + appId: OAUTH_CONFIG.appId as string, // 客户端不需要 clientSecret }; diff --git a/app/root.tsx b/app/root.tsx index 54f5d8a..9837aa1 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -87,11 +87,14 @@ function isPathAllowed(pathname: string, allowedPaths: string[]): boolean { } } - // 根路径特殊处理 - if (pathname === '/' || pathname === '/home') { - return true; // 首页通常对所有已登录用户开放 + // 根路径特殊处理(仅根路径 '/' 对所有已登录用户开放) + if (pathname === '/') { + return true; // 根路径重定向到首页,始终允许 } + // /home 路由需要检查路由权限,不再特殊处理 + // 如果用户的 routes 数据中没有 /home,则返回 403 + return false; } @@ -149,7 +152,14 @@ export async function loader({ request }: LoaderFunctionArgs) { // 🔒 RBAC 路由权限检查 const { getUserRoutesByRole } = await import("~/api/auth/user-routes"); // 权限校验需要包含隐藏路由,确保用户可以访问隐藏的功能页面 + // console.log("🔒 [Root Loader] 开始调用 getUserRoutesByRole..."); const routesResult = await getUserRoutesByRole(userRole, frontendJWT, true); + // console.log("🔒 [Root Loader] getUserRoutesByRole 返回结果:", { + // success: routesResult.success, + // hasData: !!routesResult.data, + // error: routesResult.error, + // shouldRedirectToHome: routesResult.shouldRedirectToHome + // }); if (routesResult.success && routesResult.data) { // 从菜单数据中提取所有允许的路径 @@ -165,7 +175,24 @@ export async function loader({ request }: LoaderFunctionArgs) { throw new Response("无权访问此页面", { status: 403 }); } } else { - // 获取路由权限失败,只记录警告,不阻止访问(避免影响正常使用) + // 🔑 检查是否因为认证失败需要重定向到登录页 + if (routesResult.shouldRedirectToHome) { + console.error("❌ [Root Loader] 获取用户路由权限失败,可能是令牌已过期,重定向到登录页"); + console.error("❌ [Root Loader] 错误详情:", routesResult.error); + + // 清除会话并重定向到登录页 + const { sessionStorage } = await import("~/api/login/auth.server"); + const session = await sessionStorage.getSession(request.headers.get("Cookie")); + const destroyedSession = await sessionStorage.destroySession(session); + + return redirect("/login?expired=true", { + headers: { + "Set-Cookie": destroyedSession + } + }); + } + + // 其他错误,只记录警告,不阻止访问(避免影响正常使用) console.warn("⚠️ [Root Loader] 获取用户路由权限失败,跳过权限检查"); } } catch (error) { diff --git a/app/routes/_index.tsx b/app/routes/_index.tsx index 44a104d..c3326b5 100644 --- a/app/routes/_index.tsx +++ b/app/routes/_index.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react'; import { useNavigate, Form, useLoaderData } from '@remix-run/react'; -import { type MetaFunction, type ActionFunctionArgs, LoaderFunctionArgs, redirect } from "@remix-run/node"; +import { type MetaFunction, type ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node"; import styles from "~/styles/pages/home.css?url"; import dayjs from 'dayjs'; import { getUserSession, logout } from "~/api/login/auth.server"; @@ -49,8 +49,21 @@ export async function loader({ request }: LoaderFunctionArgs) { console.warn('⚠️ [Index Loader] 用户角色为空,返回空模块列表'); } - // 返回用户信息和入口模块给客户端 - return Response.json({ userRole, userInfo, entryModules }); + // 🔑 检查用户是否有系统设置权限 + let hasSettingsAccess = false; + if (userRole && frontendJWT) { + const { getUserRoutesByRole } = await import('~/api/auth/user-routes'); + const routesResult = await getUserRoutesByRole(userRole, frontendJWT, true); // includeHidden=true + + if (routesResult.success && routesResult.data) { + // 检查是否存在顶级路由 '/settings' + hasSettingsAccess = routesResult.data.some(route => route.path === '/settings'); + // console.log(`🔑 [Index Loader] 用户${hasSettingsAccess ? '有' : '没有'}系统设置权限`); + } + } + + // 返回用户信息、入口模块和系统设置权限给客户端 + return Response.json({ userRole, userInfo, entryModules, hasSettingsAccess }); } export default function Index() { @@ -62,7 +75,7 @@ export default function Index() { }); // 检查是否通过51707端口访问 - const [isPort51707, setIsPort51707] = useState(false); + // const [isPort51707, setIsPort51707] = useState(false); // 用户信息:优先使用服务端返回的,否则从 localStorage 读取 const [userInfo, setUserInfo] = useState(loaderData.userInfo); @@ -70,7 +83,7 @@ export default function Index() { useEffect(() => { if (typeof window !== 'undefined') { - setIsPort51707(window.location.port === '51707'); + // setIsPort51707(window.location.port === '51707'); // 如果服务端没有返回用户信息,从 localStorage 读取 if (!loaderData.userInfo || !loaderData.userRole) { @@ -91,10 +104,21 @@ export default function Index() { // 打印用户角色 useEffect(() => { - console.log('📋 [Index] 当前用户角色:', userRole); - console.log('👤 [Index] 当前用户信息:', userInfo); + // console.log('📋 [Index] 当前用户角色:', userRole); + // console.log('👤 [Index] 当前用户信息:', userInfo); }, [userRole, userInfo]); + // 🔑 清除系统设置模式标志(当用户返回首页时) + useEffect(() => { + if (typeof window !== 'undefined') { + const settingsMode = sessionStorage.getItem('settingsMode'); + if (settingsMode === 'true') { + sessionStorage.removeItem('settingsMode'); + console.log('🔄 [Index] 清除系统设置模式标志'); + } + } + }, []); // 只在组件挂载时执行一次 + // 更新日期时间 useEffect(() => { const updateDateTime = () => { @@ -200,6 +224,21 @@ export default function Index() { } }; + // 处理进入系统设置 + const handleEnterSettings = () => { + if (typeof window !== 'undefined') { + // 🔑 设置标志:表示用户通过系统设置入口进入 + sessionStorage.setItem('settingsMode', 'true'); + // 清除模块相关的标志(因为不是从入口模块进入) + sessionStorage.removeItem('selectedModuleId'); + sessionStorage.removeItem('selectedModuleName'); + sessionStorage.removeItem('selectedModulePicPath'); + } + + // 跳转到系统设置的默认页面 + navigate('/rule-groups'); + }; + return (
{/* 登出表单 - 隐藏 */} @@ -250,24 +289,45 @@ export default function Index() {
{/* 动态渲染入口模块 */} {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} -
- )) + <> + {loaderData.entryModules.map((module) => ( +
handleModuleClick(module)} + onKeyDown={(e) => handleKeyDown(module, e)} + role="button" + tabIndex={0} + aria-label={module.name} + > + {module.name} + {module.name} +
+ ))} + + {/* 🔑 系统设置入口 - 只有有权限的用户才能看到 */} + {loaderData.hasSettingsAccess && ( +
{ + if (e.key === 'Enter' || e.key === ' ') { + handleEnterSettings(); + } + }} + role="button" + tabIndex={0} + aria-label="系统设置" + > + + 系统设置 +
+ )} + ) : (
暂无可用模块 diff --git a/app/routes/callback.tsx b/app/routes/callback.tsx index 57052a7..30fdd4c 100644 --- a/app/routes/callback.tsx +++ b/app/routes/callback.tsx @@ -145,8 +145,9 @@ export async function loader({ request }: LoaderFunctionArgs) { } console.log("✅ [Callback] 用户信息获取成功"); - // 获取重定向URL - const redirectTo = url.searchParams.get("redirect") || "/"; + // 🔑 强制重定向到首页,确保用户选择入口模块并初始化 sessionStorage + // 忽略 redirect 参数,总是跳转到首页让用户选择模块 + const redirectTo = "/"; // 调用后端登录接口,传递 OAuth 用户信息,获取 JWT token const loginRequest: LoginRequest = { @@ -271,7 +272,8 @@ export default function Callback() { // 从 URL 参数中获取 token(如果有) const token = searchParams.get("token"); const userInfo = searchParams.get("userInfo"); - const redirectTo = searchParams.get("redirectTo") || "/"; + // 🔑 强制重定向到首页,确保用户选择入口模块并初始化 sessionStorage + const redirectTo = "/"; if (token && typeof window !== 'undefined') { console.log('🔑 [Callback] 开始保存 token 到 localStorage'); diff --git a/app/routes/entry-modules._index.tsx b/app/routes/entry-modules._index.tsx new file mode 100644 index 0000000..f14dc89 --- /dev/null +++ b/app/routes/entry-modules._index.tsx @@ -0,0 +1,435 @@ +import { useState, useEffect } from "react"; +import { useSearchParams, useNavigate, useLoaderData, useRouteLoaderData } from "@remix-run/react"; +import { ActionFunctionArgs, LoaderFunctionArgs, MetaFunction } from "@remix-run/node"; +import { Table } from "~/components/ui/Table"; +import { Card } from "~/components/ui/Card"; +import { Button } from "~/components/ui/Button"; +import { Pagination } from "~/components/ui/Pagination"; +import { FilterPanel, FilterSelect, SearchFilter } from "~/components/ui/FilterPanel"; +import { toastService } from "~/components/ui/Toast"; +import { + getEntryModules, + deleteEntryModule, + type EntryModule, + type EntryModuleSearchParams +} from "~/api/entry-modules/entry-modules"; +import entryModulesStyles from "~/styles/pages/entry-modules.css?url"; +import { DOCUMENT_URL } from "~/config/api-config"; + +// 引入CSS样式 +export function links() { + return [ + { rel: "stylesheet", href: entryModulesStyles } + ]; +} + +// 页面元数据 +export const meta: MetaFunction = () => { + return [ + { title: "入口模块管理 - 中国烟草AI合同及卷宗审核系统" }, + { name: "description", content: "管理入口模块,包括查看、编辑和删除入口模块" }, + ]; +}; + +// 面包屑配置 +export const handle = { + breadcrumb: "入口模块管理" +}; + + +// 定义加载器返回的数据类型 +interface LoaderData { + modules: EntryModule[]; + total: number; + pageSize: number; + currentPage: number; + error?: string; + frontendJWT?: string | null; +} + +// 加载函数 - 获取入口模块列表 +export async function loader({ request }: LoaderFunctionArgs) { + try { + // 获取用户会话信息 + const { getUserSession } = await import("~/api/login/auth.server"); + const { frontendJWT } = await getUserSession(request); + + const url = new URL(request.url); + const name = url.searchParams.get('name') || undefined; + const area = url.searchParams.get('area') || undefined; + const page = parseInt(url.searchParams.get('page') || '1', 10); + const pageSize = parseInt(url.searchParams.get('pageSize') || '10', 10); + + // 构建搜索参数 + const searchParams: EntryModuleSearchParams = { + name, + area, + page, + pageSize + }; + + const modulesResponse = await getEntryModules(searchParams, frontendJWT); + if (modulesResponse.error) { + console.error("获取入口模块失败:", modulesResponse.error); + throw new Error(modulesResponse.error); + } + const modulesResult = modulesResponse.data?.modules || []; + + return Response.json({ + modules: modulesResult, + total: modulesResponse.data?.total || modulesResult.length, + pageSize, + currentPage: page, + frontendJWT + }); + } catch (error) { + console.error("加载入口模块列表失败:", error); + return Response.json( + { + modules: [], + total: 0, + pageSize: 10, + currentPage: 1, + error: error instanceof Error ? error.message : "加载入口模块列表失败" + }, + { status: 500 } + ); + } +} + +// 动作函数 - 处理删除请求 +export async function action({ request }: ActionFunctionArgs) { + // 获取表单数据 + const formData = await request.formData(); + const id = formData.get("id") as string; + const intent = formData.get("intent") as string; + const { getUserSession } = await import("~/api/login/auth.server"); + const { frontendJWT } = await getUserSession(request); + + if (intent === "delete" && id) { + try { + const result = await deleteEntryModule(parseInt(id), frontendJWT || undefined); + + if (result.error) { + return Response.json({ success: false, error: result.error }, { status: 500 }); + } + + return Response.json({ success: true }); + } catch (error) { + return Response.json( + { success: false, error: error instanceof Error ? error.message : "删除入口模块失败" }, + { status: 500 } + ); + } + } + + return Response.json({ success: false, error: "无效的操作" }, { status: 400 }); +} + +// 地区选项 +const AREA_OPTIONS = [ + { value: "", label: "全部地区" }, + { value: "梅州", label: "梅州" }, + { value: "云浮", label: "云浮" }, + { value: "揭阳", label: "揭阳" }, + { value: "潮州", label: "潮州" }, + { value: "省局", label: "省局" } +]; + +// 入口模块列表组件 +export default function EntryModulesList() { + const navigate = useNavigate(); + const [searchParams, setSearchParams] = useSearchParams(); + const [isDeleting, setIsDeleting] = useState(false); + + // 获取加载器数据 + const { modules, total, error, frontendJWT } = useLoaderData(); + + // 获取用户角色并判断权限 + const rootData = useRouteLoaderData("root") as { userRole: string }; + const userRole = rootData?.userRole || 'common'; + const hasEditPermission = userRole.toLowerCase().includes('admin') || userRole.toLowerCase().includes('developer'); + + // 调试信息 + useEffect(() => { + console.log('📋 [EntryModules] 用户角色:', userRole); + console.log('📋 [EntryModules] 是否有编辑权限:', hasEditPermission); + }, [userRole, hasEditPermission]); + + // 获取搜索参数 + const name = searchParams.get('name') || ''; + const area = searchParams.get('area') || ''; + const currentPage = parseInt(searchParams.get('page') || String(1), 10); + const pageSize = parseInt(searchParams.get('pageSize') || String(10), 10); + + // 处理loader加载数据的时候的错误 + useEffect(() => { + if (error) { + toastService.error(error); + } + }, [error]); + + // 处理名称搜索 + const handleNameSearch = (value: string) => { + const newParams = new URLSearchParams(searchParams); + if (value) { + newParams.set('name', value); + } else { + newParams.delete('name'); + } + newParams.set('page', '1'); + setSearchParams(newParams); + }; + + // 处理筛选变更 + 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'); + + setSearchParams(newParams); + }; + + // 处理重置筛选 + const handleReset = () => { + const nameInput = document.querySelector('input[placeholder="请输入入口模块名称"]'); + if (nameInput) { + (nameInput as HTMLInputElement).value = ''; + } + + // 重置所有筛选条件 + setSearchParams(new URLSearchParams()); + }; + + // 处理删除入口模块 + const handleDelete = async (id: number) => { + if (confirm('确定要删除该入口模块吗?此操作不可撤销。')) { + setIsDeleting(true); + + try { + const formData = new FormData(); + formData.append('id', id.toString()); + formData.append('intent', 'delete'); + + const response = await fetch('/entry-modules', { + method: 'POST', + body: formData + }); + + const result = await response.json(); + + if (result.success) { + toastService.success('删除成功!'); + // 刷新页面 + window.location.reload(); + } else { + toastService.error(`删除失败: ${result.error || '未知错误'}`); + } + } catch (error) { + toastService.error(`删除失败: ${error instanceof Error ? error.message : '未知错误'}`); + } finally { + setIsDeleting(false); + } + } + }; + + // 处理编辑入口模块 + const handleEdit = (id: number) => { + navigate(`/entry-modules/new?id=${id}`); + }; + + // 处理分页变更 + const handlePageChange = (page: number) => { + const newParams = new URLSearchParams(searchParams); + newParams.set('page', page.toString()); + setSearchParams(newParams); + }; + + // 处理每页条数变更 + const handlePageSizeChange = (size: number) => { + const newParams = new URLSearchParams(searchParams); + newParams.set('pageSize', size.toString()); + newParams.set('page', '1'); + setSearchParams(newParams); + }; + + // 表格列定义 + const columns = [ + { + key: 'id', + title: 'ID', + width: '80px', + render: (row: EntryModule) => row.id + }, + { + key: 'name', + title: '模块名称', + width: '200px', + render: (row: EntryModule) => ( + {row.name} + ) + }, + { + key: 'description', + title: '描述', + width: '250px', + render: (row: EntryModule) => ( + {row.description || '-'} + ) + }, + { + key: 'logo', + title: 'Logo图片', + width: '150px', + render: (row: EntryModule) => { + if (!row.path) { + return 未上传; + } + const logoUrl = `${DOCUMENT_URL}${row.path}`; + return ( +
+ {row.name} { + (e.target as HTMLImageElement).style.display = 'none'; + (e.target as HTMLImageElement).parentElement!.innerHTML = '加载失败'; + }} + /> + + 查看 + +
+ ); + } + }, + { + key: 'areas', + title: '适用地区', + width: '200px', + render: (row: EntryModule) => ( +
+ {row.areas && row.areas.length > 0 ? ( + row.areas.map((area, index) => ( + + {area} + + )) + ) : ( + 未设置 + )} +
+ ) + }, + { + key: 'created_at', + title: '创建时间', + width: '180px', + render: (row: EntryModule) => + row.created_at ? new Date(row.created_at).toLocaleString('zh-CN') : '-' + }, + { + key: 'actions', + title: '操作', + width: '150px', + render: (row: EntryModule) => ( +
+ + +
+ ) + } + ]; + + return ( +
+ + {/* 页面头部 */} +
+
+

入口模块管理

+

管理系统入口模块,包括Logo图片和适用地区设置

+
+ {hasEditPermission && ( + + )} +
+ + {/* 筛选面板 */} + + + + + + {/* 表格 */} +
>({
+ + {/* 分页 */} + {total > 0 && ( + + )} + + + ); +} diff --git a/app/routes/entry-modules.new.tsx b/app/routes/entry-modules.new.tsx new file mode 100644 index 0000000..4b11b79 --- /dev/null +++ b/app/routes/entry-modules.new.tsx @@ -0,0 +1,409 @@ +import { useState, useEffect, useRef } from "react"; +import { useNavigate, useSearchParams, useLoaderData } from "@remix-run/react"; +import { ActionFunctionArgs, LoaderFunctionArgs, MetaFunction } from "@remix-run/node"; +import { Card } from "~/components/ui/Card"; +import { Button } from "~/components/ui/Button"; +import { toastService } from "~/components/ui/Toast"; +import { Modal } from "~/components/ui/Modal"; +import { + getEntryModuleById, + createEntryModule, + updateEntryModule, + type EntryModule +} from "~/api/entry-modules/entry-modules"; +import { API_BASE_URL, DOCUMENT_URL } from "~/config/api-config"; + +// 页面元数据 +export const meta: MetaFunction = () => { + return [ + { title: "入口模块编辑 - 中国烟草AI合同及卷宗审核系统" }, + { name: "description", content: "创建或编辑入口模块" }, + ]; +}; + +export const handle = { + breadcrumb: "新建/编辑入口模块", + previousRoute: { + title: "入口模块管理", + to: "/entry-modules" + } +}; + +// 定义加载器返回的数据类型 +interface LoaderData { + module?: EntryModule; + error?: string; + frontendJWT?: string | null; +} + +// 加载函数 - 获取入口模块数据(编辑模式) +export async function loader({ request }: LoaderFunctionArgs) { + try { + const { getUserSession } = await import("~/api/login/auth.server"); + const { frontendJWT } = await getUserSession(request); + + const url = new URL(request.url); + const id = url.searchParams.get('id'); + + if (id) { + const moduleResponse = await getEntryModuleById(parseInt(id), frontendJWT); + if (moduleResponse.error) { + throw new Error(moduleResponse.error); + } + return Response.json({ + module: moduleResponse.data, + frontendJWT + }); + } + + return Response.json({ frontendJWT }); + } catch (error) { + console.error("加载入口模块失败:", error); + return Response.json( + { + error: error || "加载入口模块失败", + status: 500 + } + ); + } +} + +// 地区选项 +const AREA_OPTIONS = [ + { value: "梅州", label: "梅州" }, + { value: "云浮", label: "云浮" }, + { value: "揭阳", label: "揭阳" }, + { value: "潮州", label: "潮州" }, + { value: "省局", label: "省局" } +]; + +// 入口模块新建/编辑组件 +export default function EntryModuleNew() { + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const { module, error, frontendJWT } = useLoaderData(); + + const id = searchParams.get('id'); + const isEditMode = !!id; + + // 表单状态 + const [name, setName] = useState(module?.name || ''); + const [description, setDescription] = useState(module?.description || ''); + const [selectedAreas, setSelectedAreas] = useState(module?.areas || []); + const [logoFile, setLogoFile] = useState(null); + const [logoPreview, setLogoPreview] = useState( + module?.path ? `${DOCUMENT_URL}${module.path}` : null + ); + const [isSubmitting, setIsSubmitting] = useState(false); + const [showConfirmModal, setShowConfirmModal] = useState(false); + + const fileInputRef = useRef(null); + + // 处理loader加载数据的时候的错误 + useEffect(() => { + if (error) { + toastService.error(error); + } + }, [error]); + + // 处理logo文件选择 + const handleLogoChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) { + // 验证文件类型 + if (!file.type.startsWith('image/')) { + toastService.error('请选择图片文件'); + return; + } + + // 验证文件大小(限制5MB) + if (file.size > 5 * 1024 * 1024) { + toastService.error('图片大小不能超过5MB'); + return; + } + + setLogoFile(file); + + // 生成预览 + const reader = new FileReader(); + reader.onload = (event) => { + setLogoPreview(event.target?.result as string); + }; + reader.readAsDataURL(file); + } + }; + + // 处理地区选择 + const handleAreaToggle = (area: string) => { + setSelectedAreas(prev => { + if (prev.includes(area)) { + return prev.filter(a => a !== area); + } else { + return [...prev, area]; + } + }); + }; + + // 验证表单 + const validateForm = () => { + if (!name.trim()) { + toastService.error('请输入模块名称'); + return false; + } + + if (selectedAreas.length === 0) { + toastService.error('请至少选择一个适用地区'); + return false; + } + + return true; + }; + + // 上传logo图片 + const uploadLogo = async (): Promise => { + if (!logoFile) return module?.path || null; + + try { + const formData = new FormData(); + formData.append('file', logoFile); + formData.append('folder', 'entryModule'); + + const response = await fetch(`${API_BASE_URL}/admin/upload`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${frontendJWT}` + }, + body: formData + }); + + if (!response.ok) { + throw new Error('图片上传失败'); + } + + const result = await response.json(); + console.log('图片上传结果:', result); + + // 根据后端返回的数据结构提取路径 + if (result.data?.path) { + return result.data.path; + } else if (result.path) { + return result.path; + } else { + throw new Error('未获取到图片路径'); + } + } catch (error) { + console.error('上传logo失败:', error); + throw error; + } + }; + + // 处理表单提交 + const handleSubmit = async () => { + if (!validateForm()) return; + + setIsSubmitting(true); + + try { + // 上传logo + let logoPath = module?.path || null; + if (logoFile) { + logoPath = await uploadLogo(); + } + + const moduleData = { + name: name.trim(), + description: description.trim() || undefined, + path: logoPath, + areas: selectedAreas + }; + + let result; + if (isEditMode) { + result = await updateEntryModule(parseInt(id!), moduleData, frontendJWT); + } else { + result = await createEntryModule(moduleData, frontendJWT); + } + + if (result.error) { + toastService.error(result.error); + return; + } + + toastService.success(isEditMode ? '更新成功!' : '创建成功!'); + setTimeout(() => { + navigate('/entry-modules'); + }, 1000); + } catch (error) { + console.error('提交失败:', error); + toastService.error(error instanceof Error ? error.message : '操作失败,请重试'); + } finally { + setIsSubmitting(false); + } + }; + + // 处理取消 + const handleCancel = () => { + setShowConfirmModal(true); + }; + + // 确认取消 + const confirmCancel = () => { + navigate('/entry-modules'); + }; + + return ( +
+ + {/* 页面头部 */} +
+
+

+ {isEditMode ? '编辑入口模块' : '新建入口模块'} +

+

+ {isEditMode ? '修改入口模块信息' : '创建新的入口模块'} +

+
+
+ + {/* 表单内容 */} +
+ {/* 模块名称 */} +
+ + setName(e.target.value)} + placeholder="请输入模块名称,如:合同管理" + maxLength={255} + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+ + {/* 描述 */} +
+ +