diff --git a/app/api/auth/user-routes.ts b/app/api/auth/user-routes.ts index 96e4a25..558bbb3 100644 --- a/app/api/auth/user-routes.ts +++ b/app/api/auth/user-routes.ts @@ -1,7 +1,34 @@ import { toastService } from '~/components/ui'; -import { postgrestGet } from '../postgrest-client'; +import { apiRequest } from '../axios-client'; -// 路由数据接口 +// 后端返回的路由数据接口 +export interface BackendRouteInfo { + id: number; + route_path: string; + route_name: string; + component: string; + parent_id: number | null; + route_title: string; + icon: string | null; + sort_order: number; + is_hidden: boolean; + is_cache: boolean; + meta: string; + children?: BackendRouteInfo[]; +} + +// 后端API响应接口 +export interface BackendRoutesResponse { + code: number; + msg: string; + data: { + user_id: number; + username: string; + routes: BackendRouteInfo[]; + }; +} + +// 旧的路由数据接口(保留用于兼容) export interface RouteInfo { id: number; path: string; @@ -458,82 +485,87 @@ const FALLBACK_MENU_DATA: Record = { }; /** - * 根据角色获取用户可访问的路由 - * @param roleKey 角色标识 (如: 'admin', 'common', 'deptLeader', 'groupLeader') + * 根据角色获取用户可访问的路由(调用后端统一接口) + * @param roleKey 角色标识 (如: 'admin', 'common', 'deptLeader', 'groupLeader') - 暂时不使用,后端通过JWT自动识别 + * @param jwt JWT token * @returns 用户可访问的路由列表 */ export async function getUserRoutesByRole(roleKey: string, jwt?: string): Promise<{ success: boolean; data?: MenuItem[]; error?: string; shouldRedirectToHome?: boolean }> { try { - console.log(`获取角色 ${roleKey} 的路由权限`); + console.log(`🔍 [User Routes] 获取用户路由,角色: ${roleKey}`); - // 首先获取角色ID - const roleResult = await postgrestGet>("roles", { - filter: { - "role_key": `eq.${roleKey}` - }, - token: jwt - }); - - if (roleResult.error || !roleResult.data || roleResult.data.length === 0) { - console.error("角色不存在:", roleKey); - toastService.error("角色不存在,请联系管理员配置权限后重新登录"); - return { success: false, error: "角色不存在", shouldRedirectToHome: true }; + if (!jwt) { + console.error('❌ [User Routes] JWT token 未提供'); + toastService.error("认证信息缺失,请重新登录"); + return { success: false, error: "JWT token 未提供", shouldRedirectToHome: true }; } - const roleId = roleResult.data[0].id; + // 调用后端统一接口获取用户路由 + // 注意:Authorization 头会由 axios 拦截器自动添加(从 localStorage 读取) + // 但为了确保使用正确的 token,这里仍然显式传递 + const response = await apiRequest( + '/rbac/user/routes', // endpoint (第一个参数) + { + method: 'GET', + headers: { + 'Authorization': `Bearer ${jwt}` + } + } // options (第二个参数) + ); - // 查询角色的路由权限 - const roleRoutesResult = await postgrestGet>("role_route", { - filter: { - "role_id": `eq.${roleId}` - }, - token: jwt - }); + // console.log('🔍 [User Routes] 后端返回:', response); - if (roleRoutesResult.error) { - console.error("查询角色路由关联失败:", roleRoutesResult.error); - toastService.error("查询角色路由关联失败,请稍后再试"); - return { success: false, error: "查询角色路由关联失败", shouldRedirectToHome: true }; + // 检查响应是否成功 + if (response.error) { + console.error('❌ [User Routes] API 请求失败:', response.error); + toastService.error(response.error); + return { success: false, error: response.error, shouldRedirectToHome: true }; } - const roleRoutes = roleRoutesResult.data || []; - const routeIds = roleRoutes.map(item => item.route_id); - - if (routeIds.length === 0) { - console.log(`角色 ${roleKey} 没有分配任何路由权限`); - toastService.error("您的角色没有分配任何路由权限,请联系管理员配置权限"); - return { success: false, error: "角色没有分配任何路由权限", shouldRedirectToHome: true }; + // 检查响应数据 + if (!response.data) { + console.error('❌ [User Routes] 后端未返回数据'); + toastService.error("获取路由数据失败"); + return { success: false, error: "后端未返回数据", shouldRedirectToHome: true }; } - // 查询具体的路由信息 - const routesResult = await postgrestGet("sys_routes", { - filter: { - "id": `in.(${routeIds.join(',')})`, - "is_menu": "eq.1" - }, - order: "parent_id,meta->>order", - token: jwt - }); + const backendResponse = response.data; - if (routesResult.error) { - console.error("查询路由信息失败:", routesResult.error); - toastService.error("查询路由信息失败,请稍后再试"); - return { success: false, error: "查询路由信息失败", shouldRedirectToHome: true }; + // 检查业务状态码(后端使用 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 routes = routesResult.data || []; - - // 构建菜单树 - const menuItems = buildMenuTreeFromRoutes(routes); - - console.log(`角色 ${roleKey} 可访问 ${menuItems.length} 个路由`); + // 检查数据完整性 + if (!backendResponse.data || !Array.isArray(backendResponse.data.routes)) { + console.error('❌ [User Routes] 后端未返回路由数据'); + toastService.error("未获取到路由权限,请联系管理员配置"); + return { success: false, error: "后端未返回路由数据", shouldRedirectToHome: true }; + } + + const routes = backendResponse.data.routes; + + if (routes.length === 0) { + console.log(`⚠️ [User Routes] 用户没有分配任何路由权限`); + toastService.error("您的角色没有分配任何路由权限,请联系管理员配置"); + return { success: false, error: "用户没有分配任何路由权限", shouldRedirectToHome: true }; + } + + // 将后端路由格式转换为前端 MenuItem 格式 + const menuItems = convertBackendRoutesToMenuItems(routes); + + console.log(`✅ [User Routes] 成功获取 ${menuItems.length} 个路由`); + // console.log('📋 [User Routes] 菜单数据:', menuItems); + return { success: true, data: menuItems }; } catch (error) { - console.error("获取用户路由时发生错误:", error); + console.error("❌ [User Routes] 获取用户路由时发生错误:", error); toastService.error("获取用户路由时发生错误,请稍后再试"); - return { - success: false, + return { + success: false, error: `获取用户路由失败: ${error instanceof Error ? error.message : String(error)}`, shouldRedirectToHome: true }; @@ -541,14 +573,76 @@ export async function getUserRoutesByRole(roleKey: string, jwt?: string): Promis } /** - * 从路由信息构建菜单树结构 + * Element UI 图标到 RemixIcon 的映射 + */ +const ICON_MAPPING: Record = { + 'el-icon-s-home': 'ri-home-line', + 'el-icon-house': 'ri-home-4-line', + 'el-icon-document': 'ri-file-text-line', + 'el-icon-edit': 'ri-edit-line', + 'el-icon-connection': 'ri-links-line', + 'el-icon-setting': 'ri-settings-4-line', + 'el-icon-user': 'ri-user-line', + 'el-icon-tickets': 'ri-ticket-line', + 'el-icon-chat-dot-round': 'ri-chat-smile-2-line', + 'el-icon-s-order': 'ri-list-check', + 'el-icon-s-grid': 'ri-grid-line', + 'el-icon-s-comment': 'ri-chat-1-line', + 'el-icon-files': 'ri-file-copy-line', + 'el-icon-folder': 'ri-folder-line', + 'el-icon-upload': 'ri-upload-cloud-line', + 'el-icon-download': 'ri-download-cloud-line', + 'el-icon-search': 'ri-search-line', +}; + +/** + * 转换 Element UI 图标为 RemixIcon + */ +function convertIcon(elementIcon: string | null): string { + if (!elementIcon) { + return 'ri-file-line'; // 默认图标 + } + return ICON_MAPPING[elementIcon] || 'ri-file-line'; +} + +/** + * 将后端路由格式转换为前端 MenuItem 格式 + * @param backendRoutes 后端返回的路由数组 + * @returns MenuItem 数组 + */ +function convertBackendRoutesToMenuItems(backendRoutes: BackendRouteInfo[]): MenuItem[] { + return backendRoutes + .filter(route => !route.is_hidden) // 过滤隐藏的路由 + .map(route => { + const menuItem: MenuItem = { + id: route.route_name || `route-${route.id}`, + title: route.route_title, + path: route.route_path, + icon: convertIcon(route.icon), + order: route.sort_order, + hideBreadcrumb: route.is_hidden + }; + + // 递归处理子路由 + if (route.children && route.children.length > 0) { + menuItem.children = convertBackendRoutesToMenuItems(route.children); + } + + return menuItem; + }) + .sort((a, b) => a.order - b.order); // 按 sort_order 排序 +} + +/** + * 从路由信息构建菜单树结构(旧版本,已废弃) * @param routes 路由信息数组 * @returns 菜单树结构 + * @deprecated 使用 convertBackendRoutesToMenuItems 替代 */ function buildMenuTreeFromRoutes(routes: RouteInfo[]): MenuItem[] { // 转换为MenuItem格式 const menuMap = new Map(); - + routes.forEach(route => { const menuItem: MenuItem = { id: route.name, @@ -558,25 +652,25 @@ function buildMenuTreeFromRoutes(routes: RouteInfo[]): MenuItem[] { order: route.meta.order || 0, requiredRole: route.meta.requiredRole }; - + menuMap.set(route.id, menuItem); }); - + // 构建父子关系 const rootItems: MenuItem[] = []; const itemsWithParent: Array<{ item: MenuItem; parentId: number }> = []; - + routes.forEach(route => { const menuItem = menuMap.get(route.id); if (!menuItem) return; - + if (route.parent_id === 0) { rootItems.push(menuItem); } else { itemsWithParent.push({ item: menuItem, parentId: route.parent_id }); } }); - + // 添加子菜单 itemsWithParent.forEach(({ item, parentId }) => { const parent = menuMap.get(parentId); @@ -587,7 +681,7 @@ function buildMenuTreeFromRoutes(routes: RouteInfo[]): MenuItem[] { parent.children.push(item); } }); - + // 排序 rootItems.sort((a, b) => a.order - b.order); rootItems.forEach(item => { @@ -595,7 +689,7 @@ function buildMenuTreeFromRoutes(routes: RouteInfo[]): MenuItem[] { item.children.sort((a, b) => a.order - b.order); } }); - + return rootItems; } @@ -609,8 +703,14 @@ export function mapUserRoleToRoleKey(userRole: string): string { 'common': 'common', 'admin': 'admin', 'deptLeader': 'deptLeader', - 'groupLeader': 'groupLeader' + 'groupLeader': 'groupLeader', + // 添加常见的后端角色映射 + 'super_admin': 'admin', + 'system_admin': 'admin', + 'user': 'common', + 'developer': 'admin' }; - - return roleMapping[userRole]; + + // 如果找不到映射,返回 userRole 本身(假设后端已经返回了正确的 role_key) + return roleMapping[userRole] || userRole || 'common'; } \ No newline at end of file diff --git a/app/api/axios-client.ts b/app/api/axios-client.ts index 99dbff4..d993532 100644 --- a/app/api/axios-client.ts +++ b/app/api/axios-client.ts @@ -43,6 +43,86 @@ const axiosInstance = axios.create({ } }); +// 请求白名单 - 这些接口不需要添加 Authorization 头 +const AUTH_WHITELIST = [ + '/auth/login', + '/auth/refresh', + '/auth/register', + '/oauth/token', + '/oauth/userinfo' +]; + +/** + * 检查请求URL是否在白名单中 + */ +function isInAuthWhitelist(url?: string): boolean { + if (!url) return false; + return AUTH_WHITELIST.some(path => url.includes(path)); +} + +/** + * 请求拦截器 - 自动添加 Authorization 头 + */ +axiosInstance.interceptors.request.use( + (config) => { + // 检查是否在白名单中 + if (isInAuthWhitelist(config.url)) { + return config; + } + + // 从 localStorage 获取 token (浏览器环境) + if (typeof window !== 'undefined') { + const token = localStorage.getItem('access_token'); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + } + + return config; + }, + (error) => { + return Promise.reject(error); + } +); + +/** + * 自定义错误类:表示需要重新登录 + */ +export class AuthenticationError extends Error { + constructor(message = 'Token 已过期或无效,请重新登录') { + super(message); + this.name = 'AuthenticationError'; + } +} + +/** + * 响应拦截器 - 处理 401 错误(token 过期) + */ +axiosInstance.interceptors.response.use( + (response) => { + return response; + }, + (error) => { + if (isAxiosError(error) && error.response?.status === 401) { + // Token 过期或无效 + console.warn('⚠️ Token 已过期或无效,请重新登录'); + + if (typeof window !== 'undefined') { + // 🌐 客户端环境:清除 localStorage 并跳转 + localStorage.removeItem('access_token'); + localStorage.removeItem('user_info'); + window.location.href = '/login'; + } else { + // 🖥️ 服务端环境:抛出特殊错误,由 loader/action 处理 + console.warn('⚠️ [Server] 检测到 401 错误,抛出 AuthenticationError'); + throw new AuthenticationError('Token 已过期或无效,请重新登录'); + } + } + + return Promise.reject(error); + } +); + // 最大重试次数 const MAX_RETRIES = 2; diff --git a/app/api/evaluation_points/reviews.ts b/app/api/evaluation_points/reviews.ts index a247cfc..bbe9e9d 100644 --- a/app/api/evaluation_points/reviews.ts +++ b/app/api/evaluation_points/reviews.ts @@ -158,7 +158,8 @@ export async function getReviewPoints(fileId: string, request: Request) { token: frontendJWT }; const contractStructureComparisonResponse = await postgrestGet('contract_structure_comparison', contractStructureComparisonParams); - + // console.log('contract_structure_comparison', contractStructureComparisonResponse) + if (contractStructureComparisonResponse.error) { console.error("获取文档附件数据错误:", contractStructureComparisonResponse.error); return Response.json({ error: contractStructureComparisonResponse.error }, { status: contractStructureComparisonResponse.status || 500 }); diff --git a/app/api/evaluation_points/rules-files.ts b/app/api/evaluation_points/rules-files.ts index d733287..1f4a3b0 100644 --- a/app/api/evaluation_points/rules-files.ts +++ b/app/api/evaluation_points/rules-files.ts @@ -100,7 +100,6 @@ export interface DocumentSearchParams { sortOrder?: string; // 排序方式 page?: number; // 当前页码 pageSize?: number; // 每页条数 - token?: string; // JWT token } @@ -169,8 +168,7 @@ export async function getReviewFiles(searchParams: DocumentSearchParams = {}, do reviewStatus, dateFrom, dateTo, - sortOrder = 'upload_time_desc', - token + sortOrder = 'upload_time_desc' } = searchParams; let p_typeid: number[] | null = null; @@ -206,8 +204,8 @@ export async function getReviewFiles(searchParams: DocumentSearchParams = {}, do // 并行执行获取数据和获取总数的请求 const [filesResponse, countResponse] = await Promise.all([ - postgrestPost('rpc/get_review_files_with_details', listParams, token), - postgrestPost('rpc/count_review_files', rpcParams, token) + postgrestPost('rpc/get_review_files_with_details', listParams), + postgrestPost('rpc/count_review_files', rpcParams) ]); // 处理获取文档列表的错误 @@ -318,10 +316,9 @@ export async function getReviewFiles(searchParams: DocumentSearchParams = {}, do * @param id 文件ID * @param auditStatus 审核状态 * @param userId 用户ID - * @param token JWT token (可选) * @returns 更新结果 */ -export async function updateDocumentAuditStatus(id: string, auditStatus: number, userId: string, token?: string): Promise<{ +export async function updateDocumentAuditStatus(id: string, auditStatus: number, userId: string): Promise<{ success?: boolean; error?: string; status?: number; @@ -347,8 +344,7 @@ export async function updateDocumentAuditStatus(id: string, auditStatus: number, { id: parseInt(id), user_id: parseInt(userId) // 确保只能更新自己的文档 - }, - token + } ); console.log('📝 [updateDocumentAuditStatus] postgrestPut响应:', response); diff --git a/app/api/files/documents.ts b/app/api/files/documents.ts index 4d6bab6..70cb7df 100644 --- a/app/api/files/documents.ts +++ b/app/api/files/documents.ts @@ -93,6 +93,36 @@ export interface DocumentUI { updatedAt?: string; pageCount?: number; ocrResult?: unknown; + // 版本管理相关字段 + historyCount?: number; // 历史版本数量(不含当前版本) + previousIssues?: number | null; // 上一个版本的问题数量 + isExpanded?: boolean; // 是否展开历史版本(前端状态) + historyVersions?: DocumentVersionUI[]; // 历史版本列表 +} + +/** + * 文档历史版本结构 + */ +export interface DocumentVersionUI { + id: number; + name: string; + documentNumber: string; + type: string; + typeName: string; + size: number; + auditStatus: number; + fileStatus: string; + issues: number | null; + issuesDiff?: number; // 与上一个版本的问题数量差异(绝对值) + issuesDiffType?: 'increase' | 'decrease' | 'same'; // 差异类型 + uploadTime: string; + fileType: string; + path: string; + isTest: boolean; + updatedAt?: string; + pageCount?: number; + ocrResult?: unknown; + versionNumber?: number; // 版本号(v2, v3, v4...) } /** @@ -419,6 +449,8 @@ export async function getDocumentWithNoUserId(id: string, frontendJWT?: string): if (!id) { return { error: '文档ID不能为空', status: 400 }; } + + // console.log("get单个文档id", id) const response = await postgrestGet( 'documents', @@ -435,6 +467,7 @@ export async function getDocumentWithNoUserId(id: string, frontendJWT?: string): return { error: response.error, status: response.status }; } + // console.log("respose", response) const extractedData = extractApiData(response.data); if (!extractedData || extractedData.length === 0) { return { error: '文档不存在', status: 404 }; @@ -554,7 +587,7 @@ export async function updateDocument(id: string, document: Partial & // 获取更新后的完整文档数据 const updatedResponse = await getDocument(id, userId, frontendJWT); - return updatedResponse; + return updatedResponse; } catch (error) { console.error('更新文档信息失败:', error); return { @@ -562,4 +595,223 @@ export async function updateDocument(id: string, document: Partial & status: 500 }; } +} + +/** + * 获取文档列表(带版本信息)- 使用 RPC 函数 + * @param searchParams 搜索参数 + * @returns 文档列表和总数 + */ +export async function getDocumentsWithVersionInfo(searchParams: DocumentSearchParams = {}): Promise<{ + data?: { documents: DocumentUI[], total: number }; + error?: string; + status?: number; +}> { + try { + const { + page = 1, + pageSize = 10, + name, + documentNumber, + documentType, + auditStatus, + fileStatus, + dateFrom, + dateTo, + reviewType, + userId, + token + } = searchParams; + + // 确保userId必须存在 + if (!userId) { + return { error: '用户身份验证失败,无法获取文档列表', status: 401 }; + } + + // 处理文档类型 + let documentTypes: number[] | undefined; + if (documentType) { + documentTypes = [parseInt(documentType, 10)]; + } else if (reviewType) { + if (reviewType === 'contract') { + documentTypes = [1]; + } else if (reviewType === 'record') { + documentTypes = [2, 3, 155]; + } + } + + // 准备RPC调用参数 + const rpcParams = { + p_user_id: parseInt(userId, 10), + p_page: page, + p_page_size: pageSize, + p_search_name: name || null, + p_search_document_number: documentNumber || null, + p_search_document_types: documentTypes || null, + p_search_audit_status: auditStatus !== undefined ? parseInt(auditStatus, 10) : null, + p_search_file_status: fileStatus || null, + p_search_date_from: dateFrom || null, + p_search_date_to: dateTo || null + }; + + // 并行执行获取数据和获取总数的请求 + const [documentsResponse, countResponse] = await Promise.all([ + postgrestPost('rpc/documents_get_latest_documents_with_version_info', rpcParams, token), + postgrestPost('rpc/documents_count_latest_documents_with_filters', { + p_user_id: rpcParams.p_user_id, + p_search_name: rpcParams.p_search_name, + p_search_document_number: rpcParams.p_search_document_number, + p_search_document_types: rpcParams.p_search_document_types, + p_search_audit_status: rpcParams.p_search_audit_status, + p_search_file_status: rpcParams.p_search_file_status, + p_search_date_from: rpcParams.p_search_date_from, + p_search_date_to: rpcParams.p_search_date_to + }, token) + ]); + + // 处理获取文档列表的错误 + if (documentsResponse.error || !documentsResponse.data) { + return { error: documentsResponse.error || '获取文档数据失败', status: documentsResponse.status || 500 }; + } + + // 处理获取总数的错误 + if (countResponse.error || typeof countResponse.data !== 'number') { + console.error('获取文档总数失败:', countResponse.error); + } + + const totalCount = typeof countResponse.data === 'number' ? countResponse.data : 0; + + // 将RPC返回的数据转换为UI格式 + const documents: DocumentUI[] = documentsResponse.data.map((doc: any) => ({ + id: doc.id, + name: doc.name, + documentNumber: doc.document_number, + type: doc.type_id.toString(), + typeName: doc.type_name || '未知类型', + size: doc.file_size, + auditStatus: doc.audit_status ?? 0, + fileStatus: doc.status || '', + issues: doc.false_count ?? null, + uploadTime: formatDate(doc.updated_at), + fileType: getFileExtension(doc.name), + path: doc.path, + isTest: doc.is_test_document, + updatedAt: formatDate(doc.updated_at), + pageCount: doc.ocr_result?.__meta?.page_count || 0, + ocrResult: doc.ocr_result, + historyCount: doc.history_count || 0, + previousIssues: doc.previous_issues + })); + + return { + data: { + documents, + total: totalCount + } + }; + } catch (error) { + console.error('获取文档列表失败:', error); + return { + error: error instanceof Error ? error.message : '获取文档列表失败', + status: 500 + }; + } +} + +/** + * 获取文档历史版本列表 + * @param documentName 文档名称 + * @param userId 用户ID + * @param excludeId 排除的文档ID(当前最新版本的ID) + * @param token JWT token + * @returns 历史版本列表 + */ +export async function getDocumentHistory( + documentName: string, + userId: string, + excludeId: number, + token?: string +): Promise<{ + data?: DocumentVersionUI[]; + error?: string; + status?: number; +}> { + try { + if (!documentName) { + return { error: '文档名称不能为空', status: 400 }; + } + + if (!userId) { + return { error: '用户身份验证失败', status: 401 }; + } + + // 调用 RPC 函数获取历史版本 + const response = await postgrestPost( + 'rpc/documents_get_document_history', + { + p_document_name: documentName, + p_user_id: parseInt(userId, 10), + p_exclude_id: excludeId + }, + token + ); + + if (response.error || !response.data) { + return { error: response.error || '获取历史版本失败', status: response.status || 500 }; + } + + const historyDocs = response.data; + + // 转换为 UI 格式,并计算问题数量差异 + const documents: DocumentVersionUI[] = historyDocs.map((doc: any, index: number) => { + // 计算与下一个版本(更早的版本)的问题数量差异 + let issuesDiff: number | undefined; + let issuesDiffType: 'increase' | 'decrease' | 'same' | undefined; + + if (index < historyDocs.length - 1) { + const olderDoc = historyDocs[index + 1]; + if (doc.false_count != null && olderDoc.false_count != null) { + const diff = doc.false_count - olderDoc.false_count; + issuesDiff = Math.abs(diff); + if (diff > 0) { + issuesDiffType = 'increase'; + } else if (diff < 0) { + issuesDiffType = 'decrease'; + } else { + issuesDiffType = 'same'; + } + } + } + + return { + id: doc.id, + name: doc.name, + documentNumber: doc.document_number, + type: doc.type_id.toString(), + typeName: doc.type_name || '未知类型', + size: doc.file_size, + auditStatus: doc.audit_status ?? 0, + fileStatus: doc.status || '', + issues: doc.false_count ?? null, + issuesDiff, + issuesDiffType, + uploadTime: formatDate(doc.created_at), + fileType: getFileExtension(doc.name), + path: doc.path, + isTest: doc.is_test_document, + updatedAt: formatDate(doc.updated_at), + pageCount: doc.ocr_result?.__meta?.page_count || 0, + ocrResult: doc.ocr_result, + versionNumber: historyDocs.length - index + }; + }); + + return { data: documents }; + } catch (error) { + console.error('获取文档历史版本失败:', error); + return { + error: error instanceof Error ? error.message : '获取文档历史版本失败', + status: 500 + }; + } } \ No newline at end of file diff --git a/app/api/files/files-upload.ts b/app/api/files/files-upload.ts index 8b36025..b91e68d 100644 --- a/app/api/files/files-upload.ts +++ b/app/api/files/files-upload.ts @@ -175,9 +175,13 @@ export async function uploadContractTemplate( const headers: HeadersInit = { 'Accept': 'application/json' }; - - if (jwtToken) { - headers['Authorization'] = `Bearer ${jwtToken}`; + + // 从 localStorage 获取 token + if (typeof window !== 'undefined') { + const token = localStorage.getItem('access_token'); + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } } // 发送请求 @@ -222,7 +226,6 @@ export async function uploadContractTemplate( * @param mergeMode 合并模式:'overwrite'(覆盖原文档)或 'new'(新建文档记录) * @param isReprocess 是否触发重新处理 * @param remark 备注 - * @param jwtToken JWT token * @returns 上传结果 */ export async function appendContractAttachments( @@ -230,8 +233,7 @@ export async function appendContractAttachments( files: File[], mergeMode: 'overwrite' | 'new' = 'overwrite', isReprocess: boolean = true, - remark?: string, - jwtToken?: string + remark?: string ): Promise<{data: FileUploadResponse; error?: never} | {data?: never; error: string; status?: number}> { try { console.log('【合同附件追加】开始追加附件:', { documentId, fileCount: files.length, mergeMode }); @@ -259,9 +261,13 @@ export async function appendContractAttachments( const headers: HeadersInit = { 'Accept': 'application/json' }; - - if (jwtToken) { - headers['Authorization'] = `Bearer ${jwtToken}`; + + // 从 localStorage 获取 token + if (typeof window !== 'undefined') { + const token = localStorage.getItem('access_token'); + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } } // 发送请求 diff --git a/app/api/home/home.ts b/app/api/home/home.ts index e04daea..88e4851 100644 --- a/app/api/home/home.ts +++ b/app/api/home/home.ts @@ -436,72 +436,7 @@ export async function getHomeData(reviewType?: string | null,userId?: string | n lastMonthIssuesCount = lastMonthType2Count } - // 暂时不会存在没有指定类型得情况,暂不实现。 - // else { - // // 如果没有指定类型,则使用原来的查询方式获取所有类型的问题数量 - // const thisMonthIssuesParams: PostgrestParams = { - // select: 'count', - // filter: { - // and: `(created_at.gte.${startOfThisMonth},created_at.lte.${endOfThisMonth})`, - // 'evaluated_results->result': 'eq.false', - // user_id: `eq.${userId}` - // } - // }; - - // // 添加类型过滤条件 - // if (typeFilter) { - // if (typeFilter.startsWith('(')) { - // thisMonthIssuesParams.or = typeFilter; - // } else { - // const [field, op, value] = typeFilter.split('.'); - // if (!thisMonthIssuesParams.filter) { - // thisMonthIssuesParams.filter = {}; - // } - // thisMonthIssuesParams.filter[field] = `${op}.${value}`; - // } - // } - - // const thisMonthIssuesResponse = await handleApiResponse<{ count: number }[]>( - // postgrestGet('evaluation_results', thisMonthIssuesParams), - // '获取本月问题数据失败', - // [] - // ); - - // // 本月问题数量 - // thisMonthIssuesCount = thisMonthIssuesResponse[0]?.count || 0; - - // // 上月问题数量 - // const lastMonthIssuesParams: PostgrestParams = { - // select: 'count', - // filter: { - // and: `(created_at.gte.${startOfLastMonth},created_at.lte.${endOfLastMonth})`, - // 'evaluated_results->result': 'eq.false', - // user_id: `eq.${userId}` - // } - // }; - - // // 添加类型过滤条件 - // if (typeFilter) { - // if (typeFilter.startsWith('(')) { - // lastMonthIssuesParams.or = typeFilter; - // } else { - // const [field, op, value] = typeFilter.split('.'); - // if (!lastMonthIssuesParams.filter) { - // lastMonthIssuesParams.filter = {}; - // } - // lastMonthIssuesParams.filter[field] = `${op}.${value}`; - // } - // } - - // const lastMonthIssuesResponse = await handleApiResponse<{ count: number }[]>( - // postgrestGet('evaluation_results', lastMonthIssuesParams), - // '获取上月问题数据失败', - // [] - // ); - - // // 上月问题数量 - // lastMonthIssuesCount = lastMonthIssuesResponse[0]?.count || 0; - // } + // 计算问题数量同比增长 let issuesGrowthValue = 0; diff --git a/app/api/login/auth.server.ts b/app/api/login/auth.server.ts index b8b07d6..91ab66e 100644 --- a/app/api/login/auth.server.ts +++ b/app/api/login/auth.server.ts @@ -30,7 +30,7 @@ import { OAUTH_CONFIG, API_BASE_URL } from "~/config/api-config"; * @property {'common'} common - 普通用户,有基本的系统访问权限 * @property {'developer'} developer - 开发者/管理员,有完整的系统管理权限 */ -export type UserRole = 'common' | 'admin' | 'deptLeader' | 'groupLeader'; +export type UserRole = 'common' | 'admin' | 'deptLeader' | 'groupLeader' | string; /** * 用户信息接口,对应 sso_users 表结构 @@ -365,7 +365,7 @@ export async function getUserSession(request: Request) { */ export async function createUserSession(params: { isAuthenticated: boolean; - userRole: UserRole; + userRole: string; redirectTo: string; accessToken?: string; refreshToken?: string; diff --git a/app/api/login/login-client.ts b/app/api/login/login-client.ts new file mode 100644 index 0000000..fa5bc82 --- /dev/null +++ b/app/api/login/login-client.ts @@ -0,0 +1,154 @@ +/** + * 登录客户端 + * 调用后端 /auth/login 接口,传递 OAuth 用户信息,获取 JWT token + */ + +import { API_BASE_URL } from "~/config/api-config"; + +/** + * 登录请求参数(OAuth 方式) + */ +export interface LoginRequest { + userInfo: { + sub: string; + username?: string; + nickname?: string; + email?: string; + phone_number?: string; + ou_id?: string; + ou_name?: string; + is_leader?: boolean; + [key: string]: unknown; + }; + expiresIn: number; + area?: string; +} + +/** + * 登录响应 + */ +export interface LoginResponse { + success: boolean; + data?: { + access_token: string; + token_type: string; + expires_in: number; + user_info: { + user_id: string; + username: string; + nick_name: string; + email?: string; + phone_number?: string; + ou_id: string; + ou_name: string; + is_leader: boolean; + user_role: string; + sub: string; + }; + }; + error?: string; + message?: string; +} + +/** + * 调用后端登录接口(OAuth 方式) + * + * @param loginData 登录数据(OAuth 用户信息) + * @returns 登录响应(包含 JWT token) + */ +export async function loginWithOAuth(loginData: LoginRequest): Promise { + const loginUrl = `${API_BASE_URL}/auth/login`; + + console.log("📝 [Login Client] 调用后端 OAuth 登录接口:", loginUrl); + + try { + const response = await fetch(loginUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Accept": "application/json" + }, + body: JSON.stringify(loginData) + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + console.error("❌ [Login Client] OAuth 登录请求失败:", response.status, errorData); + + return { + success: false, + error: errorData.error || errorData.message || `登录失败: ${response.status}` + }; + } + + const data = await response.json(); + console.log("✅ [Login Client] OAuth 登录成功"); + + return data; + } catch (error) { + console.error("❌ [Login Client] OAuth 登录请求异常:", error); + return { + success: false, + error: error instanceof Error ? error.message : "网络请求失败" + }; + } +} + +/** + * 密码登录请求参数 + */ +export interface PasswordLoginRequest { + username: string; + password: string; +} + +/** + * 调用后端登录接口(密码方式) + * + * @param username 用户名 + * @param password 密码 + * @returns 登录响应(包含 JWT token) + */ +export async function loginWithPassword( + username: string, + password: string +): Promise { + const loginUrl = `${API_BASE_URL}/auth/login`; + + console.log("📝 [Login Client] 调用后端密码登录接口:", loginUrl); + + try { + const response = await fetch(loginUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Accept": "application/json" + }, + body: JSON.stringify({ + username, + password + }) + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + console.error("❌ [Login Client] 密码登录请求失败:", response.status, errorData); + + return { + success: false, + error: errorData.error || errorData.message || `登录失败: ${response.status}` + }; + } + + const data = await response.json(); + console.log("✅ [Login Client] 密码登录成功"); + + return data; + } catch (error) { + console.error("❌ [Login Client] 密码登录请求异常:", error); + return { + success: false, + error: error instanceof Error ? error.message : "网络请求失败" + }; + } +} diff --git a/app/api/postgrest-client.ts b/app/api/postgrest-client.ts index 5ee6eba..09b5be2 100644 --- a/app/api/postgrest-client.ts +++ b/app/api/postgrest-client.ts @@ -106,11 +106,11 @@ function mergeAuthHeaders( return headers; } - // 优先使用显式传入的 token,否则从上下文获取 - const token = explicitToken || 'undefined'; - - // 如果有 token(显式传入或从上下文获取),添加到 Authorization 头部 - if (token) { + // 优先使用显式传入的 token,否则尝试从客户端 localStorage 获取 + const token = explicitToken || (typeof window !== 'undefined' ? localStorage.getItem('access_token') : undefined); + + // 如果有有效的 token(显式传入或从客户端获取),添加到 Authorization 头部 + if (token && token !== 'undefined') { headers['Authorization'] = `Bearer ${token}`; } diff --git a/app/components/auth/ClientAuthGuard.tsx b/app/components/auth/ClientAuthGuard.tsx new file mode 100644 index 0000000..cd5e499 --- /dev/null +++ b/app/components/auth/ClientAuthGuard.tsx @@ -0,0 +1,57 @@ +/** + * 客户端认证守卫组件 + * + * 在客户端检查 localStorage 中的 token + * 如果未认证且访问的是需要认证的路径,则跳转到登录页 + */ + +import { useEffect } from 'react'; +import { useNavigate, useLocation } from '@remix-run/react'; +import { isAuthenticated } from '~/utils/auth-storage'; + +interface ClientAuthGuardProps { + isPublicPath: boolean; +} + +export function ClientAuthGuard({ isPublicPath }: ClientAuthGuardProps) { + const navigate = useNavigate(); + const location = useLocation(); + + useEffect(() => { + console.log('🔍 [Auth Guard] useEffect 触发', { + isPublicPath, + pathname: location.pathname + }); + + // 如果是公共路径,不需要检查认证 + if (isPublicPath) { + console.log('✅ [Auth Guard] 公共路径,跳过认证检查'); + return; + } + + // 检查客户端是否已认证(localStorage 中有 token) + const token = localStorage.getItem('access_token'); + const authenticated = isAuthenticated(); + + console.log('🔍 [Auth Guard] 认证检查', { + token: token ? `${token.substring(0, 20)}...` : null, + authenticated, + pathname: location.pathname + }); + + if (!authenticated) { + console.log('🔒 [Auth Guard] 未认证,重定向到登录页'); + + // 保存当前路径,登录后可以跳转回来 + const redirectTo = location.pathname !== '/login' ? location.pathname : '/'; + + // 跳转到登录页,并传递重定向目标 + navigate(`/login?redirect=${encodeURIComponent(redirectTo)}`, { replace: true }); + } else { + console.log('✅ [Auth Guard] 已认证,允许访问'); + } + }, [isPublicPath, navigate, location.pathname]); + + // 这个组件不渲染任何内容 + return null; +} diff --git a/app/components/layout/Layout.tsx b/app/components/layout/Layout.tsx index 93ca52c..afe5d7f 100644 --- a/app/components/layout/Layout.tsx +++ b/app/components/layout/Layout.tsx @@ -36,6 +36,8 @@ interface Match { export function Layout({ children, userRole = 'developer' as UserRole, frontendJWT = '' }: LayoutProps) { const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const [selectedApp, setSelectedApp] = useState(''); + const [effectiveUserRole, setEffectiveUserRole] = useState(userRole); + const [effectiveFrontendJWT, setEffectiveFrontendJWT] = useState(frontendJWT); const matches = useMatches() as Match[]; const location = useLocation(); @@ -48,6 +50,39 @@ export function Layout({ children, userRole = 'developer' as UserRole, frontendJ match.handle && match.handle.hideBreadcrumb === true ); + // 从 localStorage 读取用户信息和 JWT 作为备用方案 + useEffect(() => { + if (typeof window === 'undefined') return; + + try { + // 如果服务端没有传递 userRole,从 localStorage 读取 + if (!userRole || userRole === '') { + const storedUserInfoStr = localStorage.getItem('user_info'); + if (storedUserInfoStr) { + const storedUserInfo = JSON.parse(storedUserInfoStr); + const storedUserRole = storedUserInfo.user_role || 'common'; + console.log('📖 [Layout] 从 localStorage 读取用户角色:', storedUserRole); + setEffectiveUserRole(storedUserRole as UserRole); + } + } else { + setEffectiveUserRole(userRole); + } + + // 如果服务端没有传递 frontendJWT,从 localStorage 读取 + if (!frontendJWT || frontendJWT === '') { + const storedToken = localStorage.getItem('access_token'); + if (storedToken) { + console.log('📖 [Layout] 从 localStorage 读取 JWT token'); + setEffectiveFrontendJWT(storedToken); + } + } else { + setEffectiveFrontendJWT(frontendJWT); + } + } catch (error) { + console.error('❌ [Layout] 读取 localStorage 失败:', error); + } + }, [userRole, frontendJWT]); + // 从sessionStorage中获取侧边栏状态和reviewType useEffect(() => { // 检查是否为移动端 @@ -62,7 +97,7 @@ export function Layout({ children, userRole = 'developer' as UserRole, frontendJ } else if (savedState) { setSidebarCollapsed(savedState === 'true'); } - + // 从sessionStorage获取reviewType并设置对应的应用模块 if (typeof window !== 'undefined') { try { @@ -111,12 +146,12 @@ export function Layout({ children, userRole = 'developer' as UserRole, frontendJ return (
{/* 侧边栏始终保留,不再使用条件渲染 */} -
diff --git a/app/components/layout/Sidebar.tsx b/app/components/layout/Sidebar.tsx index 79f4e04..7f879d2 100644 --- a/app/components/layout/Sidebar.tsx +++ b/app/components/layout/Sidebar.tsx @@ -11,11 +11,28 @@ interface SidebarProps { selectedApp?: string; // 添加所选应用模块参数 } -// 定义不同应用模块下显示的菜单项ID +// 定义不同应用模块下显示的菜单路径(使用路由路径进行匹配) const APP_MENU_MAP = { - 'contract': ['home', 'contract-template', 'file-management', 'rule-management', 'cross-checking', 'system-settings'], - 'record': ['home', 'file-management', 'rule-management', 'cross-checking', 'system-settings'], - 'model': ['chat-with-llm'] + 'contract': [ + '/home', // 系统概览 + '/documents', // 文档管理 + '/contract-template', // 合同模板 + '/rules', // 评查规则库 + '/cross-checking', // 交叉评查 + // '/chat-with-llm', // AI法务助手 + '/settings' // 系统设置 + ], + 'record': [ + '/home', // 系统概览 + '/documents', // 文档管理 + '/rules', // 评查规则库 + '/cross-checking', // 交叉评查 + // '/chat-with-llm', // AI法务助手 + '/settings' // 系统设置 + ], + 'model': [ + '/chat-with-llm' // AI法务助手 + ] }; // 应用模块名称映射 @@ -62,28 +79,43 @@ export function Sidebar({ onToggle, collapsed, userRole, frontendJWT = '', selec const fetchUserRoutes = async () => { setIsLoadingRoutes(true); try { - console.log('userRole', userRole); + // 优先使用传入的 frontendJWT,否则从 localStorage 读取 + let jwt = frontendJWT; + + if (!jwt && typeof window !== 'undefined') { + jwt = localStorage.getItem('access_token') || ''; + console.log('📖 [Sidebar] 从 localStorage 读取 JWT'); + } + + if (!jwt) { + console.error('❌ [Sidebar] JWT token 未找到'); + setMenuItems([]); + setIsLoadingRoutes(false); + return; + } + + console.log('🔍 [Sidebar] 当前用户角色:', userRole); const roleKey = mapUserRoleToRoleKey(userRole); - const result = await getUserRoutesByRole(roleKey, frontendJWT); - + const result = await getUserRoutesByRole(roleKey, jwt); + if (result.success && result.data) { setMenuItems(result.data); - console.log('用户路由权限加载成功:', result.data); + console.log('✅ [Sidebar] 用户路由权限加载成功:', result.data); } else { - console.error('获取用户路由权限失败:', result.error); - + console.error('❌ [Sidebar] 获取用户路由权限失败:', result.error); + // 如果需要重定向到首页 if (result.shouldRedirectToHome) { - console.log('重定向到首页'); + console.log('🔄 [Sidebar] 重定向到首页'); navigate('/'); return; } - + // 其他错误情况,使用空数组 setMenuItems([]); } } catch (error) { - console.error('获取用户路由权限时发生错误:', error); + console.error('❌ [Sidebar] 获取用户路由权限时发生错误:', error); // 发生异常时也重定向到首页 navigate('/'); return; @@ -93,7 +125,7 @@ export function Sidebar({ onToggle, collapsed, userRole, frontendJWT = '', selec }; fetchUserRoutes(); - }, [userRole, navigate]); + }, [userRole, frontendJWT, navigate]); // 组件挂载后从 sessionStorage 读取初始 reviewType useEffect(() => { @@ -225,37 +257,60 @@ export function Sidebar({ onToggle, collapsed, userRole, frontendJWT = '', selec // console.log('子菜单点击:', child.title, '路径:', child.path); }; - // 获取当前应用模式下应显示的菜单ID列表 - const visibleMenuIds = APP_MENU_MAP[currentApp as keyof typeof APP_MENU_MAP] || APP_MENU_MAP['contract']; - // const visibleMenuIds = APP_MENU_MAP[currentApp as keyof typeof APP_MENU_MAP] - // console.log('当前应用模式:', currentApp, '可见菜单ID:', visibleMenuIds); + // 获取当前应用模式下应显示的菜单路径列表 + const visibleMenuPaths = APP_MENU_MAP[currentApp as keyof typeof APP_MENU_MAP] || APP_MENU_MAP['contract']; + // console.log('当前应用模式:', currentApp, '可见菜单路径:', visibleMenuPaths); // 检查是否通过51707端口访问(省局) // const isPort51708 = typeof window !== 'undefined' && window.location.port === '51708'; const isPort51707 = typeof window !== 'undefined' && window.location.port === '51707'; // 根据当前应用模式过滤菜单项 - const filteredMenuItems = menuItems.filter(item => { - // 如果是51707端口,只显示交叉评查相关菜单 - if (isPort51707) { - // 如果当前应用是智慧法务大模型,只显示AI对话菜单 - if (currentApp === 'model') { - return item.id === 'chat-with-llm' || - (item.path && item.path.startsWith('/chat-with-llm')); - }else{ - return item.id === 'cross-checking' || - (item.path && item.path.startsWith('/cross-checking')) + const filteredMenuItems = menuItems + .filter(item => { + // 如果是51707端口,只显示交叉评查相关菜单 + if (isPort51707) { + // 如果当前应用是智慧法务大模型,只显示AI对话菜单 + if (currentApp === 'model') { + return item.path && item.path.startsWith('/chat-with-llm'); + } else { + return item.path && item.path.startsWith('/cross-checking'); + } } - } + // 检查当前菜单是否在所选应用模式中显示(使用路径匹配) + if (!visibleMenuPaths.includes(item.path)) { + return false; + } - // 检查当前菜单是否在所选应用模式中显示 - if (!visibleMenuIds.includes(item.id)) { - return false; - } + return true; + }) + .map(item => { + // 处理子菜单:过滤隐藏的子菜单 + if (item.children && item.children.length > 0) { + // 过滤掉 hideBreadcrumb=true 的子菜单(这些通常是隐藏菜单) + const visibleChildren = item.children.filter(child => !child.hideBreadcrumb); - return true; - }); + // 如果过滤后没有可见的子菜单,返回不带子菜单的父级(变成可点击的单级菜单) + if (visibleChildren.length === 0) { + const { children, ...itemWithoutChildren } = item; + return itemWithoutChildren; + } + + // 如果还有可见的子菜单,返回带过滤后子菜单的项 + return { ...item, children: visibleChildren }; + } + + // 处理空 children 数组或 undefined 的情况 + if (item.children !== undefined) { + // 如果 children 存在但为空数组,移除它(让父级变成可点击的单级菜单) + const { children, ...itemWithoutChildren } = item; + return itemWithoutChildren; + } + + // 没有子菜单的项直接返回 + return item; + }); // filteredMenuItems = filteredMenuItems.map(item => { // if(item.children && item.children.length > 0){ diff --git a/app/components/reviews/ReviewTabs.tsx b/app/components/reviews/ReviewTabs.tsx index f9e46ef..0b8553d 100644 --- a/app/components/reviews/ReviewTabs.tsx +++ b/app/components/reviews/ReviewTabs.tsx @@ -49,7 +49,7 @@ export function ReviewTabs({ activeTab, onTabChange, children, fileInfo, onConfi // 根据来源页面返回 const previousRoute = fileInfo.previousRoute || 'documents'; const returnTo = previousRoute === 'documents' - ? "/documents" + ? "/documents/list" : previousRoute === 'filesUpload' ? "/files/upload" : "/rules-files"; diff --git a/app/components/ui/IssuesDiff.tsx b/app/components/ui/IssuesDiff.tsx new file mode 100644 index 0000000..b7b43a8 --- /dev/null +++ b/app/components/ui/IssuesDiff.tsx @@ -0,0 +1,59 @@ +/** + * 问题数量差异显示组件 + * 用于显示文档版本之间的问题数量对比 + */ + +interface IssuesDiffProps { + currentIssues: number | null; + previousIssues?: number | null; + issuesDiff?: number; + issuesDiffType?: 'increase' | 'decrease' | 'same'; + className?: string; +} + +export function IssuesDiff({ + currentIssues, + previousIssues, + issuesDiff, + issuesDiffType, + className = '' +}: IssuesDiffProps) { + // 如果当前问题数量为 null,显示 "-" + if (currentIssues === null) { + return -; + } + + // 如果没有上一个版本或上一个版本问题数量为 null,只显示当前数量 + if (previousIssues === null || previousIssues === undefined || issuesDiffType === undefined) { + return {currentIssues}; + } + + // 显示当前数量 + 差异 + return ( +
+ {currentIssues} + {issuesDiff !== undefined && issuesDiffType && ( + + {issuesDiffType === 'increase' && ( + <> + + +{issuesDiff} + + )} + {issuesDiffType === 'decrease' && ( + <> + + -{issuesDiff} + + )} + {issuesDiffType === 'same' && ( + <> + + 0 + + )} + + )} +
+ ); +} diff --git a/app/components/ui/MultiCascader.tsx b/app/components/ui/MultiCascader.tsx index 9d57164..e5ff701 100644 --- a/app/components/ui/MultiCascader.tsx +++ b/app/components/ui/MultiCascader.tsx @@ -113,19 +113,24 @@ const MultiCascader: React.FC = ({
{ if (el) el.indeterminate = !allChecked && someChecked; }} onChange={e => handleItemCheck(option, e.target.checked)} id={`cascader-${option.value}`} /> -
-//
-//
-// - -//
-// -// -// -// -// -// -//
-// -// ); -// } diff --git a/app/routes/contract-search.tsx b/app/routes/contract-search.tsx deleted file mode 100644 index 5f6ec27..0000000 --- a/app/routes/contract-search.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { Outlet } from "@remix-run/react"; -import { type MetaFunction } from "@remix-run/node"; - -export const meta: MetaFunction = () => { - return [ - { title: "智能搜索 - 中国烟草AI合同及卷宗审核系统" }, - { - name: "contract-search", - content: "智能搜索模块,包括智能搜索功能" - } - ]; -}; - -export const handle = { - breadcrumb: "智能搜索" -}; - -/** - * 配置列表路由布局 - */ -export default function ContractSearchLayout() { - return ; -} \ No newline at end of file diff --git a/app/routes/debug.tsx b/app/routes/debug.tsx deleted file mode 100644 index 882ee26..0000000 --- a/app/routes/debug.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { useLocation, Link } from "@remix-run/react"; - -export default function DebugPage() { - const location = useLocation(); - - return ( -
-

路由诊断页面

- -
-

当前路径信息

-
-          {JSON.stringify({
-            pathname: location.pathname,
-            search: location.search,
-            hash: location.hash,
-            key: location.key,
-            state: location.state
-          }, null, 2)}
-        
-
- -
-

测试链接

-
- 首页 - / - 评查点列表 - /rules - 评查点详情 - /rules/1 - 原生链接 - /rules -
-
- -
-

跳转测试

- - - -
-
- ); -} \ No newline at end of file diff --git a/app/routes/documents.edit.tsx b/app/routes/documents.edit.tsx index e3a286b..4b9f62e 100644 --- a/app/routes/documents.edit.tsx +++ b/app/routes/documents.edit.tsx @@ -468,10 +468,10 @@ export default function DocumentEdit() {

修改文档信息

- diff --git a/app/routes/documents._index.tsx b/app/routes/documents.list.tsx similarity index 79% rename from app/routes/documents._index.tsx rename to app/routes/documents.list.tsx index 9bbf5b5..ef562b7 100644 --- a/app/routes/documents._index.tsx +++ b/app/routes/documents.list.tsx @@ -10,7 +10,9 @@ import { FileTag } from "~/components/ui/FileTag"; import { FilterPanel, FilterSelect, SearchFilter, DateRangeFilter } from "~/components/ui/FilterPanel"; import { TableRowSkeleton, LoadingIndicator, NumberSkeleton } from '~/components/ui/SkeletonScreen'; import documentsIndexStyles from "~/styles/pages/documents_index.css?url"; -import { getDocuments, deleteDocument, type DocumentUI } from "~/api/files/documents"; +import documentVersionStyles from "~/styles/components/document-version.css?url"; +import { getDocuments, getDocumentsWithVersionInfo, getDocumentHistory, deleteDocument, type DocumentUI, type DocumentVersionUI } from "~/api/files/documents"; +import { IssuesDiff } from "~/components/ui/IssuesDiff"; import { getDocumentTypes } from "~/api/document-types/document-types"; import { updateDocumentAuditStatus } from "~/api/evaluation_points/rules-files"; import { appendContractAttachments, uploadContractTemplate } from "~/api/files/files-upload"; @@ -22,7 +24,8 @@ import { DOCUMENT_URL } from "~/api/axios-client"; // 导入样式 export function links() { return [ - { rel: "stylesheet", href: documentsIndexStyles } + { rel: "stylesheet", href: documentsIndexStyles }, + { rel: "stylesheet", href: documentVersionStyles } ]; } @@ -34,7 +37,7 @@ export const meta: MetaFunction = () => { ]; }; -// 数据加载器 +// 数据加载器 export const loader = async ({ request }: LoaderFunctionArgs) => { // 获取用户会话信息 const { getUserSession } = await import("~/api/login/auth.server"); @@ -45,7 +48,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { const page = parseInt(url.searchParams.get("page") || "1", 10); const pageSize = parseInt(url.searchParams.get("pageSize") || "10", 10); - // 获取文档类型列表,用于筛选条件 + // 获取文档类型列表(服务端需要显式传递 token,客户端依赖 axios 拦截器) const typesResponse = await getDocumentTypes({ pageSize: 500 }, frontendJWT); const documentTypes = typesResponse.data?.types || []; const documentTypeOptions = documentTypes.map(type => ({ @@ -61,7 +64,6 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { pageSize, documentTypeOptions, userInfo, // 传递用户信息到客户端 - frontendJWT, // 传递前端JWT到客户端 initialLoad: true // 标记这是初始加载 }); }; @@ -180,19 +182,40 @@ export default function DocumentsIndex() { const loaderData = useLoaderData(); const fetcher = useFetcher(); const navigate = useNavigate(); - + // 存储从 sessionStorage 获取的 reviewType const [reviewType, setReviewType] = useState(null); - + // 添加页面加载状态管理 const [isLoadingData, setIsLoadingData] = useState(true); const [documents, setDocuments] = useState([]); const [total, setTotal] = useState(0); const [filteredDocumentTypeOptions, setFilteredDocumentTypeOptions] = useState(loaderData.documentTypeOptions); const dataCache = useRef(null); - + // 添加一个状态来跟踪是否执行了删除操作 const [isDeleting, setIsDeleting] = useState(false); + + // 辅助函数:从 localStorage 获取用户ID(与 token 管理保持一致) + const getUserId = useCallback((): string | undefined => { + if (typeof window === 'undefined') return undefined; + + const userInfoStr = localStorage.getItem('user_info'); + if (!userInfoStr) return undefined; + + try { + const userInfoData = JSON.parse(userInfoStr); + return userInfoData.user_id?.toString(); + } catch (error) { + console.error('解析 localStorage 用户信息失败:', error); + return undefined; + } + }, []); + + // 版本管理:展开的文档行 + const [expandedRows, setExpandedRows] = useState>(new Set()); + // 版本管理:正在加载历史版本的文档 + const [loadingHistory, setLoadingHistory] = useState>(new Set()); // 附件追加和模板上传状态 const [showAttachmentUpload, setShowAttachmentUpload] = useState(false); @@ -240,12 +263,18 @@ export default function DocumentsIndex() { const fetchData = useCallback(async (storedReviewType: string) => { setIsLoadingData(true); loadingBarService.show(); - + try { - // 从loader data中获取用户ID - const userId = loaderData.userInfo?.user_id?.toString(); - - // 构建搜索参数 + // 从 localStorage 获取用户ID(与 token 管理保持一致) + const userId = getUserId(); + if (!userId) { + toastService.error('用户身份验证失败,无法获取文档列表'); + setIsLoadingData(false); + loadingBarService.hide(); + return; + } + + // 构建搜索参数(token 由 axios 拦截器自动从 localStorage 获取) const searchParams = { name: search || undefined, documentNumber: documentNumber || undefined, @@ -257,21 +286,20 @@ export default function DocumentsIndex() { reviewType: storedReviewType || undefined, userId: userId, // 添加用户ID筛选 page: currentPage, - pageSize, - token: loaderData.frontendJWT || undefined // 传递 JWT token + pageSize }; - // 获取文档列表 - const documentsResponse = await getDocuments(searchParams); + // 获取文档列表(带版本信息) + const documentsResponse = await getDocumentsWithVersionInfo(searchParams); if (documentsResponse.error) { throw new Error(documentsResponse.error); } - // 获取经过过滤的文档类型列表 - const filteredTypesResponse = await getDocumentTypes({ - pageSize: 500, - reviewType: storedReviewType || undefined - }, loaderData.frontendJWT || undefined); + // 获取经过过滤的文档类型列表(token 由 axios 拦截器自动获取) + const filteredTypesResponse = await getDocumentTypes({ + pageSize: 500, + reviewType: storedReviewType || undefined + }); const filteredDocumentTypes = filteredTypesResponse.data?.types || []; const filteredOptions = filteredDocumentTypes.map(type => ({ value: type.id, @@ -290,7 +318,7 @@ export default function DocumentsIndex() { setIsLoadingData(false); loadingBarService.hide(); } - }, [search, documentNumber, documentType, auditStatus, fileStatus, dateFrom, dateTo, currentPage, pageSize, loaderData.userInfo]); + }, [search, documentNumber, documentType, auditStatus, fileStatus, dateFrom, dateTo, currentPage, pageSize, getUserId]); // 在组件挂载时从 sessionStorage 获取 reviewType 并加载数据 useEffect(() => { @@ -707,17 +735,17 @@ export default function DocumentsIndex() { // 开始审核 const handleReviewFileClick = async (fileId: number, auditStatus: number | null) => { // 检查audit_status是否为0,如果是则更新为2 - if (auditStatus === 0 || auditStatus === null) { + if (auditStatus === 0 || auditStatus === null) { try { - // 从loader data中获取用户ID - const userId = loaderData.userInfo?.user_id?.toString(); + // 从 localStorage 获取用户ID(与 token 管理保持一致) + const userId = getUserId(); if (!userId) { toastService.error('用户身份验证失败'); return; } - + // console.log('开始审核',fileId,auditStatus) - const response = await updateDocumentAuditStatus(fileId.toString(), 2, userId, loaderData.frontendJWT as string | undefined); + const response = await updateDocumentAuditStatus(fileId.toString(), 2, userId); if (response.error) { console.error('更新文件审核状态失败:', response.error); toastService.error('更新文件审核状态失败:' + (response.error || '未知错误')); @@ -796,8 +824,7 @@ export default function DocumentsIndex() { attachmentFiles, attachmentMergeMode, true, // isReprocess - attachmentRemark || undefined, - loaderData.frontendJWT as string | undefined + attachmentRemark || undefined ); if (result.error) { @@ -868,8 +895,7 @@ export default function DocumentsIndex() { const result = await uploadContractTemplate( templateFile, selectedDocumentId, - undefined, // comparisonId - loaderData.frontendJWT as string | undefined + undefined // comparisonId ); if (result.error) { @@ -896,6 +922,165 @@ export default function DocumentsIndex() { } }; + // 展开/折叠历史版本 + const handleToggleExpand = async (doc: DocumentUI) => { + const newExpanded = new Set(expandedRows); + const newLoading = new Set(loadingHistory); + + if (expandedRows.has(doc.id)) { + // 折叠:移除展开状态 + newExpanded.delete(doc.id); + setExpandedRows(newExpanded); + + // 清空历史版本数据 + setDocuments(prevDocs => + prevDocs.map(d => + d.id === doc.id ? { ...d, historyVersions: undefined, isExpanded: false } : d + ) + ); + } else { + // 展开:加载历史版本 + newExpanded.add(doc.id); + setExpandedRows(newExpanded); + + // 如果还没有加载历史版本,则加载 + if (!doc.historyVersions && doc.historyCount && doc.historyCount > 0) { + newLoading.add(doc.id); + setLoadingHistory(newLoading); + + try { + // 从 localStorage 获取用户ID(与 token 管理保持一致) + const userId = getUserId(); + if (!userId) { + toastService.error('用户身份验证失败'); + newExpanded.delete(doc.id); + setExpandedRows(newExpanded); + newLoading.delete(doc.id); + setLoadingHistory(newLoading); + return; + } + + const result = await getDocumentHistory( + doc.name, + userId, + doc.id + ); + + if (result.data) { + // 更新文档的历史版本数据 + setDocuments(prevDocs => + prevDocs.map(d => + d.id === doc.id + ? { ...d, historyVersions: result.data, isExpanded: true } + : d + ) + ); + } else if (result.error) { + toastService.error(`加载历史版本失败: ${result.error}`); + // 加载失败时取消展开 + newExpanded.delete(doc.id); + setExpandedRows(newExpanded); + } + } catch (error) { + console.error('加载历史版本失败:', error); + toastService.error('加载历史版本失败'); + newExpanded.delete(doc.id); + setExpandedRows(newExpanded); + } finally { + newLoading.delete(doc.id); + setLoadingHistory(newLoading); + } + } else { + // 已经加载过,只更新展开状态 + setDocuments(prevDocs => + prevDocs.map(d => + d.id === doc.id ? { ...d, isExpanded: true } : d + ) + ); + } + } + }; + + // 渲染历史版本行的辅助函数 + const renderHistoryRow = (historyDoc: DocumentVersionUI, parentDoc: DocumentUI) => { + return ( + + + + + +
+ + + v{historyDoc.versionNumber} 版本 + +
+ + {historyDoc.documentNumber} + {formatFileSize(historyDoc.size)} + +
+ + {fileProcessingStatusOptions.find(s => s.value === historyDoc.fileStatus)?.label || '已完成'} +
+ + +
+ + {auditStatusMapping[historyDoc.auditStatus]?.label || '待审核'} +
+ + + + + {historyDoc.uploadTime} + +
+ + + 查看 + + + + 修改 + + + +
+ + + ); + }; + // 表格列定义 const columns = [ @@ -922,31 +1107,56 @@ export default function DocumentsIndex() { key: "name", width:'25%', render: (_: unknown, record: DocumentUI) => ( -
- + {/* 展开/折叠图标(仅在有历史版本时显示) */} + {record.historyCount && record.historyCount > 0 ? ( + loadingHistory.has(record.id) ? ( + + ) : ( + handleToggleExpand(record)} + title={expandedRows.has(record.id) ? '折叠历史版本' : '展开历史版本'} + > + ) + ) : ( + + )} + + -
- {record.name} -
- + + {record.name} + +
+ {record.isTest && ( - 测试 + 测试 )} -
+ {/* 版本徽章 - 始终显示 */} + {record.historyCount !== undefined && record.historyCount > 0 ? + + + v{record.historyCount + 1} {record.historyCount !== undefined && `(共${record.historyCount}个历史版本)`} + : "" + } +
) @@ -1004,9 +1214,26 @@ export default function DocumentsIndex() { { title: "问题数量", key: "issues", - width:"7%", + width:"10%", render: (_: unknown, record: DocumentUI) => ( - record.issues === null ? "-" : record.issues + record.previousIssues + ? 'increase' + : record.issues < record.previousIssues + ? 'decrease' + : 'same' + : undefined + } + /> ) }, { @@ -1236,13 +1463,57 @@ export default function DocumentsIndex() {
{isLoadingData && documents.length === 0 ? ( + ) : documents.length === 0 ? ( +
+ {isLoadingData ? "加载中..." : "暂无数据"} +
) : ( - +
+ + + {columns.map((col, index) => ( + + ))} + + + + {documents.map((doc) => ( + <> + {/* 主文档行 */} + + {columns.map((col, index) => ( + + ))} + + {/* 历史版本行 */} + {doc.isExpanded && doc.historyVersions && doc.historyVersions.length > 0 && ( + <> + {doc.historyVersions.map((historyDoc) => renderHistoryRow(historyDoc, doc))} + + )} + {/* 正在加载历史版本 */} + {doc.isExpanded && loadingHistory.has(doc.id) && ( + + + + )} + + ))} + +
+ {col.title} +
+ {col.render ? col.render(null, doc, index) : (doc as any)[col.key]} +
+
+ + 加载历史版本中... +
+
)}
diff --git a/app/routes/documents.upload.tsx b/app/routes/documents.upload.tsx deleted file mode 100644 index 9a7a88c..0000000 --- a/app/routes/documents.upload.tsx +++ /dev/null @@ -1,632 +0,0 @@ -import { useState, useRef, useCallback } from "react"; -import { type ActionFunctionArgs, type MetaFunction, json } from "@remix-run/node"; -import { Form, useActionData, useNavigation, useSubmit } from "@remix-run/react"; -import { Card } from "~/components/ui/Card"; -import { Button } from "~/components/ui/Button"; -import { Alert } from "~/components/ui/Alert"; -import { UploadArea, type UploadAreaRef } from "~/components/ui/UploadArea"; -import { FileProgress } from "~/components/ui/FileProgress"; -import { FileTag } from "~/components/ui/FileTag"; -import documentUploadStyles from "~/styles/pages/document-upload.css?url"; - -export const links = () => [ - { rel: "stylesheet", href: documentUploadStyles } -]; - -export const meta: MetaFunction = () => { - return [ - { title: "上传文档 - 中国烟草AI合同及卷宗审核系统" }, - { name: "description", content: "上传文档进行AI审核" } - ]; -}; - -export const handle = { - breadcrumb: "上传文档" -}; - -// 模拟API支持的文件类型 -const SUPPORTED_FILE_TYPES = [ - { id: "1", name: "销售合同" }, - { id: "2", name: "采购合同" }, - { id: "3", name: "专卖许可证" }, - { id: "4", name: "行政处罚决定书" }, - { id: "5", name: "承包协议" } -]; - -// 模拟API支持的存储类型 -const STORAGE_TYPES = [ - { id: "minio", name: "MinIO对象存储" }, - { id: "local", name: "本地文件系统" }, - { id: "s3", name: "Amazon S3" } -]; - -// 文件上传完成后的操作选项 -const AFTER_UPLOAD_OPTIONS = [ - { id: "list", name: "返回文档列表" }, - { id: "stay", name: "留在当前页面" }, - { id: "audit", name: "立即开始审核" } -]; - -// 定义接口 -interface UploadedFile { - id: string; - name: string; - size: number; - status: "waiting" | "uploading" | "success" | "error"; - progress: number; - error?: string; - newName?: string; - type: string; -} - -interface ActionData { - success?: boolean; - error?: string; - files?: UploadedFile[]; -} - -// Action函数处理表单提交 -export const action = async ({ request }: ActionFunctionArgs) => { - // 在实际应用中,这里应该处理文件上传逻辑 - // 例如使用FormData API获取文件并调用后端API - - try { - const formData = await request.formData(); - const docType = formData.get("docType") as string; - const docNumber = formData.get("docNumber") as string; - const docRemark = formData.get("docRemark") as string; - const isTestDocument = formData.get("isTestDocument") === "true"; - const storageType = formData.get("storageType") as string; - const afterUpload = formData.get("afterUpload") as string; - - // 在真实情况下,这里将处理文件上传 - // 由于Remix在服务器端不直接处理文件,我们将在客户端处理文件上传 - // 然后将文件信息发送给服务器 - - // 模拟处理过程 - await new Promise(resolve => setTimeout(resolve, 1000)); - - return json({ - success: true, - files: [] // 服务器处理的文件列表将返回这里 - }); - } catch (error) { - console.error("Upload error:", error); - return json( - { - success: false, - error: error instanceof Error ? error.message : "文件上传过程中发生错误" - }, - { status: 400 } - ); - } -}; - -// 格式化文件大小 -function formatFileSize(bytes: number): string { - if (bytes === 0) return "0 Bytes"; - - const k = 1024; - const sizes = ["Bytes", "KB", "MB", "GB"]; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - - return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]; -} - -// 获取文件扩展名 -function getFileExtension(filename: string): string { - return filename.split('.').pop()?.toLowerCase() || ""; -} - -// 检查文件类型是否支持 -function isFileTypeSupported(filename: string): boolean { - const ext = getFileExtension(filename); - return ["pdf", "doc", "docx", "txt"].includes(ext); -} - -export default function DocumentUpload() { - const actionData = useActionData(); - const navigation = useNavigation(); - const submit = useSubmit(); - const uploading = navigation.state === "submitting"; - - const [files, setFiles] = useState([]); - const [showAdvancedOptions, setShowAdvancedOptions] = useState(false); - const [isTestDocument, setIsTestDocument] = useState(false); - const [uploadComplete, setUploadComplete] = useState(false); - const [selectedFileIds, setSelectedFileIds] = useState([]); - - const uploadAreaRef = useRef(null); - const formRef = useRef(null); - - // 处理文件选择 - const handleFilesSelected = useCallback((fileList: FileList) => { - const newFiles: UploadedFile[] = []; - - Array.from(fileList).forEach(file => { - // 检查文件类型 - if (!isFileTypeSupported(file.name)) { - alert(`不支持的文件类型: ${file.name}\n请上传PDF、DOC、DOCX或TXT格式文件`); - return; - } - - // 检查文件大小 - if (file.size > 50 * 1024 * 1024) { // 50MB - alert(`文件过大: ${file.name}\n文件大小不能超过50MB`); - return; - } - - // 检查是否已添加 - const isDuplicate = files.some(f => f.name === file.name && f.size === file.size); - if (isDuplicate) { - alert(`文件已添加: ${file.name}`); - return; - } - - // 添加新文件 - newFiles.push({ - id: `file-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, - name: file.name, - size: file.size, - status: "waiting", - progress: 0, - type: getFileExtension(file.name) - }); - }); - - setFiles(prev => [...prev, ...newFiles]); - // 重置文件输入,允许再次选择相同文件 - uploadAreaRef.current?.resetFileInput(); - }, [files]); - - // 移除文件 - const removeFile = useCallback((fileId: string) => { - setFiles(prev => prev.filter(file => file.id !== fileId)); - setSelectedFileIds(prev => prev.filter(id => id !== fileId)); - }, []); - - // 批量删除文件 - const removeSelectedFiles = useCallback(() => { - if (selectedFileIds.length === 0) return; - - if (confirm(`确定要删除选中的 ${selectedFileIds.length} 个文件吗?`)) { - setFiles(prev => prev.filter(file => !selectedFileIds.includes(file.id))); - setSelectedFileIds([]); - } - }, [selectedFileIds]); - - // 清空文件列表 - const clearAllFiles = useCallback(() => { - if (files.length === 0) return; - - if (confirm('确定要清空文件列表吗?')) { - setFiles([]); - setSelectedFileIds([]); - } - }, [files.length]); - - // 切换文件选择 - const toggleFileSelection = useCallback((fileId: string, selected: boolean) => { - if (selected) { - setSelectedFileIds(prev => [...prev, fileId]); - } else { - setSelectedFileIds(prev => prev.filter(id => id !== fileId)); - } - }, []); - - // 更新文件名 - const updateFileName = useCallback((fileId: string, newName: string) => { - setFiles(prev => - prev.map(file => - file.id === fileId - ? { ...file, newName: newName + '.' + getFileExtension(file.name) } - : file - ) - ); - }, []); - - // 提交表单 - const handleSubmit = useCallback((event: React.FormEvent) => { - event.preventDefault(); - - const form = event.currentTarget; - const docType = form.docType.value; - - // 表单验证 - if (!docType) { - alert('请选择文档类型'); - return; - } - - if (files.length === 0) { - alert('请至少上传一个文档'); - return; - } - - // 创建FormData对象 - const formData = new FormData(form); - formData.append("isTestDocument", isTestDocument.toString()); - - // 在实际应用中,这里应该处理文件上传 - // 如果Remix不能直接处理文件上传,可以考虑使用预签名URL或其他方法 - // 这里我们模拟文件上传进度 - simulateUpload(); - - // 提交表单 - submit(formData, { method: "post", encType: "multipart/form-data" }); - }, [files.length, isTestDocument, submit]); - - // 模拟文件上传进度 - const simulateUpload = useCallback(() => { - const updatedFiles = [...files]; - - // 设置所有文件为上传中状态 - updatedFiles.forEach(file => { - file.status = "uploading"; - file.progress = 0; - }); - - setFiles(updatedFiles); - - // 模拟进度更新 - const interval = setInterval(() => { - setFiles(prevFiles => { - const newFiles = [...prevFiles]; - let allComplete = true; - - newFiles.forEach(file => { - if (file.status === "uploading") { - // 增加进度 - file.progress += Math.random() * 10; - - if (file.progress >= 100) { - file.progress = 100; - - // 模拟有10%概率上传失败 - if (Math.random() > 0.9) { - file.status = "error"; - file.error = "上传失败,请重试"; - } else { - file.status = "success"; - } - } else { - allComplete = false; - } - } - }); - - // 如果所有文件都完成了,停止定时器 - if (allComplete) { - clearInterval(interval); - setTimeout(() => { - // 检查是否有文件上传错误 - const hasErrors = newFiles.some(file => file.status === "error"); - if (!hasErrors) { - setUploadComplete(true); - } - }, 1000); - } - - return newFiles; - }); - }, 200); - }, [files]); - - // 重新上传文件 - const retryUpload = useCallback((fileId: string) => { - setFiles(prev => - prev.map(file => - file.id === fileId - ? { ...file, status: "uploading", progress: 0, error: undefined } - : file - ) - ); - - // 模拟重新上传 - setTimeout(() => { - setFiles(prev => - prev.map(file => { - if (file.id === fileId) { - const success = Math.random() > 0.1; - return { - ...file, - status: success ? "success" : "error", - progress: 100, - error: success ? undefined : "上传失败,请重试" - }; - } - return file; - }) - ); - }, 2000); - }, []); - - // 重置表单,继续上传 - const resetForm = useCallback(() => { - setFiles([]); - setUploadComplete(false); - setSelectedFileIds([]); - formRef.current?.reset(); - }, []); - - return ( -
-
-

上传文档

-
- - -
-
- - - {!uploadComplete ? ( -
-
-
- - -
不同文档类型应用不同的评查规则
-
- -
- - -
如无编号可留空,系统将自动识别
-
-
- -
- - -
- -
- - - - -
- - 标记为测试文档(不计入正式统计) -
- - {files.length > 0 && ( -
-
- 已选择 {selectedFileIds.length} 个文件 -
-
- - -
-
- )} - -
- {files.map(file => ( -
- toggleFileSelection(file.id, e.target.checked)} - disabled={uploading || file.status === "uploading"} - className="mr-3" - /> - - - -
-
- {file.newName || file.name} - {file.status !== "uploading" && ( - - )} -
- -
- {formatFileSize(file.size)} - - {file.status === "waiting" && "等待上传"} - {file.status === "uploading" && "上传中..."} - {file.status === "success" && "上传成功"} - {file.status === "error" && ( - <> - {file.error} - - - )} - -
- -
-
-
-
- -
- -
-
- ))} -
-
- -
-
setShowAdvancedOptions(!showAdvancedOptions)} - > - 高级上传设置 - -
- -
-
-
- - -
选择文档的存储位置
-
- -
- - -
上传完成后自动执行的操作
-
-
-
-
-
- ) : ( -
- - 所有文件上传成功! - - -
- - - -
-
- )} -
-
- ); -} diff --git a/app/routes/examples/TooltipExample.tsx b/app/routes/examples/TooltipExample.tsx deleted file mode 100644 index 6c8c6ac..0000000 --- a/app/routes/examples/TooltipExample.tsx +++ /dev/null @@ -1,236 +0,0 @@ -import { Tooltip } from '../../components/ui/Tooltip'; - -/** - * Tooltip 组件示例 - * 展示不同主题、位置和风格的提示框 - */ -export function TooltipExample() { - return ( -
-

Tooltip 组件示例

- -
- {/* 基础提示框 */} -
-

基础提示框

-
- - - - - - - -
-
- - {/* 不同位置 */} -
-

不同位置

-
- - - - - - - - - - - - - - - -
-
- - {/* 不同主题 */} -
-

不同主题

-
- - - 深色 - - - - - - 浅色 - - - - - - 主题色 - - - - - - 成功 - - - - - - 警告 - - - - - - 错误 - - - - - - 信息 - - -
-
- - {/* 富文本提示框 */} -
-

富文本提示框

-
- -
- CPU使用率: - 32% -
-
- 内存使用率: - 76% -
-
- 磁盘空间: - 245GB/500GB -
-
- } - theme="light" - rich={true} - header="系统性能报告" - footer="更新时间: 2023-10-15 15:30:42" - showArrow={true} - > - - - - -
- 本月销售额: - ¥258,432 -
-
- 环比增长: - +15.8% -
-
- 销售热点: - 电子产品 -
-
- } - theme="dark" - rich={true} - header="销售数据分析" - footer="数据来源: 销售管理系统" - placement="bottom" - > - - -
-
- - {/* 条件渲染示例 */} -
-

状态提示示例

-
- - 95 - - - - 75 - - - - 45 - -
-
- - {/* 自定义样式示例 */} -
-

自定义样式

-
- - - 自定义宽度 - - - - - - 无箭头 - - - - - - 自定义类名 - - -
-
-
-
- ); -} - -export default TooltipExample; \ No newline at end of file diff --git a/app/routes/examples/message-modal.tsx b/app/routes/examples/message-modal.tsx deleted file mode 100644 index 7a8e958..0000000 --- a/app/routes/examples/message-modal.tsx +++ /dev/null @@ -1,204 +0,0 @@ -import { useState } from 'react'; -import { MessageModal, messageService, MessageModalProvider } from '~/components/ui/MessageModal'; -import type { MessageType } from '~/components/ui/MessageModal'; -import { LinksFunction } from '@remix-run/node'; -import messageModalStyles from '~/styles/components/message-modal.css?url'; - -export const links: LinksFunction = () => [ - { rel: "stylesheet", href: messageModalStyles }, -]; - -export default function MessageModalExample() { - const [isModalOpen, setIsModalOpen] = useState(false); - const [modalType, setModalType] = useState('info'); - const [modalTitle, setModalTitle] = useState(''); - const [modalMessage, setModalMessage] = useState(''); - const [withConfirm, setWithConfirm] = useState(false); - const [autoClose, setAutoClose] = useState(false); - - // 打开普通模态框 - const openModal = (type: MessageType, title: string, message: string) => { - setModalType(type); - setModalTitle(title); - setModalMessage(message); - setIsModalOpen(true); - }; - - // 打开各类型的服务提示框 - const showSuccessMessage = () => { - messageService.success('操作成功完成!', { - title: '成功提示', - autoClose: true - }); - }; - - const showErrorMessage = () => { - messageService.error('操作过程中发生错误,请重试。', { - title: '错误提示' - }); - }; - - const showWarningMessage = () => { - messageService.warning('此操作可能产生不可逆转的结果。', { - title: '警告提示', - onConfirm: () => { - messageService.success('您已确认继续操作') - }, - confirmText: '继续操作', - cancelText: '取消' - }); - }; - - const showInfoMessage = () => { - messageService.info('系统将于今晚10点进行升级维护。', { - title: '通知', - autoClose: true, - autoCloseDelay: 5000 - }); - }; - - const showCustomMessage = () => { - messageService.show({ - title: '自定义消息', - message: '这是一个带有自定义内容的消息', - type: 'info', - confirmText: '了解', - children: ( -
-

这里可以放置更复杂的自定义内容。

-
- - 比如详细的操作说明或其他信息。 -
-
- ) - }); - }; - - // 处理确认 - const handleConfirm = () => { - messageService.success('您点击了确认按钮!', { autoClose: true }); - setIsModalOpen(false); - }; - - return ( - -
-

消息模态框示例

- -
-

基本用法

-

您可以直接使用MessageModal组件来控制模态框的显示和隐藏。

- -
- - - - -
- -
- - - -
- - setIsModalOpen(false)} - title={modalTitle} - message={modalMessage} - type={modalType} - autoClose={autoClose} - onConfirm={withConfirm ? handleConfirm : undefined} - confirmText={withConfirm ? "确认" : "我知道了"} - cancelText="取消" - /> -
- -
-

全局消息服务

-

使用messageService可以在任何组件中方便地显示消息提示,而无需手动管理状态。

- -
- - - - - -
-
-
-
- ); -} \ No newline at end of file diff --git a/app/routes/examples/pdfview.tsx b/app/routes/examples/pdfview.tsx deleted file mode 100644 index 5f02fdd..0000000 --- a/app/routes/examples/pdfview.tsx +++ /dev/null @@ -1,192 +0,0 @@ -import React, { useEffect, useRef, useState, useCallback } from 'react'; -import { createPortal } from 'react-dom'; -import { Spin, Tooltip, Input } from 'antd'; -import { - LeftOutlined, - RightOutlined, - PlusCircleOutlined, - MinusCircleOutlined, - FullscreenExitOutlined, - FullscreenOutlined, - CloseCircleOutlined, - ExclamationCircleOutlined, - RotateLeftOutlined, - RotateRightOutlined, - UnorderedListOutlined, -} from '@ant-design/icons'; -import './index.less'; -import { Document, Page, pdfjs } from 'react-pdf'; -import pdfjsWorker from 'react-pdf/dist/esm/pdf.worker.entry'; - -pdfjs.GlobalWorkerOptions.workerSrc = pdfjsWorker; - -const PDFView = ({ - file, - parentDom, - onClose, -}: { - file?: string | null; - parentDom?: HTMLDivElement | null; - onClose?: () => void; -}) => { - const defaultWidth = 600; - const pageDiv = useRef(null); - const [numPages, setNumPages] = useState(0); - const [pageNumber, setPageNumber] = useState(1); - const [pageWidth, setPageWidth] = useState(defaultWidth); - const [fullscreen, setFullscreen] = useState(false); - const [rotation, setRotation] = useState(0); - const [showThumbnails, setShowThumbnails] = useState(false); - const [visiblePages, setVisiblePages] = useState([1]); // 控制可见页面 - - const parent = parentDom || document.body; - - // 加载 PDF 元信息,不渲染全部页面 - const onDocumentLoadSuccess = useCallback(({ numPages }: { numPages: number }) => { - setNumPages(numPages); - }, []); - - const lastPage = () => pageNumber > 1 && setPageNumber(pageNumber - 1); - const nextPage = () => pageNumber < numPages && setPageNumber(pageNumber + 1); - const onPageNumberChange = (e: { target: { value: string } }) => { - let value = Math.max(1, Math.min(numPages, Number(e.target.value) || 1)); - setPageNumber(value); - setVisiblePages([value]); // 只加载当前页 - }; - - const pageZoomIn = () => setPageWidth(pageWidth * 1.2); - const pageZoomOut = () => pageWidth > defaultWidth && setPageWidth(pageWidth * 0.8); - const pageFullscreen = () => { - setPageWidth(fullscreen ? defaultWidth : parent.offsetWidth - 50); - setFullscreen(!fullscreen); - }; - - const rotateLeft = () => setRotation((prev) => (prev - 90) % 360); - const rotateRight = () => setRotation((prev) => (prev + 90) % 360); - const toggleThumbnails = () => setShowThumbnails(!showThumbnails); - - // 动态更新可见页面 - useEffect(() => { - if (!showThumbnails) { - setVisiblePages([pageNumber]); - } else { - // 缩略图模式下限制加载数量,避免卡顿 - const start = Math.max(1, pageNumber - 2); - const end = Math.min(numPages, pageNumber + 2); - setVisiblePages(Array.from({ length: end - start + 1 }, (_, i) => start + i)); - } - }, [pageNumber, showThumbnails, numPages]); - - useEffect(() => setPageNumber(1), [file]); - useEffect(() => { - if( pageDiv.current){ - (pageDiv.current.scrollTop = 0) - } - }, [pageNumber]); - - const renderContent=()=>(
-
-
-
- - -
- } - loading={
} - > - {showThumbnails ? ( -
- {Array.from({ length: numPages }, (_, i) => i + 1).map((page) => ( -
{ - setPageNumber(page); - setShowThumbnails(false); - }} - > - {visiblePages.includes(page) ? ( - } - renderTextLayer={false} // 禁用文本层,提升性能 - renderAnnotationLayer={false} // 禁用注释层 - /> - ) : ( -
第 {page} 页
- )} - 第 {page} 页 -
- ))} -
- ) : ( - } - renderTextLayer={false} // 禁用文本层 - renderAnnotationLayer={false} // 禁用注释层 - error={() => setPageNumber(1)} - /> - )} - -
-
-
-
- - - - {' '} - / {numPages} - - - - - - - - - - - - - - - - - - - - {fullscreen ? : } - - {onClose && ( - - - - )} -
-
-
- ) - if(parentDom){ - return renderContent() - } - return createPortal( - renderContent(), - parent,) -}; - -export default PDFView; \ No newline at end of file diff --git a/app/routes/examples/toast.tsx b/app/routes/examples/toast.tsx deleted file mode 100644 index fe5108b..0000000 --- a/app/routes/examples/toast.tsx +++ /dev/null @@ -1,164 +0,0 @@ -import { useState } from 'react'; -import { Toast, toastService } from '~/components/ui/Toast'; -import type { ToastType } from '~/components/ui/Toast'; -import { LinksFunction } from '@remix-run/node'; -import toastStyles from '~/styles/components/toast.css?url'; - -export const links: LinksFunction = () => [ - { rel: "stylesheet", href: toastStyles }, -]; - -export default function ToastExample() { - const [isToastOpen, setIsToastOpen] = useState(false); - const [toastType, setToastType] = useState('info'); - const [toastMessage, setToastMessage] = useState(''); - const [autoClose, setAutoClose] = useState(true); - - // 打开普通通知 - const openToast = (type: ToastType, message: string) => { - setToastType(type); - setToastMessage(message); - setIsToastOpen(true); - }; - - // 使用服务显示不同类型通知 - const showSuccessToast = () => { - toastService.success('操作成功完成!'); - }; - - const showErrorToast = () => { - toastService.error('操作过程中发生错误,请重试。'); - }; - - const showWarningToast = () => { - toastService.warning('此操作可能产生不可逆转的结果。'); - }; - - const showInfoToast = () => { - toastService.info('系统将于今晚10点进行升级维护。'); - }; - - // 显示多行文本的长通知 - const showLongToast = () => { - toastService.info('这是一个具有很长内容的通知,将自动换行以适应容器宽度,并且最多显示三行,超出部分会被截断。系统会自动处理长文本的换行和截断,确保显示效果一致。'); - }; - - // 短时间内显示多个通知 - const showMultipleToasts = () => { - toastService.success('第一条通知'); - setTimeout(() => { - toastService.info('第二条通知'); - }, 300); - setTimeout(() => { - toastService.warning('第三条通知'); - }, 600); - setTimeout(() => { - toastService.error('第四条通知'); - }, 900); - }; - - return ( -
-

轻量级顶部通知示例

- -
-

基本用法

-

您可以直接使用Toast组件来控制通知的显示和隐藏。

- -
- - - - -
- -
- -
- - setIsToastOpen(false)} - message={toastMessage} - type={toastType} - autoClose={autoClose} - /> -
- -
-

全局通知服务

-

使用toastService可以在任何组件中方便地显示通知,而无需手动管理状态。

- -
- - - - -
- -
- - -
-
-
- ); -} \ No newline at end of file diff --git a/app/routes/home.tsx b/app/routes/home.tsx index dc5c5d5..5bd9c8e 100644 --- a/app/routes/home.tsx +++ b/app/routes/home.tsx @@ -366,17 +366,17 @@ export default function Home() {
- +
- + {/* 最近文档区域 */} - 查看全部} + 查看全部} className="mt-6" >
diff --git a/app/routes/login.tsx b/app/routes/login.tsx index 20833a3..a561092 100644 --- a/app/routes/login.tsx +++ b/app/routes/login.tsx @@ -1,9 +1,10 @@ import { useEffect, useState } from "react"; -import { useActionData, useLoaderData, Form } from "@remix-run/react"; +import { useLoaderData, useNavigate, useFetcher } from "@remix-run/react"; import { type MetaFunction, type LoaderFunctionArgs, type ActionFunctionArgs, redirect } from "@remix-run/node"; import { OAuthClient } from "~/api/login/oauth-client"; import { CLIENT_OAUTH_CONFIG } from "~/config/api-config"; -import { getUserSession, getSession, simpleRootLogin } from "~/api/login/auth.server"; +import { getUserSession, getSession, createUserSession } from "~/api/login/auth.server"; +import { loginWithPassword } from "~/api/login/login-client"; import styles from "~/styles/pages/login.css?url"; import { toastService } from "~/components/ui"; @@ -18,31 +19,24 @@ export const meta: MetaFunction = () => { ]; }; -// 加载器,获取当前会话状态 +// 加载器,获取重定向URL和错误信息 export async function loader({ request }: LoaderFunctionArgs) { - const { isAuthenticated } = await getUserSession(request); - - // 如果已登录,重定向到首页 - if (isAuthenticated) { - return redirect("/"); - } + // ⚠️ 不再检查服务端 session 认证 + // 认证检查改为在客户端通过 localStorage 进行 - // 获取重定向URL并保存到session + // 获取重定向URL const url = new URL(request.url); const redirectTo = url.searchParams.get("redirect") || "/"; - + const session = await getSession(request); - + // 读取 flash 消息(来自 callback 的错误) const loginError = session.get("loginError"); - - session.set("redirectTo", redirectTo); - + // 提交 session 以清除 flash 消息 if (loginError) { const { sessionStorage } = await import("~/api/login/auth.server"); - return Response.json({ - isAuthenticated: false, + return Response.json({ redirectTo, flashError: loginError }, { @@ -51,65 +45,141 @@ export async function loader({ request }: LoaderFunctionArgs) { } }); } - - return Response.json({ - isAuthenticated: false, + + return Response.json({ redirectTo, flashError: null }); } -// 处理表单提交的action函数 +// 处理管理员账密登录 export async function action({ request }: ActionFunctionArgs) { - const formData = await request.formData(); - const intent = formData.get("intent"); - const username = formData.get("username")?.toString().trim(); - const password = formData.get("password")?.toString().trim(); + try { + const formData = await request.formData(); + const username = formData.get("username") as string; + const password = formData.get("password") as string; + const redirectTo = formData.get("redirectTo") as string || "/"; - if (intent === "password_login") { - // 获取重定向目标 - const session = await getSession(request); - const redirectTo = session.get("redirectTo") || "/"; - - // 调用 simpleRootLogin 方法进行登录 - const response = await simpleRootLogin(username || "", password || "", redirectTo); - - // 检查响应状态 - if (response.status === 302) { - // 登录成功,直接返回重定向响应 - return response; - } else { - // 登录失败,返回错误信息(不再使用URL参数) - const errorData = await response.json(); + // 验证输入 + if (!username?.trim()) { return Response.json({ success: false, - error: errorData.error || "登录失败", - retryCount: errorData.retryCount || 0, - isLocked: errorData.isLocked || false, - remainingAttempts: errorData.remainingAttempts || 5 - }, { - status: response.status - }); + error: "请输入用户名" + }, { status: 400 }); } - } - return null; + if (!password?.trim()) { + return Response.json({ + success: false, + error: "请输入密码" + }, { status: 400 }); + } + + console.log("📝 [Login Action] 开始处理管理员登录:", { username }); + + // 调用后端登录接口 + const response = await loginWithPassword(username.trim(), password.trim()); + + if (!response.success || !response.data) { + console.error("❌ [Login Action] 登录失败:", response.error); + return Response.json({ + success: false, + error: response.error || "登录失败,请检查用户名和密码" + }, { status: 401 }); + } + + const { access_token, user_info } = response.data; + + // 验证返回数据 + if (!access_token) { + console.error("❌ [Login Action] 后端未返回 access_token"); + return Response.json({ + success: false, + error: "登录失败:未获取到认证令牌" + }, { status: 500 }); + } + + if (!user_info) { + console.error("❌ [Login Action] 后端未返回 user_info"); + return Response.json({ + success: false, + error: "登录失败:未获取到用户信息" + }, { status: 500 }); + } + + console.log("✅ [Login Action] 登录成功,准备创建 session"); + console.log("👤 [Login Action] 用户角色:", user_info.user_role); // 应该是 "admin" + + // 获取当前 URL 用于构建 callback URL + const url = new URL(request.url); + + // 🔑 重要:将 token 和用户信息作为 URL 参数传递给客户端 + // 复用 OAuth 登录的 callback 页面逻辑 + const callbackUrl = new URL('/callback', url.origin); + callbackUrl.searchParams.set('token', access_token); + callbackUrl.searchParams.set('userInfo', encodeURIComponent(JSON.stringify({ + user_id: user_info.user_id, + username: user_info.username, + nick_name: user_info.nick_name, + email: user_info.email, + phone_number: user_info.phone_number, + ou_id: user_info.ou_id, + ou_name: user_info.ou_name, + is_leader: user_info.is_leader, + user_role: user_info.user_role, + sub: user_info.sub + }))); + callbackUrl.searchParams.set('redirectTo', redirectTo); + + // ✅ 使用统一的 session 创建函数(和 OAuth 登录一样) + return createUserSession({ + isAuthenticated: true, + userRole: user_info.user_role, + redirectTo: callbackUrl.toString(), // 先跳转到 callback 页面保存 token + frontendJWT: access_token, // 保存到 Cookie Session + userInfo: { + user_id: user_info.user_id, + username: user_info.username, + nick_name: user_info.nick_name, + email: user_info.email, + phone_number: user_info.phone_number, + ou_id: user_info.ou_id, + ou_name: user_info.ou_name, + is_leader: user_info.is_leader, + user_role: user_info.user_role, + sub: user_info.sub + } + }); + + } catch (error) { + console.error("❌ [Login Action] 处理登录时发生异常:", error); + return Response.json({ + success: false, + error: error instanceof Error ? error.message : "登录失败,请稍后重试" + }, { status: 500 }); + } } export default function Login() { - const actionData = useActionData(); + const navigate = useNavigate(); const loaderData = useLoaderData(); + const fetcher = useFetcher<{ success: boolean; error?: string }>(); const [isFlipped, setIsFlipped] = useState(false); const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); - - // 从 actionData 或 loaderData 中获取错误信息 - // actionData 的错误优先(来自密码登录) - // loaderData.flashError 次之(来自 OAuth 回调) - const error = actionData?.error || loaderData?.flashError; - const isLocked = actionData?.isLocked || false; - const retryCount = actionData?.retryCount || 0; - const remainingAttempts = actionData?.remainingAttempts || 5; + const [passwordLoginError, setPasswordLoginError] = useState(null); + + // 从 loaderData 中获取 OAuth 回调的错误信息 + const oauthError = loaderData?.flashError; + + // 显示的错误信息:密码登录错误优先,其次是 OAuth 错误 + const error = passwordLoginError || oauthError; + const isLocked = false; // 可以从后端响应中获取 + const retryCount = 0; + const remainingAttempts = 5; + + // 监听 fetcher 的状态 + const isLoading = fetcher.state === "submitting" || fetcher.state === "loading"; // 处理OAuth2.0登录 const handleOAuthLogin = () => { @@ -148,29 +218,55 @@ export default function Login() { // 处理账号密码登录表单提交 const handlePasswordLoginSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + // 清除之前的错误 + setPasswordLoginError(null); + // 检查账户是否被锁定 if (isLocked) { - e.preventDefault(); toastService.error("账户已被锁定,请联系管理员"); return; } - + // 客户端验证 if (!username.trim()) { - e.preventDefault(); toastService.error("请输入用户名"); return; } - + if (!password.trim()) { - e.preventDefault(); toastService.error("请输入密码"); return; } - - // 验证通过,让表单正常提交 + + console.log("📝 [Login] 提交管理员登录表单"); + + // ✅ 使用 fetcher 提交表单到服务端 action + const formData = new FormData(); + formData.append("username", username.trim()); + formData.append("password", password.trim()); + formData.append("redirectTo", loaderData?.redirectTo || "/"); + + fetcher.submit(formData, { + method: "post", + action: "/login" + }); }; + // 处理 fetcher 响应 + useEffect(() => { + if (fetcher.data) { + if (!fetcher.data.success && fetcher.data.error) { + // 登录失败,显示错误 + console.error("❌ [Login] 登录失败:", fetcher.data.error); + setPasswordLoginError(fetcher.data.error); + toastService.error(fetcher.data.error); + } + // 登录成功的情况由 action 中的 redirect 处理,会自动跳转到 callback 页面 + } + }, [fetcher.data]); + useEffect(() => { // 检查OAuth配置是否完整(客户端不需要检查 clientSecret) if (!CLIENT_OAUTH_CONFIG.serverUrl || !CLIENT_OAUTH_CONFIG.clientId) { @@ -279,9 +375,7 @@ export default function Login() {
)} -
- - +
setUsername(e.target.value)} className="form-input" placeholder="请输入用户名" + disabled={isLoading} required />
- +
setPassword(e.target.value)} className="form-input" placeholder="请输入密码" + disabled={isLoading} required />
- - {isLocked && ( @@ -334,7 +430,7 @@ export default function Login() { 账户已被锁定,请联系管理员解锁 )} -
+
- - - - 下载文档 - -
- - - ) : fileType === "pdf" ? ( - /* PDF 文档渲染 */ - { - console.error("PDF加载错误:", error); - setLoadError("PDF文档加载失败:" + (error.message || "未知错误")); - }} - className="flex flex-col items-center" - error={
PDF文档加载失败,请检查链接或网络连接。
} - noData={
无数据
} - loading={
PDF加载中...
} - > - {renderAllPages()} -
- ) : ( - /* Word 文档渲染 */ - <> - {docxLoading ? ( - /* 加载状态显示 */ -
-
-
-
-

Word文档加载中...

- {debugInfo.length > 0 && ( -
-

加载过程:

- {debugInfo.map((info, index) => ( -
{info}
- ))} -
- )} -
- ) : ( - /* 本地渲染的Word文档 */ -
- )} - - )} -
- - - - {/* 抽取内容区域 - 始终显示,但DOCX模式下不交互 */} -
-

抽取内容

-
    - {extractedContent.map((item) => ( - - ))} -
-
- - {/* 添加自定义样式 */} - +``` + +--- + +## 3. RBAC动态路由对接 + +### 3.1 获取用户路由 + +**端点**: +- `GET /user/routes` (别名,推荐) +- `GET /rbac/user/routes` (完整路径) + +**请求头**: +``` +Authorization: Bearer {JWT_TOKEN} +``` + +**响应格式**: + +```typescript +interface RouteResponse { + code: number; // 200表示成功 + msg: string; // 操作消息 + data: { + user_id: number; + username: string; + routes: RouteInfo[]; // 路由树 + }; +} + +interface RouteInfo { + id: number; + route_path: string; // 路由路径,如 "/home" + route_name: string; // 路由名称,如 "Home" + component?: string; // 组件路径,如 "views/Home.vue" + parent_id?: number; // 父路由ID + route_title: string; // 路由标题,用于菜单显示 + icon?: string; // 图标,如 "el-icon-house" + sort_order: number; // 排序 + is_hidden: boolean; // 是否隐藏(不在菜单显示) + is_cache: boolean; // 是否缓存 + meta?: any; // 其他元信息 + children?: RouteInfo[]; // 子路由 +} +``` + +**响应示例**: + +```json +{ + "code": 200, + "msg": "操作成功", + "data": { + "user_id": 6, + "username": "001", + "routes": [ + { + "id": 1, + "route_path": "/", + "route_name": "Layout", + "component": "layout/index", + "route_title": "入口页", + "icon": "el-icon-s-home", + "sort_order": 1, + "is_hidden": false, + "is_cache": true, + "children": [ + { + "id": 2, + "route_path": "/home", + "route_name": "Home", + "component": "views/Home", + "parent_id": 1, + "route_title": "系统首页", + "icon": "el-icon-house", + "sort_order": 2, + "is_hidden": false, + "is_cache": true + }, + { + "id": 3, + "route_path": "/dashboard", + "route_name": "Dashboard", + "component": "views/Dashboard", + "parent_id": 1, + "route_title": "工作台", + "icon": "el-icon-data-line", + "sort_order": 3, + "is_hidden": false, + "is_cache": true + } + ] + } + ] + } +} +``` + +### 3.2 路由注册最佳实践 + +#### 3.2.1 路由守卫配置(router/index.ts) + +```typescript +import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router'; +import { useUserStore } from '@/stores/user'; +import { ElMessage } from 'element-plus'; + +// 静态路由(不需要权限的页面) +const constantRoutes: RouteRecordRaw[] = [ + { + path: '/login', + name: 'Login', + component: () => import('@/views/Login.vue'), + meta: { title: '登录', hidden: true } + }, + { + path: '/404', + name: 'NotFound', + component: () => import('@/views/404.vue'), + meta: { title: '404', hidden: true } + } +]; + +const router = createRouter({ + history: createWebHistory(import.meta.env.BASE_URL), + routes: constantRoutes +}); + +// 白名单(不需要登录的页面) +const whiteList = ['/login', '/404']; + +// 全局前置守卫 +router.beforeEach(async (to, from, next) => { + const userStore = useUserStore(); + + // 恢复登录状态(从localStorage) + if (!userStore.isLoggedIn) { + userStore.restoreLoginState(); + } + + // 已登录 + if (userStore.isLoggedIn) { + if (to.path === '/login') { + // 已登录,访问登录页 → 跳转到首页 + next({ path: '/home' }); + } else { + // 检查是否已加载路由 + if (!userStore.hasLoadedRoutes) { + try { + // 获取用户路由 + await userStore.fetchUserRoutes(); + + // 重新导航到目标路由(因为路由刚刚动态注册) + next({ ...to, replace: true }); + } catch (error) { + console.error('获取路由失败:', error); + ElMessage.error('获取权限失败,请重新登录'); + userStore.logout(); + next({ path: '/login' }); + } + } else { + // 路由已加载,正常放行 + next(); + } + } + } else { + // 未登录 + if (whiteList.includes(to.path)) { + // 白名单内的页面,直接放行 + next(); + } else { + // 其他页面,跳转到登录 + next({ path: '/login', query: { redirect: to.fullPath } }); + } + } +}); + +// 全局后置钩子 +router.afterEach((to) => { + // 设置页面标题 + document.title = (to.meta.title as string) || '智慧法务系统'; +}); + +export default router; +``` + +### 3.3 菜单组件生成 + +#### 3.3.1 侧边栏菜单组件(components/Sidebar.vue) + +```vue + + + +``` + +#### 3.3.2 菜单项组件(components/SidebarItem.vue) + +```vue + + + +``` + +--- + +## 4. PostgREST数据访问 + +### 4.1 PostgREST基础 + +后端使用PostgREST提供RESTful API,所有数据库表都可以通过HTTP访问。 + +**特点**: +- 自动根据表结构生成API +- 支持强大的过滤、排序、分页 +- 前端请求会被后端全局异常处理器拦截并转发到PostgREST + +### 4.2 请求格式 + +#### 4.2.1 查询数据(GET) + +```typescript +// 基础查询 +GET /{table_name} + +// 示例:查询文档列表 +const fetchDocuments = async () => { + const response = await axios.get('/documents', { + headers: { + 'Authorization': `Bearer ${token}` + } + }); + return response.data; +}; + +// 带过滤条件 +GET /documents?user_id=eq.5 + +// 多个条件(AND) +GET /documents?user_id=eq.5&status=eq.0 + +// 选择字段 +GET /documents?select=id,title,created_at + +// 排序 +GET /documents?order=created_at.desc + +// 分页 +GET /documents?limit=20&offset=0 + +// 组合查询 +GET /documents?user_id=eq.5&status=eq.0&select=id,title,created_at&order=created_at.desc&limit=20 +``` + +#### PostgREST过滤操作符 + +| 操作符 | 说明 | 示例 | +|-------|------|------| +| `eq` | 等于 | `id=eq.5` | +| `neq` | 不等于 | `status=neq.1` | +| `gt` | 大于 | `created_at=gt.2025-01-01` | +| `gte` | 大于等于 | `id=gte.10` | +| `lt` | 小于 | `updated_at=lt.2025-12-31` | +| `lte` | 小于等于 | `id=lte.100` | +| `like` | 模糊匹配 | `title=like.*合同*` | +| `ilike` | 不区分大小写模糊匹配 | `title=ilike.*WORD*` | +| `in` | 在列表中 | `id=in.(1,2,3,4,5)` | +| `is` | 是NULL | `deleted_at=is.null` | +| `not.is` | 不是NULL | `deleted_at=not.is.null` | + +#### 4.2.2 创建数据(POST) + +```typescript +// 创建单条记录 +POST /{table_name} +Content-Type: application/json + +{ + "field1": "value1", + "field2": "value2" +} + +// 示例:创建文档 +const createDocument = async (data: any) => { + const response = await axios.post('/documents', data, { + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + return response.data; +}; + +// 批量创建 +POST /documents +Content-Type: application/json + +[ + { "title": "文档1", "user_id": 5 }, + { "title": "文档2", "user_id": 5 } +] +``` + +#### 4.2.3 更新数据(PATCH) + +```typescript +// 更新记录(需要过滤条件) +PATCH /{table_name}?{filter} +Content-Type: application/json + +{ + "field1": "new_value" +} + +// 示例:更新文档 +const updateDocument = async (id: number, data: any) => { + const response = await axios.patch(`/documents?id=eq.${id}`, data, { + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + return response.data; +}; +``` + +#### 4.2.4 删除数据(DELETE) + +```typescript +// 删除记录(需要过滤条件) +DELETE /{table_name}?{filter} + +// 示例:删除文档 +const deleteDocument = async (id: number) => { + const response = await axios.delete(`/documents?id=eq.${id}`, { + headers: { + 'Authorization': `Bearer ${token}` + } + }); + return response.data; +}; +``` + +### 4.3 Axios封装 + +#### 4.3.1 请求拦截器(utils/request.ts) + +```typescript +import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'; +import { ElMessage } from 'element-plus'; +import { useUserStore } from '@/stores/user'; + +// 创建axios实例 +const service: AxiosInstance = axios.create({ + baseURL: import.meta.env.VITE_API_BASE_URL || 'http://172.16.0.55:8073', + timeout: 30000 +}); + +// 请求拦截器 +service.interceptors.request.use( + (config: AxiosRequestConfig) => { + const userStore = useUserStore(); + + // 自动添加Token + if (userStore.token) { + config.headers = config.headers || {}; + config.headers['Authorization'] = `Bearer ${userStore.token}`; + } + + return config; + }, + (error) => { + console.error('请求错误:', error); + return Promise.reject(error); + } +); + +// 响应拦截器 +service.interceptors.response.use( + (response: AxiosResponse) => { + const res = response.data; + + // PostgREST返回数组或对象,不是统一格式 + // 如果是数组或对象,直接返回 + if (Array.isArray(res) || typeof res === 'object' && !res.code) { + return response; + } + + // 统一格式的响应(code/msg/data) + if (res.code !== undefined) { + if (res.code === 200) { + return response; + } else { + ElMessage.error(res.msg || '操作失败'); + return Promise.reject(new Error(res.msg || 'Error')); + } + } + + return response; + }, + (error) => { + console.error('响应错误:', error); + + const userStore = useUserStore(); + + if (error.response) { + switch (error.response.status) { + case 401: + ElMessage.error('登录已过期,请重新登录'); + userStore.logout(); + break; + case 403: + ElMessage.error('没有权限访问'); + break; + case 404: + ElMessage.error('请求的资源不存在'); + break; + case 500: + ElMessage.error('服务器错误'); + break; + default: + ElMessage.error(error.response.data?.msg || '请求失败'); + } + } else { + ElMessage.error('网络错误,请检查网络连接'); + } + + return Promise.reject(error); + } +); + +export default service; +``` + +#### 4.3.2 API封装示例(api/documents.ts) + +```typescript +import request from '@/utils/request'; + +// 文档接口类型定义 +export interface Document { + id: number; + title: string; + user_id: number; + status: number; + created_at: string; + updated_at: string; +} + +export interface DocumentQuery { + user_id?: number; + status?: number; + keyword?: string; + limit?: number; + offset?: number; + order?: string; +} + +/** + * 查询文档列表 + */ +export const getDocuments = (query: DocumentQuery = {}) => { + const params: any = {}; + + // 过滤条件 + if (query.user_id !== undefined) { + params.user_id = `eq.${query.user_id}`; + } + if (query.status !== undefined) { + params.status = `eq.${query.status}`; + } + if (query.keyword) { + params.title = `like.*${query.keyword}*`; + } + + // 排序 + if (query.order) { + params.order = query.order; + } else { + params.order = 'created_at.desc'; + } + + // 分页 + if (query.limit) { + params.limit = query.limit; + } + if (query.offset) { + params.offset = query.offset; + } + + return request({ + url: '/documents', + method: 'get', + params + }); +}; + +/** + * 获取单个文档 + */ +export const getDocument = (id: number) => { + return request({ + url: '/documents', + method: 'get', + params: { + id: `eq.${id}` + } + }); +}; + +/** + * 创建文档 + */ +export const createDocument = (data: Partial) => { + return request({ + url: '/documents', + method: 'post', + data + }); +}; + +/** + * 更新文档 + */ +export const updateDocument = (id: number, data: Partial) => { + return request({ + url: `/documents?id=eq.${id}`, + method: 'patch', + data + }); +}; + +/** + * 删除文档 + */ +export const deleteDocument = (id: number) => { + return request({ + url: `/documents?id=eq.${id}`, + method: 'delete' + }); +}; +``` + +### 4.4 数据权限说明 + +**重要**: 后端已禁用自动数据隔离,前端需要手动添加过滤条件! + +#### 普通用户(uploader角色) + +普通用户只能访问自己的数据,前端需要手动添加 `user_id` 过滤: + +```typescript +// ❌ 错误:会查询所有用户的文档 +const docs = await axios.get('/documents'); + +// ✅ 正确:只查询当前用户的文档 +const userStore = useUserStore(); +const userId = userStore.userInfo?.user_id; +const docs = await axios.get(`/documents?user_id=eq.${userId}`); +``` + +#### 管理员用户 + +管理员可以访问所有数据,无需添加 `user_id` 过滤。 + +#### 最佳实践 + +```typescript +// 在API封装中自动处理权限 +export const getDocuments = (query: DocumentQuery = {}) => { + const userStore = useUserStore(); + const params: any = {}; + + // 非管理员自动添加user_id过滤 + if (!userStore.isAdmin) { + params.user_id = `eq.${userStore.userInfo?.user_id}`; + } + + // 其他过滤条件 + if (query.status !== undefined) { + params.status = `eq.${query.status}`; + } + + return request({ + url: '/documents', + method: 'get', + params + }); +}; +``` + +--- + +## 5. 完整代码示例 + +### 5.1 项目结构 + +``` +src/ +├── api/ # API接口封装 +│ ├── auth.ts # 认证接口 +│ ├── documents.ts # 文档接口 +│ └── ... +├── components/ # 组件 +│ ├── Sidebar.vue # 侧边栏 +│ ├── SidebarItem.vue # 菜单项 +│ └── ... +├── router/ # 路由 +│ └── index.ts +├── stores/ # Pinia状态管理 +│ └── user.ts # 用户Store +├── utils/ # 工具函数 +│ └── request.ts # Axios封装 +├── views/ # 页面 +│ ├── Login.vue # 登录页 +│ ├── Home.vue # 首页 +│ └── ... +├── App.vue +└── main.ts +``` + +### 5.2 完整示例:文档列表页面 + +```vue + + + + + +``` + +--- + +## 6. 常见问题FAQ + +### 6.1 登录相关 + +**Q: 登录后Token存在哪里?** + +A: Token存储在两个地方: +1. Pinia Store的内存状态(`userStore.token`) +2. localStorage持久化存储 + +```typescript +// 保存Token +localStorage.setItem('token', access_token); + +// 读取Token +const token = localStorage.getItem('token'); + +// 删除Token(登出) +localStorage.removeItem('token'); +``` + +**Q: Token过期如何处理?** + +A: 后端返回401状态码时,前端自动登出并跳转到登录页: + +```typescript +// 响应拦截器 +if (error.response?.status === 401) { + ElMessage.error('登录已过期,请重新登录'); + userStore.logout(); + router.push('/login'); +} +``` + +**Q: 如何实现自动登录?** + +A: 在应用启动时从localStorage恢复登录状态: + +```typescript +// main.ts +import { useUserStore } from '@/stores/user'; + +const app = createApp(App); +app.use(pinia); +app.use(router); + +// 恢复登录状态 +const userStore = useUserStore(); +userStore.restoreLoginState(); + +app.mount('#app'); +``` + +### 6.2 路由相关 + +**Q: 为什么要动态注册路由?** + +A: 因为不同用户有不同权限,看到的菜单和可访问的页面不同。通过动态注册路由: +1. 提高安全性(用户只能访问有权限的页面) +2. 减少打包体积(按需加载组件) +3. 灵活配置权限(后端控制) + +**Q: 动态路由何时加载?** + +A: 在用户登录成功后,通过路由守卫自动加载: + +```typescript +router.beforeEach(async (to, from, next) => { + if (userStore.isLoggedIn && !userStore.hasLoadedRoutes) { + await userStore.fetchUserRoutes(); // 加载路由 + next({ ...to, replace: true }); // 重新导航 + } +}); +``` + +**Q: `/user/routes` 和 `/rbac/user/routes` 有什么区别?** + +A: 两者功能完全相同,`/user/routes` 是别名路由,为了兼容前端直接调用。推荐使用 `/user/routes`。 + +### 6.3 数据访问相关 + +**Q: PostgREST查询如何分页?** + +A: 使用 `limit` 和 `offset` 参数: + +```typescript +// 第1页,每页20条 +GET /documents?limit=20&offset=0 + +// 第2页,每页20条 +GET /documents?limit=20&offset=20 + +// 第3页,每页20条 +GET /documents?limit=20&offset=40 +``` + +**Q: 如何获取总记录数?** + +A: PostgREST在响应头 `Content-Range` 返回总数: + +```typescript +const response = await axios.get('/documents?limit=20&offset=0'); +const contentRange = response.headers['content-range']; +// 格式: "0-19/156" 表示返回0-19条,总共156条 +const total = parseInt(contentRange.split('/')[1]); // 156 +``` + +**Q: 如何实现模糊搜索?** + +A: 使用 `like` 或 `ilike` 操作符: + +```typescript +// 搜索标题包含"合同"的文档 +GET /documents?title=like.*合同* + +// 不区分大小写 +GET /documents?title=ilike.*contract* +``` + +**Q: 普通用户只能看到自己的数据吗?** + +A: 是的!后端已禁用自动数据隔离,前端必须手动添加 `user_id` 过滤条件: + +```typescript +const userStore = useUserStore(); + +// 普通用户:只查询自己的数据 +if (!userStore.isAdmin) { + const docs = await axios.get(`/documents?user_id=eq.${userStore.userInfo.user_id}`); +} + +// 管理员:查询所有数据 +if (userStore.isAdmin) { + const docs = await axios.get('/documents'); +} +``` + +### 6.4 权限控制 + +**Q: 如何判断用户是否是管理员?** + +A: 通过 `user_role` 字段判断: + +```typescript +const userStore = useUserStore(); + +// 方式1:直接判断 +if (userStore.userInfo?.user_role === 'admin') { + console.log('管理员'); +} + +// 方式2:使用计算属性 +if (userStore.isAdmin) { + console.log('管理员'); +} +``` + +**Q: 如何控制按钮显示/隐藏?** + +A: 使用 `v-if` 指令: + +```vue + +``` + +**Q: 如何实现自定义权限指令?** + +A: 创建Vue自定义指令: + +```typescript +// directives/permission.ts +import { Directive } from 'vue'; +import { useUserStore } from '@/stores/user'; + +export const permission: Directive = { + mounted(el, binding) { + const userStore = useUserStore(); + const { value } = binding; + + // value 是权限代码,如 'document:delete' + if (value && !userStore.userInfo?.permissions?.includes(value)) { + el.parentNode?.removeChild(el); + } + } +}; + +// main.ts +import { permission } from '@/directives/permission'; +app.directive('permission', permission); + +// 使用 +删除 +``` + +--- + +## 7. 环境配置 + +### 7.1 开发环境配置(.env.development) + +```env +# API基础URL +VITE_API_BASE_URL=http://172.16.0.55:8073 + +# 应用端口 +VITE_PORT=5173 + +# 是否开启Mock +VITE_USE_MOCK=false +``` + +### 7.2 生产环境配置(.env.production) + +```env +# API基础URL(生产环境) +VITE_API_BASE_URL=https://api.example.com + +# 应用端口 +VITE_PORT=80 + +# 是否开启Mock +VITE_USE_MOCK=false +``` + +--- + +## 8. 测试清单 + +### 8.1 登录测试 + +- [ ] 密码登录成功 +- [ ] 密码登录失败(错误提示) +- [ ] OAuth登录成功 +- [ ] Token自动保存到localStorage +- [ ] 刷新页面后自动恢复登录状态 +- [ ] Token过期自动跳转登录页 + +### 8.2 路由测试 + +- [ ] 登录后自动加载路由 +- [ ] 侧边栏菜单正确显示 +- [ ] 无权限路由无法访问(跳转404或登录页) +- [ ] 路由跳转正常 +- [ ] 页面标题正确显示 + +### 8.3 数据访问测试 + +- [ ] 查询列表成功 +- [ ] 分页功能正常 +- [ ] 搜索过滤正常 +- [ ] 创建数据成功 +- [ ] 更新数据成功 +- [ ] 删除数据成功 +- [ ] 普通用户只能看到自己的数据 +- [ ] 管理员可以看到所有数据 + +--- + +## 9. 联系与支持 + +如有问题,请联系后端团队或查看以下文档: + +- **RBAC系统总结**: `docs/RBAC/RBAC系统使用总结.md` +- **用户管理指南**: `docs/RBAC/用户管理完整指南.md` +- **角色权限配置**: `docs/RBAC/角色路由权限分配表.md` + +--- + +**文档版本**: v2.0 +**创建时间**: 2025-11-17 +**维护者**: Claude Code +**状态**: ✅ 完整对接文档,包含RBAC和PostgREST所有功能 diff --git a/auth_doc/前端对接文档-API端点列表.md b/auth_doc/前端对接文档-API端点列表.md new file mode 100644 index 0000000..dc1e142 --- /dev/null +++ b/auth_doc/前端对接文档-API端点列表.md @@ -0,0 +1,121 @@ +# API端点列表 + +## PostgREST代理端点 + +所有PostgREST代理端点都位于 `/api/v1/postgrest/{table_name}` + +支持的HTTP方法:GET, POST, PATCH, DELETE + +### 文档管理模块 + +| 表名 | 端点 | 说明 | 所需权限 | +|------|------|------|---------| +| documents | /api/v1/postgrest/documents | 文档表 | document:document:view/create/update/delete | +| document_types | /api/v1/postgrest/document_types | 文档类型(公共数据) | document:type:view/create/update/delete | +| document_metadata | /api/v1/postgrest/document_metadata | 文档元数据 | document:metadata:view/create/update/delete | + +### 评查管理模块 + +| 表名 | 端点 | 说明 | 所需权限 | +|------|------|------|---------| +| evaluation_results | /api/v1/postgrest/evaluation_results | 评查结果 | evaluation:result:view/create/update/delete | +| evaluation_points | /api/v1/postgrest/evaluation_points | 评查点配置 | evaluation:point:view/create/update/delete | +| evaluation_point_categories | /api/v1/postgrest/evaluation_point_categories | 评查点分类 | evaluation:category:view/create/update/delete | +| evaluation_rules | /api/v1/postgrest/evaluation_rules | 评查规则 | evaluation:rule:view/create/update/delete | + +### 交叉评查模块 + +| 表名 | 端点 | 说明 | 所需权限 | +|------|------|------|---------| +| cross_examination_tasks | /api/v1/postgrest/cross_examination_tasks | 交叉评查任务 | crossreview:task:view/create/update/delete | +| cross_task_document_mapping | /api/v1/postgrest/cross_task_document_mapping | 任务-文档映射 | crossreview:mapping:view/create/update/delete | +| cross_scoring_proposals | /api/v1/postgrest/cross_scoring_proposals | 评分提案 | crossreview:proposal:view/create/update/delete | +| cross_proposal_votes | /api/v1/postgrest/cross_proposal_votes | 提案投票 | crossreview:vote:view/create/update/delete | + +### 用户管理模块 + +| 表名 | 端点 | 说明 | 所需权限 | +|------|------|------|---------| +| sso_users | /api/v1/postgrest/sso_users | 用户表 | system:user:view/create/update/delete | +| user_role | /api/v1/postgrest/user_role | 用户-角色关联 | system:user_role:view/create/update/delete | + +### 系统管理模块 + +| 表名 | 端点 | 说明 | 所需权限 | +|------|------|------|---------| +| roles | /api/v1/postgrest/roles | 角色表 | system:role:view/create/update/delete | +| sys_routes | /api/v1/postgrest/sys_routes | 系统路由(菜单) | system:route:view/create/update/delete | +| role_route | /api/v1/postgrest/role_route | 角色-路由关联 | system:role_route:view/create/update/delete | + +### RBAC权限管理模块 + +| 表名 | 端点 | 说明 | 所需权限 | +|------|------|------|---------| +| permissions | /api/v1/postgrest/permissions | 权限定义 | system:permission:view/create/update/delete | +| role_permissions | /api/v1/postgrest/role_permissions | 角色-权限关联 | system:role_permission:view/create/update/delete | +| user_permissions | /api/v1/postgrest/user_permissions | 用户-权限关联 | system:user_permission:view/create/update/delete | +| data_permission_rules | /api/v1/postgrest/data_permission_rules | 数据权限规则 | system:data_rule:view/create/update/delete | +| permission_audit_logs | /api/v1/postgrest/permission_audit_logs | 权限审计日志 | system:audit_log:view/create/update/delete | +| jwt_tokens | /api/v1/postgrest/jwt_tokens | JWT Token管理 | system:jwt_token:view/create/update/delete | + +### 报表统计模块 + +| 表名 | 端点 | 说明 | 所需权限 | +|------|------|------|---------| +| statistics_summary | /api/v1/postgrest/statistics_summary | 统计汇总 | report:summary:view/create/update/delete | + +## FastAPI业务端点 + +### 认证端点 + +| 端点 | 方法 | 说明 | 认证 | +|------|------|------|------| +| /api/v1/auth/login | POST | 用户登录 | 否 | +| /api/v1/auth/logout | POST | 用户登出 | 是 | +| /api/v1/auth/refresh | POST | 刷新Token | 是 | + +### 文档业务端点(示例) + +| 端点 | 方法 | 说明 | 认证 | +|------|------|------|------| +| /api/v1/documents/{id}/ocr | POST | 文档OCR处理 | 是 | +| /api/v1/documents/{id}/merge | POST | 合并PDF文档 | 是 | +| /api/v1/documents/{id}/extract | POST | AI提取信息 | 是 | + +## 请求示例 + +### GET请求示例 +```bash +GET /api/v1/postgrest/documents?status=eq.active&limit=10&order=created_at.desc +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... +``` + +### POST请求示例 +```bash +POST /api/v1/postgrest/documents +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... +Content-Type: application/json + +{ + "title": "新文档", + "content": "文档内容", + "status": "draft" +} +``` + +### PATCH请求示例 +```bash +PATCH /api/v1/postgrest/documents?id=eq.1936 +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... +Content-Type: application/json + +{ + "status": "reviewed" +} +``` + +### DELETE请求示例 +```bash +DELETE /api/v1/postgrest/documents?id=eq.1936 +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... +``` diff --git a/auth_doc/前端对接文档-PostgREST查询参考.md b/auth_doc/前端对接文档-PostgREST查询参考.md new file mode 100644 index 0000000..8ebcb3e --- /dev/null +++ b/auth_doc/前端对接文档-PostgREST查询参考.md @@ -0,0 +1,316 @@ +# PostgREST查询参考 + +## 过滤运算符完整列表 + +### 比较运算符 + +| 运算符 | 说明 | PostgREST语法 | JavaScript示例 | +|--------|------|---------------|----------------| +| eq | 等于 | `field=eq.value` | `{ field: 'eq.value' }` | +| neq | 不等于 | `field=neq.value` | `{ field: 'neq.value' }` | +| gt | 大于 | `field=gt.value` | `{ field: 'gt.100' }` | +| gte | 大于等于 | `field=gte.value` | `{ field: 'gte.100' }` | +| lt | 小于 | `field=lt.value` | `{ field: 'lt.100' }` | +| lte | 小于等于 | `field=lte.value` | `{ field: 'lte.100' }` | + +### 模式匹配运算符 + +| 运算符 | 说明 | PostgREST语法 | JavaScript示例 | +|--------|------|---------------|----------------| +| like | 模糊匹配(区分大小写) | `field=like.*pattern*` | `{ field: 'like.*测试*' }` | +| ilike | 模糊匹配(不区分大小写) | `field=ilike.*pattern*` | `{ field: 'ilike.*TEST*' }` | + +**模式匹配通配符**: +- `*`: 匹配任意字符(等同于SQL的`%`) +- `_`: 匹配单个字符 + +**示例**: +```javascript +// 查询标题以"合同"开头的文档 +{ title: 'like.合同*' } + +// 查询标题以"合同"结尾的文档 +{ title: 'like.*合同' } + +// 查询标题包含"合同"的文档 +{ title: 'like.*合同*' } + +// 不区分大小写查找 +{ title: 'ilike.*contract*' } +``` + +### 列表运算符 + +| 运算符 | 说明 | PostgREST语法 | JavaScript示例 | +|--------|------|---------------|----------------| +| in | 在列表中 | `field=in.(value1,value2,...)` | `{ field: 'in.(active,pending)' }` | +| cs | 包含(数组字段) | `field=cs.{value1,value2}` | `{ tags: 'cs.{重要,紧急}' }` | +| cd | 被包含(数组字段) | `field=cd.{value1,value2}` | `{ tags: 'cd.{重要,紧急}' }` | +| ov | 重叠(数组字段) | `field=ov.{value1,value2}` | `{ tags: 'ov.{重要,紧急}' }` | + +**示例**: +```javascript +// 查询状态为active或pending的文档 +{ status: 'in.(active,pending)' } + +// 查询ID在[1, 2, 3]中的文档 +{ id: 'in.(1,2,3)' } + +// 查询标签包含"重要"和"紧急"的文档(数组字段) +{ tags: 'cs.{重要,紧急}' } +``` + +### Null检查运算符 + +| 运算符 | 说明 | PostgREST语法 | JavaScript示例 | +|--------|------|---------------|----------------| +| is | 是null | `field=is.null` | `{ field: 'is.null' }` | +| nt | 不是null | `field=nt.null` (或 `is.not.null`) | `{ field: 'nt.null' }` | + +**示例**: +```javascript +// 查询deleted_at为null的文档(未删除) +{ deleted_at: 'is.null' } + +// 查询deleted_at不为null的文档(已删除) +{ deleted_at: 'nt.null' } +``` + +## 排序 + +### 单字段排序 + +```javascript +// 按创建时间升序 +{ order: 'created_at.asc' } + +// 按创建时间降序 +{ order: 'created_at.desc' } +``` + +### 多字段排序 + +```javascript +// 先按状态升序,再按创建时间降序 +{ order: 'status.asc,created_at.desc' } +``` + +## 分页 + +### Limit和Offset + +```javascript +// 每页10条,第1页 +{ limit: 10, offset: 0 } + +// 每页10条,第2页 +{ limit: 10, offset: 10 } + +// 每页20条,第3页 +{ limit: 20, offset: 40 } +``` + +### 获取总数 + +PostgREST会在响应头中返回`Content-Range`,包含总记录数: + +``` +Content-Range: 0-9/50 +``` +表示:返回第0-9条记录,总共50条记录。 + +**解析示例**: +```javascript +const response = await apiClient.get('/postgrest/documents', { + params: { limit: 10, offset: 0 } +}); + +const contentRange = response.headers['content-range']; +const total = parseInt(contentRange.split('/')[1]); // 50 +``` + +## 字段选择 + +### 选择特定字段 + +```javascript +// 只返回id, title, status字段 +{ select: 'id,title,status' } +``` + +### 嵌套查询(外键关联) + +PostgREST支持嵌套查询,但DocAuditAI暂未启用此功能。 + +## 高级查询 + +### 逻辑运算符 + +**注意**: 前端应避免使用`or`、`and`、`not`参数,这些参数由后端自动生成(用于数据范围和交叉评查权限)。 + +### 复杂查询示例 + +```javascript +// 查询状态为active且创建时间在2025年之后的文档,按创建时间降序,每页10条 +const params = { + status: 'eq.active', + created_at: 'gte.2025-01-01', + order: 'created_at.desc', + limit: 10, + offset: 0 +}; + +const response = await apiClient.get('/postgrest/documents', { params }); +``` + +## 完整示例 + +### 示例1:文档列表查询 + +```javascript +// 需求:查询标题包含"合同"、状态为active的文档, +// 按创建时间降序排列,每页20条,第2页 + +async function getDocumentList() { + const response = await apiClient.get('/postgrest/documents', { + params: { + title: 'ilike.*合同*', + status: 'eq.active', + order: 'created_at.desc', + limit: 20, + offset: 20 // 第2页 + } + }); + + return { + data: response.data, + total: parseInt(response.headers['content-range'].split('/')[1]) + }; +} +``` + +### 示例2:高级搜索 + +```javascript +// 需求:搜索2025年创建的、状态为active或pending的文档 + +async function advancedSearch() { + const response = await apiClient.get('/postgrest/documents', { + params: { + status: 'in.(active,pending)', + created_at: 'gte.2025-01-01', + created_at: 'lt.2026-01-01', // 注意:PostgREST会组合多个同名参数 + order: 'created_at.desc' + } + }); + + return response.data; +} +``` + +### 示例3:分页封装 + +```javascript +// 通用分页函数 +async function fetchPaginated(table, page, pageSize, filters = {}) { + const params = { + limit: pageSize, + offset: (page - 1) * pageSize, + ...filters + }; + + const response = await apiClient.get(`/postgrest/${table}`, { params }); + + const contentRange = response.headers['content-range']; + const total = contentRange ? parseInt(contentRange.split('/')[1]) : 0; + + return { + data: response.data, + total: total, + page: page, + pageSize: pageSize, + totalPages: Math.ceil(total / pageSize) + }; +} + +// 使用示例 +const result = await fetchPaginated('documents', 1, 10, { + status: 'eq.active', + order: 'created_at.desc' +}); + +console.log(`共${result.total}条记录,第${result.page}/${result.totalPages}页`); +``` + +## 性能优化建议 + +### 1. 始终使用分页 + +```javascript +// ✅ 好的做法 +{ limit: 10, offset: 0 } + +// ❌ 不好的做法(返回所有数据) +{} +``` + +### 2. 只查询需要的字段 + +```javascript +// ✅ 好的做法(只查询id和title) +{ select: 'id,title' } + +// ❌ 不好的做法(查询所有字段) +{} +``` + +### 3. 使用索引字段进行过滤 + +优先使用已建立索引的字段(如id, user_id, ou_id, status)进行过滤,提升查询性能。 + +### 4. 避免过度模糊查询 + +```javascript +// ✅ 好的做法(明确的过滤条件) +{ title: 'like.合同*' } + +// ❌ 不好的做法(前后通配符,性能差) +{ title: 'like.*合同*' } +``` + +## 常见错误 + +### 错误1:运算符拼写错误 + +```javascript +// ❌ 错误 +{ status: 'equal.active' } // 应该是 eq + +// ✅ 正确 +{ status: 'eq.active' } +``` + +### 错误2:日期格式错误 + +```javascript +// ❌ 错误 +{ created_at: 'gte.2025/01/01' } // 格式错误 + +// ✅ 正确 +{ created_at: 'gte.2025-01-01' } // ISO 8601格式 +``` + +### 错误3:in运算符语法错误 + +```javascript +// ❌ 错误 +{ status: 'in.active,pending' } // 缺少括号 + +// ✅ 正确 +{ status: 'in.(active,pending)' } +``` + +## 参考资料 + +- [PostgREST官方文档](https://postgrest.org/en/stable/) +- [PostgREST API查询语法](https://postgrest.org/en/stable/api.html#horizontal-filtering-rows) diff --git a/auth_doc/前端对接文档-权限列表.md b/auth_doc/前端对接文档-权限列表.md new file mode 100644 index 0000000..412383a --- /dev/null +++ b/auth_doc/前端对接文档-权限列表.md @@ -0,0 +1,338 @@ +# 权限列表 + +本文档列出DocAuditAI系统中所有权限及其对应的操作。 + +## 权限键格式 + +权限键格式:`{module}:{resource}:{action}` + +- **module**: 模块名称(如document, system, evaluation) +- **resource**: 资源名称(如document, user, role) +- **action**: 操作类型(view, create, update, delete等) + +## 文档管理模块 (document) + +| 权限键 | 说明 | 对应操作 | +|--------|------|---------| +| document:document:view | 查看文档 | GET /postgrest/documents | +| document:document:create | 创建文档 | POST /postgrest/documents | +| document:document:update | 更新文档 | PATCH /postgrest/documents | +| document:document:delete | 删除文档 | DELETE /postgrest/documents | +| document:type:view | 查看文档类型 | GET /postgrest/document_types | +| document:type:create | 创建文档类型 | POST /postgrest/document_types | +| document:type:update | 更新文档类型 | PATCH /postgrest/document_types | +| document:type:delete | 删除文档类型 | DELETE /postgrest/document_types | +| document:metadata:view | 查看文档元数据 | GET /postgrest/document_metadata | +| document:metadata:create | 创建文档元数据 | POST /postgrest/document_metadata | +| document:metadata:update | 更新文档元数据 | PATCH /postgrest/document_metadata | +| document:metadata:delete | 删除文档元数据 | DELETE /postgrest/document_metadata | + +## 评查管理模块 (evaluation) + +| 权限键 | 说明 | 对应操作 | +|--------|------|---------| +| evaluation:result:view | 查看评查结果 | GET /postgrest/evaluation_results | +| evaluation:result:create | 创建评查结果 | POST /postgrest/evaluation_results | +| evaluation:result:update | 更新评查结果 | PATCH /postgrest/evaluation_results | +| evaluation:result:delete | 删除评查结果 | DELETE /postgrest/evaluation_results | +| evaluation:point:view | 查看评查点 | GET /postgrest/evaluation_points | +| evaluation:point:create | 创建评查点 | POST /postgrest/evaluation_points | +| evaluation:point:update | 更新评查点 | PATCH /postgrest/evaluation_points | +| evaluation:point:delete | 删除评查点 | DELETE /postgrest/evaluation_points | +| evaluation:category:view | 查看评查点分类 | GET /postgrest/evaluation_point_categories | +| evaluation:category:create | 创建评查点分类 | POST /postgrest/evaluation_point_categories | +| evaluation:category:update | 更新评查点分类 | PATCH /postgrest/evaluation_point_categories | +| evaluation:category:delete | 删除评查点分类 | DELETE /postgrest/evaluation_point_categories | +| evaluation:rule:view | 查看评查规则 | GET /postgrest/evaluation_rules | +| evaluation:rule:create | 创建评查规则 | POST /postgrest/evaluation_rules | +| evaluation:rule:update | 更新评查规则 | PATCH /postgrest/evaluation_rules | +| evaluation:rule:delete | 删除评查规则 | DELETE /postgrest/evaluation_rules | + +## 交叉评查模块 (crossreview) + +| 权限键 | 说明 | 对应操作 | +|--------|------|---------| +| crossreview:task:view | 查看交叉评查任务 | GET /postgrest/cross_examination_tasks | +| crossreview:task:create | 创建交叉评查任务 | POST /postgrest/cross_examination_tasks | +| crossreview:task:update | 更新交叉评查任务 | PATCH /postgrest/cross_examination_tasks | +| crossreview:task:delete | 删除交叉评查任务 | DELETE /postgrest/cross_examination_tasks | +| crossreview:mapping:view | 查看任务文档映射 | GET /postgrest/cross_task_document_mapping | +| crossreview:mapping:create | 创建任务文档映射 | POST /postgrest/cross_task_document_mapping | +| crossreview:mapping:update | 更新任务文档映射 | PATCH /postgrest/cross_task_document_mapping | +| crossreview:mapping:delete | 删除任务文档映射 | DELETE /postgrest/cross_task_document_mapping | +| crossreview:proposal:view | 查看评分提案 | GET /postgrest/cross_scoring_proposals | +| crossreview:proposal:create | 创建评分提案 | POST /postgrest/cross_scoring_proposals | +| crossreview:proposal:update | 更新评分提案 | PATCH /postgrest/cross_scoring_proposals | +| crossreview:proposal:delete | 删除评分提案 | DELETE /postgrest/cross_scoring_proposals | +| crossreview:vote:view | 查看提案投票 | GET /postgrest/cross_proposal_votes | +| crossreview:vote:create | 创建提案投票 | POST /postgrest/cross_proposal_votes | +| crossreview:vote:update | 更新提案投票 | PATCH /postgrest/cross_proposal_votes | +| crossreview:vote:delete | 删除提案投票 | DELETE /postgrest/cross_proposal_votes | + +## 系统管理模块 (system) + +| 权限键 | 说明 | 对应操作 | +|--------|------|---------| +| system:user:view | 查看用户 | GET /postgrest/sso_users | +| system:user:create | 创建用户 | POST /postgrest/sso_users | +| system:user:update | 更新用户 | PATCH /postgrest/sso_users | +| system:user:delete | 删除用户 | DELETE /postgrest/sso_users | +| system:role:view | 查看角色 | GET /postgrest/roles | +| system:role:create | 创建角色 | POST /postgrest/roles | +| system:role:update | 更新角色 | PATCH /postgrest/roles | +| system:role:delete | 删除角色 | DELETE /postgrest/roles | +| system:route:view | 查看系统路由 | GET /postgrest/sys_routes | +| system:route:create | 创建系统路由 | POST /postgrest/sys_routes | +| system:route:update | 更新系统路由 | PATCH /postgrest/sys_routes | +| system:route:delete | 删除系统路由 | DELETE /postgrest/sys_routes | +| system:user_role:view | 查看用户-角色关联 | GET /postgrest/user_role | +| system:user_role:create | 创建用户-角色关联 | POST /postgrest/user_role | +| system:user_role:update | 更新用户-角色关联 | PATCH /postgrest/user_role | +| system:user_role:delete | 删除用户-角色关联 | DELETE /postgrest/user_role | +| system:role_route:view | 查看角色-路由关联 | GET /postgrest/role_route | +| system:role_route:create | 创建角色-路由关联 | POST /postgrest/role_route | +| system:role_route:update | 更新角色-路由关联 | PATCH /postgrest/role_route | +| system:role_route:delete | 删除角色-路由关联 | DELETE /postgrest/role_route | +| system:permission:view | 查看权限定义 | GET /postgrest/permissions | +| system:permission:create | 创建权限定义 | POST /postgrest/permissions | +| system:permission:update | 更新权限定义 | PATCH /postgrest/permissions | +| system:permission:delete | 删除权限定义 | DELETE /postgrest/permissions | +| system:role_permission:view | 查看角色-权限关联 | GET /postgrest/role_permissions | +| system:role_permission:create | 创建角色-权限关联 | POST /postgrest/role_permissions | +| system:role_permission:update | 更新角色-权限关联 | PATCH /postgrest/role_permissions | +| system:role_permission:delete | 删除角色-权限关联 | DELETE /postgrest/role_permissions | +| system:user_permission:view | 查看用户-权限关联 | GET /postgrest/user_permissions | +| system:user_permission:create | 创建用户-权限关联 | POST /postgrest/user_permissions | +| system:user_permission:update | 更新用户-权限关联 | PATCH /postgrest/user_permissions | +| system:user_permission:delete | 删除用户-权限关联 | DELETE /postgrest/user_permissions | +| system:data_rule:view | 查看数据权限规则 | GET /postgrest/data_permission_rules | +| system:data_rule:create | 创建数据权限规则 | POST /postgrest/data_permission_rules | +| system:data_rule:update | 更新数据权限规则 | PATCH /postgrest/data_permission_rules | +| system:data_rule:delete | 删除数据权限规则 | DELETE /postgrest/data_permission_rules | +| system:audit_log:view | 查看审计日志 | GET /postgrest/permission_audit_logs | +| system:audit_log:create | 创建审计日志 | POST /postgrest/permission_audit_logs | +| system:audit_log:update | 更新审计日志 | PATCH /postgrest/permission_audit_logs | +| system:audit_log:delete | 删除审计日志 | DELETE /postgrest/permission_audit_logs | +| system:jwt_token:view | 查看JWT Token | GET /postgrest/jwt_tokens | +| system:jwt_token:create | 创建JWT Token | POST /postgrest/jwt_tokens | +| system:jwt_token:update | 更新JWT Token | PATCH /postgrest/jwt_tokens | +| system:jwt_token:delete | 删除JWT Token | DELETE /postgrest/jwt_tokens | + +## 报表统计模块 (report) + +| 权限键 | 说明 | 对应操作 | +|--------|------|---------| +| report:summary:view | 查看统计汇总 | GET /postgrest/statistics_summary | +| report:summary:create | 创建统计汇总 | POST /postgrest/statistics_summary | +| report:summary:update | 更新统计汇总 | PATCH /postgrest/statistics_summary | +| report:summary:delete | 删除统计汇总 | DELETE /postgrest/statistics_summary | + +## 预定义角色及其权限 + +### 1. 系统管理员 +拥有所有权限(71个权限) + +### 2. 文档管理员 +- document:document:view +- document:document:create +- document:document:update +- document:document:delete +- document:type:view +- document:type:create +- document:type:update +- document:type:delete +- document:metadata:view +- document:metadata:create +- document:metadata:update +- document:metadata:delete + +### 3. 文档审查员 +- document:document:view +- document:document:update +- evaluation:result:view +- evaluation:result:create +- evaluation:result:update +- evaluation:point:view + +### 4. 交叉评查管理员 +- crossreview:task:view +- crossreview:task:create +- crossreview:task:update +- crossreview:task:delete +- crossreview:mapping:view +- crossreview:mapping:create +- crossreview:mapping:update +- crossreview:mapping:delete +- crossreview:proposal:view +- crossreview:proposal:create +- crossreview:proposal:update +- crossreview:vote:view +- crossreview:vote:create +- crossreview:vote:update + +### 5. 交叉评查参与者 +- crossreview:task:view +- crossreview:mapping:view +- crossreview:proposal:view +- crossreview:proposal:create +- crossreview:vote:view +- crossreview:vote:create + +### 6. 评价点管理员 +- evaluation:point:view +- evaluation:point:create +- evaluation:point:update +- evaluation:point:delete +- evaluation:category:view +- evaluation:category:create +- evaluation:category:update +- evaluation:category:delete +- evaluation:rule:view +- evaluation:rule:create +- evaluation:rule:update +- evaluation:rule:delete + +### 7. 报表查看员 +- report:summary:view +- document:document:view +- evaluation:result:view + +### 8. 普通用户 +- document:document:view +- document:type:view +- evaluation:point:view +- evaluation:category:view + +### 9. 审计员 +- system:audit_log:view +- document:document:view +- evaluation:result:view +- crossreview:task:view + +## 前端权限控制示例 + +### 示例1:按钮权限控制 + +```vue + +``` + +### 示例2:菜单权限控制 + +```javascript +// 菜单配置(根据权限动态生成) +const menuConfig = [ + { + name: '文档管理', + permission: 'document:document:view', + children: [ + { name: '文档列表', permission: 'document:document:view', path: '/documents' }, + { name: '新建文档', permission: 'document:document:create', path: '/documents/new' } + ] + }, + { + name: '评查管理', + permission: 'evaluation:point:view', + children: [ + { name: '评查点配置', permission: 'evaluation:point:view', path: '/evaluation/points' }, + { name: '评查结果', permission: 'evaluation:result:view', path: '/evaluation/results' } + ] + }, + { + name: '系统管理', + role: '系统管理员', // 只有系统管理员才能看到 + children: [ + { name: '用户管理', permission: 'system:user:view', path: '/system/users' }, + { name: '角色管理', permission: 'system:role:view', path: '/system/roles' } + ] + } +]; + +// 过滤菜单(根据用户权限) +function filterMenuByPermission(menu, userPermissions, userRoles) { + return menu.filter(item => { + // 检查角色 + if (item.role && !userRoles.includes(item.role)) { + return false; + } + + // 检查权限 + if (item.permission && !userPermissions.includes(item.permission)) { + return false; + } + + // 递归过滤子菜单 + if (item.children) { + item.children = filterMenuByPermission(item.children, userPermissions, userRoles); + } + + return true; + }); +} +``` + +### 示例3:路由守卫 + +```javascript +// router.js +import { createRouter, createWebHistory } from 'vue-router'; + +const router = createRouter({ + history: createWebHistory(), + routes: [ + { + path: '/documents', + component: DocumentList, + meta: { permission: 'document:document:view' } + }, + { + path: '/system/users', + component: UserManagement, + meta: { role: '系统管理员' } + } + ] +}); + +// 全局路由守卫 +router.beforeEach((to, from, next) => { + const userInfo = JSON.parse(localStorage.getItem('user_info') || '{}'); + + // 检查角色 + if (to.meta.role && !userInfo.roles?.includes(to.meta.role)) { + alert('无权访问此页面'); + next('/'); + return; + } + + // 检查权限(简化版,实际应维护权限列表) + if (to.meta.permission) { + // TODO: 检查用户是否有该权限 + } + + next(); +}); +``` + +## 注意事项 + +1. **权限检查由后端强制执行** + 即使前端绕过权限控制,后端也会拒绝无权请求。 + +2. **前端权限控制是为了提升用户体验** + 隐藏用户无权操作的按钮和菜单,避免用户点击后被拒绝。 + +3. **定期同步权限列表** + 登录时从后端获取用户的完整权限列表,存储到前端状态管理中。 + +4. **角色权限继承** + 用户通过角色继承权限,也可以直接分配权限(优先级高于角色)。 diff --git a/auth_doc/前端对接文档.md b/auth_doc/前端对接文档.md new file mode 100644 index 0000000..385862a --- /dev/null +++ b/auth_doc/前端对接文档.md @@ -0,0 +1,1562 @@ +# DocAuditAI 前端对接文档 + +**版本**: v1.0 +**最后更新**: 2025-11-17 +**适用范围**: 前端开发人员 + +--- + +## 目录 + +1. [系统概述](#1-系统概述) +2. [认证与授权](#2-认证与授权) +3. [PostgREST API使用](#3-postgrest-api使用) +4. [RBAC权限系统](#4-rbac权限系统) +5. [数据范围过滤](#5-数据范围过滤) +6. [交叉评查权限](#6-交叉评查权限) +7. [错误处理](#7-错误处理) +8. [常用示例](#8-常用示例) +9. [最佳实践](#9-最佳实践) +10. [故障排查](#10-故障排查) + +--- + +## 1. 系统概述 + +### 1.1 架构概览 + +DocAuditAI采用三层架构: + +``` +┌─────────────┐ +│ 前端应用 │ Vue.js / React / Angular +└─────────────┘ + ↓ HTTPS +┌─────────────┐ +│ FastAPI │ 认证、权限检查、业务逻辑 +│ (端口8000) │ +└─────────────┘ + ↓ +┌─────────────┐ +│ PostgREST │ 直接数据库访问(经过RBAC过滤) +│ (端口3000) │ +└─────────────┘ + ↓ +┌─────────────┐ +│ PostgreSQL │ 数据存储 +│ (端口5432) │ +└─────────────┘ +``` + +### 1.2 API端点 + +- **FastAPI主应用**: `http://localhost:8000/api/v1/` +- **PostgREST代理**: `http://localhost:8000/api/v1/postgrest/` +- **认证端点**: `http://localhost:8000/api/v1/auth/` + +### 1.3 技术栈要求 + +**前端推荐技术栈**: +- **HTTP客户端**: Axios (推荐) 或 Fetch API +- **状态管理**: Vuex / Pinia (Vue) 或 Redux (React) +- **UI框架**: Element Plus / Ant Design / Material-UI + +--- + +## 2. 认证与授权 + +### 2.1 JWT认证流程 + +DocAuditAI使用JWT(JSON Web Token)进行用户认证。 + +#### 登录流程 + +```mermaid +sequenceDiagram + participant 前端 + participant FastAPI + participant 数据库 + + 前端->>FastAPI: POST /api/v1/auth/login {username, password} + FastAPI->>数据库: 验证用户凭据 + 数据库-->>FastAPI: 用户信息 + 权限 + FastAPI-->>前端: {access_token, token_type, user_info} + 前端->>前端: 存储access_token到localStorage +``` + +#### 登录示例 + +**请求**: +```javascript +// JavaScript/TypeScript +import axios from 'axios'; + +const API_BASE_URL = 'http://localhost:8000/api/v1'; + +async function login(username, password) { + try { + const response = await axios.post(`${API_BASE_URL}/auth/login`, { + username, + password + }); + + // 保存Token + localStorage.setItem('access_token', response.data.access_token); + localStorage.setItem('user_info', JSON.stringify(response.data.user_info)); + + return response.data; + } catch (error) { + console.error('登录失败:', error.response?.data || error.message); + throw error; + } +} + +// 使用示例 +login('user@example.com', 'password123') + .then(data => { + console.log('登录成功:', data.user_info); + }) + .catch(err => { + console.error('登录失败'); + }); +``` + +**成功响应** (HTTP 200): +```json +{ + "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "token_type": "Bearer", + "user_info": { + "user_id": 9, + "username": "云浮测试", + "ou_id": "yunfu002", + "ou_name": "云浮测试用户账户", + "roles": ["文档审查员", "普通用户"] + } +} +``` + +**失败响应** (HTTP 401): +```json +{ + "detail": "用户名或密码错误" +} +``` + +### 2.2 请求认证 + +所有需要认证的API请求都必须在请求头中携带JWT Token。 + +#### Axios全局配置(推荐) + +```javascript +// axios-instance.js +import axios from 'axios'; + +const API_BASE_URL = 'http://localhost:8000/api/v1'; + +// 创建Axios实例 +const apiClient = axios.create({ + baseURL: API_BASE_URL, + headers: { + 'Content-Type': 'application/json' + } +}); + +// 请求拦截器:自动添加Token +apiClient.interceptors.request.use( + config => { + const token = localStorage.getItem('access_token'); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; + }, + error => Promise.reject(error) +); + +// 响应拦截器:处理认证错误 +apiClient.interceptors.response.use( + response => response, + error => { + if (error.response?.status === 401) { + // Token过期或无效,跳转到登录页 + localStorage.removeItem('access_token'); + localStorage.removeItem('user_info'); + window.location.href = '/login'; + } + return Promise.reject(error); + } +); + +export default apiClient; +``` + +**使用示例**: +```javascript +import apiClient from './axios-instance'; + +// 查询文档列表(自动携带Token) +async function getDocuments() { + const response = await apiClient.get('/postgrest/documents?limit=10'); + return response.data; +} +``` + +### 2.3 Token刷新 + +JWT Token有效期为24小时。Token过期后需要重新登录。 + +**检查Token是否过期**: +```javascript +function isTokenExpired() { + const token = localStorage.getItem('access_token'); + if (!token) return true; + + try { + const payload = JSON.parse(atob(token.split('.')[1])); + const exp = payload.exp * 1000; // 转换为毫秒 + return Date.now() >= exp; + } catch (e) { + return true; + } +} + +// 定期检查Token(可选) +setInterval(() => { + if (isTokenExpired()) { + alert('登录已过期,请重新登录'); + window.location.href = '/login'; + } +}, 60000); // 每分钟检查一次 +``` + +### 2.4 退出登录 + +```javascript +async function logout() { + try { + // 调用后端登出接口(可选,如果后端有黑名单机制) + await apiClient.post('/auth/logout'); + } catch (error) { + console.error('登出失败:', error); + } finally { + // 清除本地存储 + localStorage.removeItem('access_token'); + localStorage.removeItem('user_info'); + window.location.href = '/login'; + } +} +``` + +--- + +## 3. PostgREST API使用 + +### 3.1 PostgREST代理概述 + +PostgREST代理提供了对数据库表的直接RESTful访问,**自动集成RBAC权限检查和数据范围过滤**。 + +**基础URL**: `/api/v1/postgrest/{table_name}` + +**支持的HTTP方法**: +- `GET`: 查询数据 +- `POST`: 创建数据 +- `PATCH`: 更新数据 +- `DELETE`: 删除数据 + +### 3.2 查询操作 (GET) + +#### 3.2.1 查询所有记录 + +```javascript +// 查询所有文档(自动应用数据范围过滤) +async function getAllDocuments() { + const response = await apiClient.get('/postgrest/documents'); + return response.data; +} + +// 响应示例 +[ + { + "id": 1936, + "title": "测试文档1", + "status": "active", + "ou_id": "yunfu002", + "user_id": 9, + "created_at": "2025-11-15T10:30:00Z" + }, + { + "id": 1937, + "title": "测试文档2", + "status": "pending", + "ou_id": "yunfu002", + "user_id": 9, + "created_at": "2025-11-16T14:20:00Z" + } +] +``` + +#### 3.2.2 分页查询 + +PostgREST使用`limit`和`offset`参数实现分页。 + +```javascript +// 分页查询:每页10条,第2页 +async function getDocumentsPaginated(page = 1, pageSize = 10) { + const offset = (page - 1) * pageSize; + const response = await apiClient.get('/postgrest/documents', { + params: { + limit: pageSize, + offset: offset + } + }); + return response.data; +} + +// 使用示例 +const documents = await getDocumentsPaginated(2, 10); // 第2页,每页10条 +``` + +#### 3.2.3 过滤查询 + +PostgREST支持丰富的过滤运算符: + +| 运算符 | 说明 | 示例 | +|--------|------|------| +| `eq` | 等于 | `status=eq.active` | +| `neq` | 不等于 | `status=neq.deleted` | +| `gt` | 大于 | `created_at=gt.2025-01-01` | +| `gte` | 大于等于 | `score=gte.80` | +| `lt` | 小于 | `priority=lt.5` | +| `lte` | 小于等于 | `age=lte.30` | +| `like` | 模糊匹配 | `title=like.*测试*` | +| `ilike` | 不区分大小写模糊匹配 | `title=ilike.*TEST*` | +| `in` | 在列表中 | `status=in.(active,pending)` | +| `is` | 是null | `deleted_at=is.null` | + +**示例**: +```javascript +// 查询状态为active的文档 +async function getActiveDocuments() { + const response = await apiClient.get('/postgrest/documents', { + params: { + status: 'eq.active' + } + }); + return response.data; +} + +// 查询标题包含"合同"的文档 +async function searchDocuments(keyword) { + const response = await apiClient.get('/postgrest/documents', { + params: { + title: `ilike.*${keyword}*` + } + }); + return response.data; +} + +// 多条件查询:状态为active且创建时间在2025年之后 +async function getRecentActiveDocuments() { + const response = await apiClient.get('/postgrest/documents', { + params: { + status: 'eq.active', + created_at: 'gte.2025-01-01' + } + }); + return response.data; +} +``` + +#### 3.2.4 排序 + +使用`order`参数指定排序字段和方向。 + +```javascript +// 按创建时间降序排列 +async function getDocumentsSorted() { + const response = await apiClient.get('/postgrest/documents', { + params: { + order: 'created_at.desc' + } + }); + return response.data; +} + +// 多字段排序:先按状态升序,再按创建时间降序 +async function getDocumentsMultiSort() { + const response = await apiClient.get('/postgrest/documents', { + params: { + order: 'status.asc,created_at.desc' + } + }); + return response.data; +} +``` + +#### 3.2.5 字段选择 + +使用`select`参数指定返回的字段。 + +```javascript +// 只返回id、title、status字段 +async function getDocumentsPartial() { + const response = await apiClient.get('/postgrest/documents', { + params: { + select: 'id,title,status' + } + }); + return response.data; +} + +// 响应示例 +[ + { + "id": 1936, + "title": "测试文档1", + "status": "active" + } +] +``` + +### 3.3 创建操作 (POST) + +```javascript +// 创建新文档 +async function createDocument(documentData) { + const response = await apiClient.post('/postgrest/documents', documentData); + return response.data; +} + +// 使用示例 +const newDocument = await createDocument({ + title: '新文档', + content: '文档内容', + status: 'draft', + ou_id: 'yunfu002', // 后端会自动验证ou_id是否在用户权限范围内 + user_id: 9 +}); + +// 成功响应 (HTTP 201) +[ + { + "id": 2001, + "title": "新文档", + "content": "文档内容", + "status": "draft", + "ou_id": "yunfu002", + "user_id": 9, + "created_at": "2025-11-17T10:00:00Z" + } +] +``` + +**重要提示**: +- 创建文档时,后端会自动验证`ou_id`和`user_id`是否符合用户的数据范围权限 +- 如果尝试创建不在权限范围内的数据,会返回403 Forbidden + +### 3.4 更新操作 (PATCH) + +```javascript +// 更新文档状态 +async function updateDocumentStatus(documentId, newStatus) { + const response = await apiClient.patch('/postgrest/documents', + { status: newStatus }, + { + params: { + id: `eq.${documentId}` + } + } + ); + return response.data; +} + +// 使用示例 +const updated = await updateDocumentStatus(1936, 'reviewed'); + +// 成功响应 (HTTP 200) +[ + { + "id": 1936, + "title": "测试文档1", + "status": "reviewed", + "updated_at": "2025-11-17T11:00:00Z" + } +] +``` + +**重要提示**: +- 更新操作会自动应用数据范围过滤,用户只能更新自己权限范围内的数据 +- 对于交叉评查文档,如果用户参与了该文档的交叉评查任务,即使文档不在常规数据范围内,也可以更新 + +### 3.5 删除操作 (DELETE) + +```javascript +// 删除文档 +async function deleteDocument(documentId) { + const response = await apiClient.delete('/postgrest/documents', { + params: { + id: `eq.${documentId}` + } + }); + return response.status === 204; // 成功删除返回204 No Content +} + +// 使用示例 +const deleted = await deleteDocument(1936); +if (deleted) { + console.log('文档删除成功'); +} +``` + +### 3.6 完整示例:文档管理 + +```javascript +// document-service.js +import apiClient from './axios-instance'; + +class DocumentService { + // 查询文档列表 + async getDocuments(filters = {}) { + const params = { + limit: filters.pageSize || 10, + offset: ((filters.page || 1) - 1) * (filters.pageSize || 10), + ...filters.where + }; + + if (filters.orderBy) { + params.order = filters.orderBy; + } + + const response = await apiClient.get('/postgrest/documents', { params }); + return response.data; + } + + // 获取单个文档 + async getDocumentById(id) { + const response = await apiClient.get('/postgrest/documents', { + params: { id: `eq.${id}` } + }); + return response.data[0]; // PostgREST返回数组,取第一个 + } + + // 创建文档 + async createDocument(data) { + const response = await apiClient.post('/postgrest/documents', data); + return response.data[0]; + } + + // 更新文档 + async updateDocument(id, data) { + const response = await apiClient.patch( + '/postgrest/documents', + data, + { params: { id: `eq.${id}` } } + ); + return response.data[0]; + } + + // 删除文档 + async deleteDocument(id) { + await apiClient.delete('/postgrest/documents', { + params: { id: `eq.${id}` } + }); + return true; + } +} + +export default new DocumentService(); +``` + +**使用示例**: +```javascript +import DocumentService from './document-service'; + +// 1. 查询文档列表(分页、过滤、排序) +const documents = await DocumentService.getDocuments({ + page: 1, + pageSize: 10, + where: { + status: 'eq.active', + title: 'ilike.*合同*' + }, + orderBy: 'created_at.desc' +}); + +// 2. 获取单个文档 +const document = await DocumentService.getDocumentById(1936); + +// 3. 创建文档 +const newDoc = await DocumentService.createDocument({ + title: '采购合同', + content: '合同内容...', + status: 'draft' +}); + +// 4. 更新文档 +const updatedDoc = await DocumentService.updateDocument(1936, { + status: 'reviewed' +}); + +// 5. 删除文档 +await DocumentService.deleteDocument(1936); +``` + +--- + +## 4. RBAC权限系统 + +### 4.1 权限模型 + +DocAuditAI采用**RBAC(基于角色的访问控制)**模型,支持: + +- **用户-角色关联**: 一个用户可以有多个角色 +- **角色-权限关联**: 一个角色可以有多个权限 +- **用户-权限关联**: 用户可以直接拥有权限(绕过角色) +- **数据范围控制**: 细粒度的数据访问权限(ALL/DEPT/DEPT_AND_SUB/SELF/CUSTOM) + +#### 权限键格式 + +权限键格式为:`{module}:{resource}:{action}` + +**示例**: +- `document:document:view` - 查看文档 +- `document:document:create` - 创建文档 +- `document:document:update` - 更新文档 +- `document:document:delete` - 删除文档 +- `crossreview:task:view` - 查看交叉评查任务 + +### 4.2 自动权限检查 + +**所有通过PostgREST代理的请求都会自动进行权限检查**,前端无需手动调用权限检查接口。 + +**权限检查流程**: +1. 用户发起请求(携带JWT Token) +2. 后端解析Token,提取用户ID和角色 +3. 根据表名和HTTP方法映射到权限键 +4. 检查用户是否拥有该权限 +5. 如果有权限,继续处理请求;否则返回403 Forbidden + +**示例**: +```javascript +// GET /api/v1/postgrest/documents +// 后端自动检查权限: document:document:view + +// POST /api/v1/postgrest/documents +// 后端自动检查权限: document:document:create + +// PATCH /api/v1/postgrest/documents?id=eq.1936 +// 后端自动检查权限: document:document:update + +// DELETE /api/v1/postgrest/documents?id=eq.1936 +// 后端自动检查权限: document:document:delete +``` + +### 4.3 前端权限控制 + +虽然后端会自动检查权限,但前端也应该根据用户权限**隐藏或禁用**无权操作的按钮和菜单。 + +#### 4.3.1 获取当前用户权限 + +```javascript +// 从Token中解析用户信息(包含角色) +function getUserInfo() { + const userInfoStr = localStorage.getItem('user_info'); + if (!userInfoStr) return null; + return JSON.parse(userInfoStr); +} + +// 检查用户是否有特定角色 +function hasRole(roleName) { + const userInfo = getUserInfo(); + return userInfo?.roles?.includes(roleName) || false; +} + +// 使用示例 +if (hasRole('系统管理员')) { + // 显示管理员菜单 +} +``` + +#### 4.3.2 权限指令(Vue示例) + +```vue + + + + +``` + +**推荐做法**: +1. 登录时从后端获取用户的完整权限列表(包括通过角色继承的权限和直接分配的权限) +2. 将权限列表存储到Vuex/Pinia/Redux中 +3. 前端根据权限列表控制UI元素的显示/隐藏 + +--- + +## 5. 数据范围过滤 + +### 5.1 数据范围类型 + +DocAuditAI支持5种数据范围类型: + +| 数据范围 | 说明 | 过滤逻辑 | +|---------|------|---------| +| **ALL** | 全部数据 | 无过滤,可查看所有数据 | +| **DEPT** | 本部门数据 | `ou_id = 用户的ou_id` | +| **DEPT_AND_SUB** | 本部门及下级部门数据 | `ou_id IN (用户的ou_id_tree)` | +| **SELF** | 本人数据 | `user_id = 用户ID` | +| **CUSTOM** | 自定义规则 | 根据自定义SQL表达式过滤 | + +### 5.2 自动数据范围过滤 + +**所有通过PostgREST代理的查询请求都会自动应用数据范围过滤**,前端无需关心过滤逻辑。 + +**示例**: +```javascript +// 用户A(数据范围SELF,user_id=9) +// 请求: GET /api/v1/postgrest/documents +// 后端自动添加过滤: user_id=eq.9 + +// 用户B(数据范围DEPT,ou_id=yunfu002) +// 请求: GET /api/v1/postgrest/documents +// 后端自动添加过滤: ou_id=eq.yunfu002 + +// 用户C(数据范围ALL) +// 请求: GET /api/v1/postgrest/documents +// 后端无过滤,返回所有文档 +``` + +### 5.3 前端注意事项 + +1. **不要尝试绕过数据范围过滤** + 后端会强制覆盖前端提供的`ou_id`或`user_id`参数,尝试绕过会被拒绝或忽略。 + + ```javascript + // ❌ 错误示例:尝试访问其他部门数据 + const response = await apiClient.get('/postgrest/documents', { + params: { + ou_id: 'eq.guangzhou001' // 后端会覆盖为用户自己的ou_id + } + }); + + // ✅ 正确示例:正常查询,后端自动应用数据范围 + const response = await apiClient.get('/postgrest/documents'); + ``` + +2. **数据范围对不同操作的影响** + - **查询(GET)**: 自动过滤,只返回权限范围内的数据 + - **创建(POST)**: 验证ou_id/user_id是否在权限范围内 + - **更新(PATCH)**: 只能更新权限范围内的数据 + - **删除(DELETE)**: 只能删除权限范围内的数据 + +--- + +## 6. 交叉评查权限 + +### 6.1 交叉评查权限概述 + +交叉评查允许用户**跨部门访问**特定文档,即使这些文档不在其常规数据范围内。 + +**应用场景**: +- 用户A(云浮部门)参与了一个交叉评查任务,该任务包含来自梅州部门的文档 +- 用户A可以查看和评审这些梅州文档,尽管其常规数据范围是SELF(只能看自己的) + +### 6.2 交叉评查权限逻辑 + +对于配置了`special_handling='cross_review_mixed'`的表(如`documents`),后端会: + +1. **GET请求**: 扩展访问范围(常规数据范围 **OR** 交叉评查文档) + ``` + 常规过滤: user_id=eq.9 + 交叉评查扩展: id.in.(1936,1937) + 最终过滤: or=(id.in.(1936,1937),user_id.eq.9) + ``` + +2. **PATCH/DELETE请求**: 检查目标文档是否在交叉评查范围 + - 如果是交叉评查文档 → 移除常规数据范围限制,允许操作 + - 如果不是 → 保持常规数据范围限制 + +### 6.3 前端使用示例 + +前端无需特殊处理,后端会自动处理交叉评查权限。 + +```javascript +// 场景:用户9(数据范围SELF)参与了任务183的交叉评查 +// 任务183包含文档[1936, 1937](来自其他用户) + +// 1. 查询文档列表 +const documents = await apiClient.get('/postgrest/documents'); +// 返回: +// - 用户自己的文档(user_id=9) +// - 交叉评查文档[1936, 1937] + +// 2. 更新交叉评查文档 +const updated = await apiClient.patch( + '/postgrest/documents', + { status: 'reviewed' }, + { params: { id: 'eq.1936' } } +); +// 成功:后端检测到1936是交叉评查文档,允许更新 + +// 3. 尝试更新其他用户的非交叉评查文档 +try { + await apiClient.patch( + '/postgrest/documents', + { status: 'reviewed' }, + { params: { id: 'eq.9999' } } // 不在交叉评查范围 + ); +} catch (error) { + console.error('无权更新此文档'); // 403 Forbidden +} +``` + +### 6.4 获取交叉评查任务 + +```javascript +// 查询当前用户的交叉评查任务 +async function getCrossReviewTasks() { + const response = await apiClient.get('/postgrest/cross_examination_tasks'); + return response.data; +} + +// 响应示例 +[ + { + "id": 183, + "task_name": "2025年第一季度交叉评查", + "status": "in_progress", + "created_by": 5, + "user_ids": [9, 10, 11], // 参与用户 + "created_at": "2025-11-10T09:00:00Z" + } +] +``` + +--- + +## 7. 错误处理 + +### 7.1 HTTP状态码 + +| 状态码 | 说明 | 处理建议 | +|--------|------|---------| +| **200 OK** | 请求成功 | 正常处理数据 | +| **201 Created** | 创建成功 | 显示成功消息 | +| **204 No Content** | 删除成功 | 显示成功消息 | +| **400 Bad Request** | 请求参数错误 | 检查请求参数格式 | +| **401 Unauthorized** | 未认证或Token无效 | 跳转到登录页 | +| **403 Forbidden** | 无权限 | 显示"无权限"提示 | +| **404 Not Found** | 资源不存在 | 显示"资源不存在"提示 | +| **500 Internal Server Error** | 服务器错误 | 显示"服务器错误"提示 | + +### 7.2 错误响应格式 + +**标准错误响应**: +```json +{ + "detail": "错误描述" +} +``` + +**详细错误响应**(包含更多上下文): +```json +{ + "detail": "权限不足", + "error_code": "PERMISSION_DENIED", + "permission_required": "document:document:delete", + "user_id": 9 +} +``` + +### 7.3 全局错误处理 + +```javascript +// axios-instance.js +apiClient.interceptors.response.use( + response => response, + error => { + const status = error.response?.status; + const detail = error.response?.data?.detail || '未知错误'; + + switch (status) { + case 400: + // 请求参数错误 + console.error('请求参数错误:', detail); + alert(`请求参数错误: ${detail}`); + break; + + case 401: + // 未认证或Token无效 + console.error('认证失败,跳转到登录页'); + localStorage.removeItem('access_token'); + localStorage.removeItem('user_info'); + window.location.href = '/login'; + break; + + case 403: + // 无权限 + console.error('权限不足:', detail); + alert(`权限不足: ${detail}`); + break; + + case 404: + // 资源不存在 + console.error('资源不存在:', detail); + alert(`资源不存在: ${detail}`); + break; + + case 500: + // 服务器错误 + console.error('服务器错误:', detail); + alert(`服务器错误,请稍后重试`); + break; + + default: + console.error('未知错误:', error); + alert(`请求失败,请稍后重试`); + } + + return Promise.reject(error); + } +); +``` + +### 7.4 特定错误处理 + +```javascript +// 创建文档时处理权限错误 +async function createDocumentWithErrorHandling(data) { + try { + const response = await apiClient.post('/postgrest/documents', data); + alert('文档创建成功'); + return response.data[0]; + } catch (error) { + if (error.response?.status === 403) { + const detail = error.response.data?.detail || '权限不足'; + if (detail.includes('数据范围')) { + alert('您无权在此组织单位创建文档'); + } else { + alert('您没有创建文档的权限'); + } + } else { + alert('创建文档失败,请重试'); + } + throw error; + } +} +``` + +--- + +## 8. 常用示例 + +### 8.1 文档列表页面 + +```vue + + + +``` + +### 8.2 文档详情页面 + +```vue + + + +``` + +--- + +## 9. 最佳实践 + +### 9.1 安全最佳实践 + +1. **始终使用HTTPS** + 生产环境必须使用HTTPS传输,防止Token被窃取。 + +2. **安全存储Token** + - 使用`localStorage`或`sessionStorage`存储Token + - 不要将Token存储在Cookie中(避免CSRF攻击) + - 不要将Token暴露在URL参数中 + +3. **Token过期处理** + - 定期检查Token是否过期 + - Token过期后立即跳转到登录页 + - 提供Token刷新机制(如果后端支持) + +4. **不要绕过前端权限检查** + - 即使前端隐藏了按钮,用户仍可能通过开发者工具发起请求 + - 后端会强制执行权限检查,前端绕过无效 + +### 9.2 性能最佳实践 + +1. **分页查询** + 始终使用分页,避免一次性加载大量数据。 + + ```javascript + // ✅ 好的做法 + const response = await apiClient.get('/postgrest/documents', { + params: { limit: 10, offset: 0 } + }); + + // ❌ 不好的做法 + const response = await apiClient.get('/postgrest/documents'); // 返回所有数据 + ``` + +2. **字段选择** + 只查询需要的字段,减少数据传输量。 + + ```javascript + // ✅ 好的做法 + const response = await apiClient.get('/postgrest/documents', { + params: { select: 'id,title,status' } + }); + + // ❌ 不好的做法 + const response = await apiClient.get('/postgrest/documents'); // 返回所有字段 + ``` + +3. **缓存数据** + 对于不经常变化的数据(如字典表、配置表),使用前端缓存。 + + ```javascript + // 缓存字典数据 + let documentTypesCache = null; + + async function getDocumentTypes() { + if (documentTypesCache) { + return documentTypesCache; + } + + const response = await apiClient.get('/postgrest/document_types'); + documentTypesCache = response.data; + + // 5分钟后过期 + setTimeout(() => { + documentTypesCache = null; + }, 5 * 60 * 1000); + + return documentTypesCache; + } + ``` + +4. **批量操作** + 尽量减少请求次数,使用批量操作。 + + ```javascript + // ❌ 不好的做法:逐个删除 + for (const id of [1, 2, 3]) { + await apiClient.delete('/postgrest/documents', { + params: { id: `eq.${id}` } + }); + } + + // ✅ 好的做法:批量删除 + await apiClient.delete('/postgrest/documents', { + params: { id: `in.(1,2,3)` } + }); + ``` + +### 9.3 用户体验最佳实践 + +1. **加载状态** + 显示加载指示器,提升用户体验。 + + ```vue + + + + ``` + +2. **错误提示** + 友好的错误提示,避免显示技术细节。 + + ```javascript + // ❌ 不好的做法 + alert(error.message); // "Cannot read property 'data' of undefined" + + // ✅ 好的做法 + alert('加载数据失败,请稍后重试'); + ``` + +3. **操作确认** + 对于删除等危险操作,提供确认提示。 + + ```javascript + async function deleteDocument(id) { + if (!confirm('确定要删除此文档吗?此操作不可撤销。')) { + return; + } + + try { + await apiClient.delete('/postgrest/documents', { + params: { id: `eq.${id}` } + }); + alert('删除成功'); + } catch (error) { + alert('删除失败'); + } + } + ``` + +--- + +## 10. 故障排查 + +### 10.1 常见问题 + +#### 问题1:401 Unauthorized - Token无效 + +**症状**: 所有API请求返回401错误。 + +**原因**: +- Token已过期 +- Token格式错误 +- Token未正确携带在请求头中 + +**解决方法**: +1. 检查Token是否存在:`localStorage.getItem('access_token')` +2. 检查Token格式是否正确(应为`Bearer {token}`) +3. 检查Token是否过期 +4. 重新登录获取新Token + +#### 问题2:403 Forbidden - 权限不足 + +**症状**: 某些API请求返回403错误。 + +**原因**: +- 用户没有该操作的权限 +- 尝试访问不在数据范围内的数据 + +**解决方法**: +1. 确认用户是否有对应的权限(检查用户角色) +2. 确认数据是否在用户的数据范围内 +3. 联系管理员分配权限 + +#### 问题3:CORS错误 + +**症状**: 浏览器控制台显示CORS错误。 + +**原因**: +- 前端域名未在后端CORS白名单中 + +**解决方法**: +1. 联系后端开发人员将前端域名添加到CORS白名单 +2. 开发环境可以配置代理绕过CORS + +**Vue.js开发环境代理配置**: +```javascript +// vue.config.js +module.exports = { + devServer: { + proxy: { + '/api': { + target: 'http://localhost:8000', + changeOrigin: true + } + } + } +}; +``` + +#### 问题4:数据未按预期过滤 + +**症状**: 查询返回了不应该看到的数据。 + +**原因**: +- 后端数据范围配置错误 +- 前端缓存了旧数据 + +**解决方法**: +1. 清除浏览器缓存和localStorage +2. 检查用户的数据范围配置是否正确 +3. 联系后端开发人员检查RBAC配置 + +### 10.2 调试技巧 + +#### 1. 查看请求详情 + +使用浏览器开发者工具查看请求详情: +1. 打开开发者工具(F12) +2. 切换到Network标签 +3. 发起请求 +4. 点击请求查看Headers、Payload、Response + +#### 2. 查看Token内容 + +```javascript +// 解码JWT Token +function decodeToken(token) { + try { + const payload = JSON.parse(atob(token.split('.')[1])); + console.log('Token内容:', payload); + return payload; + } catch (e) { + console.error('Token解码失败:', e); + return null; + } +} + +// 使用示例 +const token = localStorage.getItem('access_token'); +decodeToken(token); +``` + +#### 3. 启用详细日志 + +```javascript +// axios-instance.js +apiClient.interceptors.request.use(config => { + console.log('[Request]', config.method.toUpperCase(), config.url, config.params); + return config; +}); + +apiClient.interceptors.response.use( + response => { + console.log('[Response]', response.status, response.data); + return response; + }, + error => { + console.error('[Error]', error.response?.status, error.response?.data); + return Promise.reject(error); + } +); +``` + +--- + +## 附录 + +### A. 完整API端点列表 + +详见:[API端点列表文档](./前端对接文档-API端点列表.md) + +### B. PostgREST过滤运算符完整列表 + +详见:[PostgREST查询参考](./前端对接文档-PostgREST查询参考.md) + +### C. 权限列表 + +详见:[权限列表文档](./前端对接文档-权限列表.md) + +--- + +## 联系支持 + +如有问题或建议,请联系: +- **技术支持**: support@docauditai.com +- **开发团队**: dev@docauditai.com + +--- + +**文档版本**: v1.0 +**最后更新**: 2025-11-17 +**维护者**: Claude Code diff --git a/auth_doc/前端快速开始_5分钟集成.md b/auth_doc/前端快速开始_5分钟集成.md new file mode 100644 index 0000000..0205862 --- /dev/null +++ b/auth_doc/前端快速开始_5分钟集成.md @@ -0,0 +1,333 @@ +# 前端快速开始 - 5分钟集成RBAC系统 + +**版本**: v1.0 +**日期**: 2025-11-17 +**目标**: 最快速度集成登录、动态路由和数据访问 + +--- + +## ⚡ 5分钟集成步骤 + +### 步骤1: 安装依赖(1分钟) + +```bash +npm install axios pinia vue-router@4 element-plus +``` + +### 步骤2: 配置环境变量(30秒) + +创建 `.env.development`: + +```env +VITE_API_BASE_URL=http://172.16.0.55:8073 +``` + +### 步骤3: 创建请求工具(1分钟) + +**文件**: `src/utils/request.ts` + +```typescript +import axios from 'axios'; +import { ElMessage } from 'element-plus'; + +const service = axios.create({ + baseURL: import.meta.env.VITE_API_BASE_URL || 'http://172.16.0.55:8073', + timeout: 30000 +}); + +// 请求拦截器:自动添加Token +service.interceptors.request.use(config => { + const token = localStorage.getItem('token'); + if (token) { + config.headers['Authorization'] = `Bearer ${token}`; + } + return config; +}); + +// 响应拦截器:统一错误处理 +service.interceptors.response.use( + response => response, + error => { + if (error.response?.status === 401) { + ElMessage.error('登录已过期'); + localStorage.clear(); + window.location.href = '/login'; + } + return Promise.reject(error); + } +); + +export default service; +``` + +### 步骤4: 创建用户Store(2分钟) + +**文件**: `src/stores/user.ts` + +```typescript +import { defineStore } from 'pinia'; +import { ref } from 'vue'; +import request from '@/utils/request'; +import router from '@/router'; + +export const useUserStore = defineStore('user', () => { + const token = ref(''); + const userInfo = ref(null); + const routes = ref([]); + + // 登录 + const login = async (username: string, password: string) => { + const res = await request.post('/auth/login', { username, password }); + + if (res.data.success) { + token.value = res.data.data.access_token; + userInfo.value = res.data.data.user_info; + + localStorage.setItem('token', token.value); + localStorage.setItem('userInfo', JSON.stringify(userInfo.value)); + + return true; + } + return false; + }; + + // 获取路由 + const fetchRoutes = async () => { + const res = await request.get('/user/routes'); + + if (res.data.code === 200) { + routes.value = res.data.data.routes; + + // 动态注册路由 + routes.value.forEach(route => { + router.addRoute({ + path: route.route_path, + name: route.route_name, + component: () => import(`@/views/${route.component}.vue`), + meta: { title: route.route_title } + }); + }); + + return routes.value; + } + }; + + // 登出 + const logout = () => { + token.value = ''; + userInfo.value = null; + routes.value = []; + localStorage.clear(); + router.push('/login'); + }; + + return { token, userInfo, routes, login, fetchRoutes, logout }; +}); +``` + +### 步骤5: 配置路由守卫(30秒) + +**文件**: `src/router/index.ts` + +```typescript +import { createRouter, createWebHistory } from 'vue-router'; +import { useUserStore } from '@/stores/user'; + +const router = createRouter({ + history: createWebHistory(), + routes: [ + { path: '/login', component: () => import('@/views/Login.vue') } + ] +}); + +router.beforeEach(async (to, from, next) => { + const userStore = useUserStore(); + const token = localStorage.getItem('token'); + + if (token && !userStore.token) { + userStore.token = token; + userStore.userInfo = JSON.parse(localStorage.getItem('userInfo') || '{}'); + } + + if (to.path !== '/login' && !token) { + next('/login'); + } else if (token && !userStore.routes.length) { + await userStore.fetchRoutes(); + next({ ...to, replace: true }); + } else { + next(); + } +}); + +export default router; +``` + +### 步骤6: 创建登录页(30秒) + +**文件**: `src/views/Login.vue` + +```vue + + + +``` + +--- + +## ✅ 完成! + +现在你已经完成了基础集成,可以: + +1. ✅ 用户登录 +2. ✅ 动态加载路由 +3. ✅ 访问受保护的页面 + +--- + +## 🚀 下一步:数据访问 + +### 查询数据 + +```typescript +import request from '@/utils/request'; + +// 查询文档列表 +const fetchDocuments = async () => { + const res = await request.get('/documents', { + params: { + user_id: `eq.${userInfo.user_id}`, // 只查自己的数据 + limit: 20, + offset: 0 + } + }); + return res.data; +}; +``` + +### 创建数据 + +```typescript +const createDocument = async (data: any) => { + const res = await request.post('/documents', data); + return res.data; +}; +``` + +### 更新数据 + +```typescript +const updateDocument = async (id: number, data: any) => { + const res = await request.patch(`/documents?id=eq.${id}`, data); + return res.data; +}; +``` + +### 删除数据 + +```typescript +const deleteDocument = async (id: number) => { + const res = await request.delete(`/documents?id=eq.${id}`); + return res.data; +}; +``` + +--- + +## 📚 测试账号 + +| 用户名 | 密码 | 角色 | 说明 | +|--------|------|------|------| +| 000 | admin06111 | 超级管理员 | 所有权限(29个路由) | +| 001 | gdyc06111 | 普通用户 | 有限权限(19个路由) | +| jy001 | jyyc0814 | 系统管理员 | 所有权限(29个路由) | + +--- + +## 🔧 常见问题 + +### 1. Token过期怎么办? + +后端返回401时,自动清除Token并跳转登录页。 + +### 2. 路由404怎么办? + +检查组件路径是否正确: + +```typescript +// 后端返回: component: "views/Home" +// 前端import: @/views/views/Home.vue ❌ + +// 修改为: +component: () => import(`@/views/Home.vue`) +``` + +### 3. 普通用户能看到所有数据? + +前端必须手动添加 `user_id` 过滤: + +```typescript +// ❌ 错误 +const docs = await request.get('/documents'); + +// ✅ 正确 +const docs = await request.get('/documents', { + params: { user_id: `eq.${userInfo.user_id}` } +}); +``` + +--- + +## 📖 详细文档 + +完整API说明请查看: +- **完整对接文档**: `docs/RBAC/前端完整对接文档_RBAC与PostgREST.md` + +--- + +**创建时间**: 2025-11-17 +**维护者**: Claude Code diff --git a/auth_doc/角色-路由权限实现方案.md b/auth_doc/角色-路由权限实现方案.md new file mode 100644 index 0000000..3df0859 --- /dev/null +++ b/auth_doc/角色-路由权限实现方案.md @@ -0,0 +1,1180 @@ +# 角色-路由权限实现方案 + +**版本**: v1.0 +**日期**: 2025-11-17 +**状态**: 设计完成,待实现 + +--- + +## 目录 + +1. [功能概述](#功能概述) +2. [设计原则](#设计原则) +3. [架构设计](#架构设计) +4. [数据库设计](#数据库设计) +5. [后端实现](#后端实现) +6. [前端实现](#前端实现) +7. [安全策略](#安全策略) +8. [性能优化](#性能优化) +9. [实施步骤](#实施步骤) +10. [测试方案](#测试方案) + +--- + +## 功能概述 + +### 业务需求 + +实现**基于角色的前端路由权限控制**,使不同角色的用户登录后看到不同的菜单和页面: + +- ✅ **系统管理员** - 可访问所有路由(用户管理、角色管理、权限管理等) +- ✅ **文档管理员** - 可访问文档管理相关路由 +- ✅ **文档审查员** - 可访问文档查看和评查路由 +- ✅ **普通用户** - 仅可访问基础路由(首页、个人中心) + +### 核心功能 + +1. **路由定义管理** - 系统路由的增删改查 +2. **角色-路由关联** - 配置角色可访问的路由 +3. **用户路由查询** - 根据用户角色获取可访问路由列表 +4. **动态菜单生成** - 前端根据路由列表动态生成菜单 +5. **路由守卫** - 前端路由守卫拦截未授权访问 + +--- + +## 设计原则 + +### 1. 高可用性原则 + +- **缓存优先**: 路由权限数据缓存到 Redis(TTL: 30分钟) +- **降级策略**: Redis 不可用时直接查询数据库 +- **异步刷新**: 后台定时刷新缓存,避免缓存雪崩 + +### 2. 高性能原则 + +- **单次查询**: 用户登录时一次性获取所有可访问路由 +- **前端缓存**: 路由数据存储在前端 LocalStorage +- **懒加载**: 路由组件按需加载 + +### 3. 安全性原则 + +- **前后端双重验证**: 前端路由守卫 + 后端 API 权限校验 +- **最小权限**: 默认拒绝所有访问,显式授权 +- **审计日志**: 记录路由访问日志 + +### 4. 可维护性原则 + +- **配置化**: 路由通过数据库配置,无需修改代码 +- **层级结构**: 支持多级路由嵌套 +- **元信息**: 路由携带图标、标题等元信息 + +--- + +## 架构设计 + +### 整体架构 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ 前端应用 │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ 路由守卫 │ │ 动态菜单 │ │ 权限指令 │ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ │ +└─────────────────────────────────────────────────────────────┘ + ↓ HTTP请求 +┌─────────────────────────────────────────────────────────────┐ +│ FastAPI 后端 │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ 路由权限API │ │ 路由管理API │ │ 角色管理API │ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ │ +│ ┌──────────────────────────────────────────────────┐ │ +│ │ RoutePermission 模块 │ │ +│ │ - get_user_routes() 获取用户可访问路由 │ │ +│ │ - get_role_routes() 获取角色可访问路由 │ │ +│ │ - check_route_access() 检查路由访问权限 │ │ +│ └──────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────┐ +│ 数据层 │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ sys_routes │ │ role_route │ │ Redis Cache │ │ +│ │ (路由定义) │ │ (角色-路由) │ │ (路由缓存) │ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ │ +└─────────────────────────────────────────────────────────────┘ +``` + +### 数据流向 + +#### 用户登录流程 + +``` +1. 用户登录 + ↓ +2. 后端验证凭据 + ↓ +3. 查询用户角色 + ↓ +4. 根据角色查询可访问路由(优先从 Redis 缓存读取) + ↓ +5. 返回用户信息 + 路由列表 + ↓ +6. 前端存储路由数据到 LocalStorage + ↓ +7. Vue Router 动态注册路由 + ↓ +8. 根据路由生成侧边栏菜单 +``` + +#### 路由访问流程 + +``` +1. 用户访问路由(如 /system/users) + ↓ +2. 前端路由守卫拦截 + ↓ +3. 检查路由是否在可访问列表 + ↓ +4. [是] 允许访问 → 加载页面组件 + ↓ +5. [否] 拒绝访问 → 跳转 403 页面 +``` + +--- + +## 数据库设计 + +### 表结构 + +#### 1. sys_routes(系统路由表) + +已存在,需要确认字段: + +```sql +CREATE TABLE IF NOT EXISTS sys_routes ( + id SERIAL PRIMARY KEY, + route_path VARCHAR(255) NOT NULL, -- 路由路径 /system/users + route_name VARCHAR(100) NOT NULL, -- 路由名称 UserManagement + component VARCHAR(255), -- 组件路径 views/system/Users.vue + parent_id INTEGER, -- 父路由ID(支持多级路由) + route_title VARCHAR(100), -- 路由标题(中文) + icon VARCHAR(50), -- 图标名称 + sort_order INTEGER DEFAULT 0, -- 排序顺序 + is_hidden BOOLEAN DEFAULT FALSE, -- 是否隐藏(隐藏的路由不显示在菜单) + is_cache BOOLEAN DEFAULT TRUE, -- 是否缓存(KeepAlive) + meta JSONB, -- 元信息(扩展字段) + status INTEGER DEFAULT 0, -- 状态 0=启用 1=禁用 + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP, + + CONSTRAINT fk_parent_route FOREIGN KEY (parent_id) REFERENCES sys_routes(id) ON DELETE CASCADE +); + +-- 索引 +CREATE INDEX idx_routes_parent_id ON sys_routes(parent_id); +CREATE INDEX idx_routes_status ON sys_routes(status); +CREATE UNIQUE INDEX idx_routes_path ON sys_routes(route_path) WHERE deleted_at IS NULL; +``` + +**字段说明**: +- `route_path`: 前端路由路径,如 `/system/users` +- `route_name`: 路由名称(唯一标识),如 `UserManagement` +- `component`: 组件路径,如 `views/system/Users.vue` +- `parent_id`: 父路由ID,支持多级嵌套菜单 +- `route_title`: 菜单显示的标题(中文) +- `icon`: 图标(Element Plus / Ant Design 图标名) +- `is_hidden`: 是否在菜单中隐藏(某些路由不显示在菜单,但可访问) +- `meta`: JSON 扩展字段,存储额外信息(如权限标识、面包屑等) + +#### 2. role_route(角色-路由关联表) + +已存在,需要确认字段: + +```sql +CREATE TABLE IF NOT EXISTS role_route ( + id SERIAL PRIMARY KEY, + role_id INTEGER NOT NULL, + route_id INTEGER NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT fk_role FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE, + CONSTRAINT fk_route FOREIGN KEY (route_id) REFERENCES sys_routes(id) ON DELETE CASCADE, + CONSTRAINT uk_role_route UNIQUE (role_id, route_id) +); + +-- 索引 +CREATE INDEX idx_role_route_role_id ON role_route(role_id); +CREATE INDEX idx_role_route_route_id ON role_route(route_id); +``` + +### 数据示例 + +#### 系统路由数据 + +```sql +-- 一级路由:首页 +INSERT INTO sys_routes (id, route_path, route_name, component, route_title, icon, sort_order) +VALUES (1, '/dashboard', 'Dashboard', 'views/Dashboard.vue', '首页', 'el-icon-house', 1); + +-- 一级路由:系统管理(父菜单) +INSERT INTO sys_routes (id, route_path, route_name, component, route_title, icon, sort_order) +VALUES (2, '/system', 'System', 'Layout', '系统管理', 'el-icon-setting', 10); + +-- 二级路由:用户管理 +INSERT INTO sys_routes (id, route_path, route_name, component, parent_id, route_title, icon, sort_order) +VALUES (3, '/system/users', 'SystemUsers', 'views/system/Users.vue', 2, '用户管理', 'el-icon-user', 1); + +-- 二级路由:角色管理 +INSERT INTO sys_routes (id, route_path, route_name, component, parent_id, route_title, icon, sort_order) +VALUES (4, '/system/roles', 'SystemRoles', 'views/system/Roles.vue', 2, '角色管理', 'el-icon-user-filled', 2); + +-- 一级路由:文档管理 +INSERT INTO sys_routes (id, route_path, route_name, component, route_title, icon, sort_order) +VALUES (10, '/documents', 'Documents', 'Layout', '文档管理', 'el-icon-document', 20); + +-- 二级路由:文档列表 +INSERT INTO sys_routes (id, route_path, route_name, component, parent_id, route_title, icon, sort_order) +VALUES (11, '/documents/list', 'DocumentList', 'views/documents/List.vue', 10, '文档列表', 'el-icon-tickets', 1); +``` + +#### 角色-路由关联数据 + +```sql +-- 系统管理员 - 拥有所有路由 +INSERT INTO role_route (role_id, route_id) +SELECT 1, id FROM sys_routes WHERE deleted_at IS NULL; + +-- 文档管理员 - 拥有首页 + 文档管理路由 +INSERT INTO role_route (role_id, route_id) +VALUES (2, 1), (2, 10), (2, 11); + +-- 普通用户 - 只有首页 +INSERT INTO role_route (role_id, route_id) +VALUES (8, 1); +``` + +--- + +## 后端实现 + +### 文件结构 + +``` +app/ +├── rbac/ +│ ├── route_permission.py # 路由权限检查模块(新建) +│ ├── permission_checker.py # 现有权限检查模块 +│ └── data_scope_injector.py # 现有数据范围注入模块 +├── routes/ +│ └── rbac_routes.py # RBAC 路由管理接口(新建) +``` + +### 核心模块:route_permission.py + +```python +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +""" +路由权限检查模块 + +提供基于角色的路由权限检查功能 +""" + +from typing import List, Dict, Optional, Set +import asyncpg +from core.database import get_asyncpg_pool +from core.redis_client import get_redis_client +from core.logger import rbac_logger +import json + +class RoutePermission: + """路由权限管理器""" + + # Redis 缓存键前缀 + CACHE_PREFIX = "rbac:routes" + CACHE_TTL = 1800 # 30分钟 + + @classmethod + async def get_user_routes( + cls, + user_id: int, + use_cache: bool = True + ) -> List[Dict]: + """ + 获取用户可访问的路由列表 + + Args: + user_id: 用户ID + use_cache: 是否使用缓存 + + Returns: + 路由列表(树形结构) + """ + # 1. 尝试从缓存获取 + if use_cache: + cached = await cls._get_routes_from_cache(user_id) + if cached is not None: + rbac_logger.info(f"路由权限缓存命中: user={user_id}") + return cached + + # 2. 查询用户角色 + pool = await get_asyncpg_pool() + async with pool.acquire() as conn: + roles = await conn.fetch( + """ + SELECT r.id, r.role_key, r.role_name + FROM user_role ur + JOIN roles r ON ur.role_id = r.id + WHERE ur.user_id = $1 + """, + user_id + ) + + if not roles: + rbac_logger.warning(f"用户无角色: user={user_id}") + return [] + + role_ids = [role['id'] for role in roles] + + # 3. 查询角色可访问的路由 + routes = await conn.fetch( + """ + SELECT DISTINCT + sr.id, + sr.route_path, + sr.route_name, + sr.component, + sr.parent_id, + sr.route_title, + sr.icon, + sr.sort_order, + sr.is_hidden, + sr.is_cache, + sr.meta + FROM role_route rr + JOIN sys_routes sr ON rr.route_id = sr.id + WHERE rr.role_id = ANY($1) + AND sr.status = 0 + AND sr.deleted_at IS NULL + ORDER BY sr.sort_order, sr.id + """, + role_ids + ) + + # 4. 转换为字典列表 + route_list = [dict(route) for route in routes] + + # 5. 构建树形结构 + route_tree = cls._build_route_tree(route_list) + + # 6. 缓存结果 + if use_cache: + await cls._cache_routes(user_id, route_tree) + + rbac_logger.info(f"查询用户路由: user={user_id}, routes={len(route_list)}") + return route_tree + + @classmethod + async def get_role_routes( + cls, + role_id: int, + use_cache: bool = True + ) -> List[Dict]: + """ + 获取角色可访问的路由列表 + + Args: + role_id: 角色ID + use_cache: 是否使用缓存 + + Returns: + 路由列表 + """ + # 缓存键 + cache_key = f"{cls.CACHE_PREFIX}:role:{role_id}" + + # 1. 尝试从缓存获取 + if use_cache: + redis = await get_redis_client() + try: + cached = await redis.get(cache_key) + if cached: + rbac_logger.info(f"角色路由缓存命中: role={role_id}") + return json.loads(cached) + except Exception as e: + rbac_logger.warning(f"读取路由缓存失败: {e}") + + # 2. 查询数据库 + pool = await get_asyncpg_pool() + async with pool.acquire() as conn: + routes = await conn.fetch( + """ + SELECT + sr.id, + sr.route_path, + sr.route_name, + sr.route_title + FROM role_route rr + JOIN sys_routes sr ON rr.route_id = sr.id + WHERE rr.role_id = $1 + AND sr.status = 0 + AND sr.deleted_at IS NULL + ORDER BY sr.sort_order + """, + role_id + ) + + route_list = [dict(route) for route in routes] + + # 3. 缓存结果 + if use_cache: + redis = await get_redis_client() + try: + await redis.setex( + cache_key, + cls.CACHE_TTL, + json.dumps(route_list, ensure_ascii=False) + ) + except Exception as e: + rbac_logger.warning(f"缓存路由失败: {e}") + + return route_list + + @classmethod + async def check_route_access( + cls, + user_id: int, + route_path: str + ) -> bool: + """ + 检查用户是否有访问指定路由的权限 + + Args: + user_id: 用户ID + route_path: 路由路径 + + Returns: + 是否有权限 + """ + routes = await cls.get_user_routes(user_id, use_cache=True) + + # 递归检查路由树 + def check_in_tree(routes_list: List[Dict]) -> bool: + for route in routes_list: + if route['route_path'] == route_path: + return True + if route.get('children'): + if check_in_tree(route['children']): + return True + return False + + has_access = check_in_tree(routes) + + rbac_logger.info( + f"路由访问检查: user={user_id}, route={route_path}, " + f"result={has_access}" + ) + + return has_access + + @classmethod + def _build_route_tree(cls, routes: List[Dict]) -> List[Dict]: + """ + 构建路由树形结构 + + Args: + routes: 路由列表(扁平) + + Returns: + 路由树(嵌套) + """ + # 按 parent_id 分组 + route_map = {} + root_routes = [] + + for route in routes: + route_id = route['id'] + route_map[route_id] = route + route['children'] = [] + + # 构建父子关系 + for route in routes: + parent_id = route.get('parent_id') + if parent_id and parent_id in route_map: + route_map[parent_id]['children'].append(route) + else: + root_routes.append(route) + + # 移除空 children + def remove_empty_children(routes_list): + for route in routes_list: + if not route['children']: + del route['children'] + else: + remove_empty_children(route['children']) + + remove_empty_children(root_routes) + + return root_routes + + @classmethod + async def _get_routes_from_cache(cls, user_id: int) -> Optional[List[Dict]]: + """从缓存获取路由列表""" + cache_key = f"{cls.CACHE_PREFIX}:user:{user_id}" + redis = await get_redis_client() + + try: + cached = await redis.get(cache_key) + if cached: + return json.loads(cached) + except Exception as e: + rbac_logger.warning(f"读取路由缓存失败: {e}") + + return None + + @classmethod + async def _cache_routes(cls, user_id: int, routes: List[Dict]): + """缓存路由列表""" + cache_key = f"{cls.CACHE_PREFIX}:user:{user_id}" + redis = await get_redis_client() + + try: + await redis.setex( + cache_key, + cls.CACHE_TTL, + json.dumps(routes, ensure_ascii=False) + ) + except Exception as e: + rbac_logger.warning(f"缓存路由失败: {e}") + + @classmethod + async def clear_user_routes_cache(cls, user_id: int): + """清除用户路由缓存""" + cache_key = f"{cls.CACHE_PREFIX}:user:{user_id}" + redis = await get_redis_client() + + try: + await redis.delete(cache_key) + rbac_logger.info(f"清除路由缓存: user={user_id}") + except Exception as e: + rbac_logger.warning(f"清除路由缓存失败: {e}") + + @classmethod + async def clear_role_routes_cache(cls, role_id: int): + """清除角色路由缓存""" + cache_key = f"{cls.CACHE_PREFIX}:role:{role_id}" + redis = await get_redis_client() + + try: + await redis.delete(cache_key) + rbac_logger.info(f"清除角色路由缓存: role={role_id}") + except Exception as e: + rbac_logger.warning(f"清除角色路由缓存失败: {e}") +``` + +### API 接口:rbac_routes.py + +```python +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +""" +RBAC 路由管理接口 + +提供路由权限相关的 API 端点 +""" + +from fastapi import APIRouter, Depends, Query +from typing import List, Dict, Optional +from pydantic import BaseModel, Field + +from app.rbac.route_permission import RoutePermission +from app.auth.auth import verify_token, User +from app.base_api import unified_resp +from core.logger import api_logger + +router_rbac = APIRouter(prefix="/rbac", tags=["RBAC-路由权限"]) + + +class RouteResponse(BaseModel): + """路由响应模型""" + id: int + route_path: str + route_name: str + component: Optional[str] + parent_id: Optional[int] + route_title: str + icon: Optional[str] + sort_order: int + is_hidden: bool + is_cache: bool + meta: Optional[Dict] + children: Optional[List['RouteResponse']] = None + + +class UserRoutesResponse(BaseModel): + """用户路由响应""" + user_id: int + username: str + routes: List[RouteResponse] + + +@router_rbac.get("/user/routes", summary="获取当前用户可访问路由") +@unified_resp +async def get_current_user_routes( + current_user: User = Depends(verify_token) +) -> Dict: + """ + 获取当前用户可访问的路由列表(树形结构) + + 返回数据用于前端动态路由和菜单生成 + """ + try: + routes = await RoutePermission.get_user_routes( + user_id=current_user.id, + use_cache=True + ) + + api_logger.info(f"获取用户路由: user={current_user.id}, count={len(routes)}") + + return { + 'user_id': current_user.id, + 'username': current_user.username, + 'routes': routes + } + + except Exception as e: + api_logger.error(f"获取用户路由失败: {e}", exc_info=True) + raise + + +@router_rbac.get("/roles/{role_id}/routes", summary="获取角色可访问路由") +@unified_resp +async def get_role_routes( + role_id: int, + current_user: User = Depends(verify_token) +) -> Dict: + """ + 获取指定角色可访问的路由列表 + + 用于角色管理页面展示角色权限 + """ + try: + routes = await RoutePermission.get_role_routes( + role_id=role_id, + use_cache=True + ) + + api_logger.info(f"获取角色路由: role={role_id}, count={len(routes)}") + + return { + 'role_id': role_id, + 'routes': routes + } + + except Exception as e: + api_logger.error(f"获取角色路由失败: {e}", exc_info=True) + raise + + +@router_rbac.get("/check-route", summary="检查路由访问权限") +@unified_resp +async def check_route_access( + route_path: str = Query(..., description="路由路径"), + current_user: User = Depends(verify_token) +) -> Dict: + """ + 检查当前用户是否有访问指定路由的权限 + + 前端可用此接口动态控制按钮/链接显示 + """ + try: + has_access = await RoutePermission.check_route_access( + user_id=current_user.id, + route_path=route_path + ) + + return { + 'route_path': route_path, + 'has_access': has_access + } + + except Exception as e: + api_logger.error(f"检查路由权限失败: {e}", exc_info=True) + raise +``` + +--- + +## 前端实现 + +### 1. 路由配置(Vue3 + Vue Router 4) + +```javascript +// router/index.js +import { createRouter, createWebHistory } from 'vue-router' +import { useUserStore } from '@/stores/user' + +// 静态路由(无需权限) +const constantRoutes = [ + { + path: '/login', + name: 'Login', + component: () => import('@/views/Login.vue'), + meta: { title: '登录' } + }, + { + path: '/403', + name: 'Forbidden', + component: () => import('@/views/error/403.vue'), + meta: { title: '无权限' } + }, + { + path: '/404', + name: 'NotFound', + component: () => import('@/views/error/404.vue'), + meta: { title: '页面不存在' } + } +] + +const router = createRouter({ + history: createWebHistory(), + routes: constantRoutes +}) + +// 全局路由守卫 +router.beforeEach(async (to, from, next) => { + const userStore = useUserStore() + const token = localStorage.getItem('access_token') + + // 白名单路由 + const whiteList = ['/login', '/403', '/404'] + + if (token) { + if (to.path === '/login') { + next('/') + return + } + + // 检查是否已加载动态路由 + if (!userStore.hasLoadedRoutes) { + try { + // 获取用户路由 + await userStore.loadUserRoutes() + + // 动态添加路由 + const dynamicRoutes = userStore.routes + dynamicRoutes.forEach(route => { + router.addRoute(route) + }) + + // 标记已加载 + userStore.hasLoadedRoutes = true + + // 重新导航到目标路由 + next({ ...to, replace: true }) + return + } catch (error) { + console.error('加载路由失败:', error) + // 清除 token,跳转登录 + localStorage.removeItem('access_token') + next('/login') + return + } + } + + // 检查路由权限 + if (!userStore.hasRoute(to.path)) { + next('/403') + return + } + + next() + } else { + // 未登录 + if (whiteList.includes(to.path)) { + next() + } else { + next(`/login?redirect=${to.path}`) + } + } +}) + +export default router +``` + +### 2. Pinia Store(用户状态管理) + +```javascript +// stores/user.js +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import apiClient from '@/utils/request' + +export const useUserStore = defineStore('user', () => { + const userInfo = ref(null) + const routes = ref([]) + const hasLoadedRoutes = ref(false) + + // 加载用户路由 + async function loadUserRoutes() { + try { + const response = await apiClient.get('/rbac/user/routes') + const data = response.data + + userInfo.value = { + user_id: data.user_id, + username: data.username + } + + // 转换后端路由为 Vue Router 格式 + routes.value = convertRoutes(data.routes) + + // 存储到 LocalStorage + localStorage.setItem('user_routes', JSON.stringify(data.routes)) + + return routes.value + } catch (error) { + console.error('加载用户路由失败:', error) + throw error + } + } + + // 转换路由格式 + function convertRoutes(backendRoutes) { + return backendRoutes.map(route => { + const vueRoute = { + path: route.route_path, + name: route.route_name, + component: () => import(`@/views/${route.component}`), + meta: { + title: route.route_title, + icon: route.icon, + hidden: route.is_hidden, + keepAlive: route.is_cache, + ...route.meta + } + } + + // 递归处理子路由 + if (route.children && route.children.length > 0) { + vueRoute.children = convertRoutes(route.children) + } + + return vueRoute + }) + } + + // 检查是否有指定路由权限 + function hasRoute(path) { + const checkInRoutes = (routeList) => { + for (const route of routeList) { + if (route.path === path) { + return true + } + if (route.children && checkInRoutes(route.children)) { + return true + } + } + return false + } + + return checkInRoutes(routes.value) + } + + // 清除路由数据 + function clearRoutes() { + routes.value = [] + hasLoadedRoutes.value = false + localStorage.removeItem('user_routes') + } + + return { + userInfo, + routes, + hasLoadedRoutes, + loadUserRoutes, + hasRoute, + clearRoutes + } +}) +``` + +### 3. 动态菜单生成 + +```vue + + + + +``` + +```vue + + + + +``` + +--- + +## 安全策略 + +### 1. 前后端双重验证 + +``` +前端路由守卫(第一层防护) + ↓ +后端 API 权限校验(第二层防护) + ↓ +PostgREST 数据权限(第三层防护) +``` + +### 2. Token 防篡改 + +- JWT 签名验证 +- Token 有效期控制(24小时) +- Token 刷新机制 + +### 3. 路由访问审计 + +记录所有路由访问日志: + +```python +# 在路由守卫中记录审计日志 +await AuditLogger.log_route_access( + user_id=user_id, + route_path=route_path, + is_allowed=has_access, + ip_address=request.client.host +) +``` + +### 4. 最小权限原则 + +- 默认拒绝所有访问 +- 显式授权才能访问 +- 未配置的路由一律返回 403 + +--- + +## 性能优化 + +### 1. 缓存策略 + +| 缓存层级 | 存储位置 | TTL | 说明 | +|---------|---------|-----|------| +| 用户路由缓存 | Redis | 30分钟 | 用户可访问路由列表 | +| 角色路由缓存 | Redis | 30分钟 | 角色可访问路由列表 | +| 前端路由缓存 | LocalStorage | 登录期间 | 前端动态路由数据 | + +### 2. 懒加载 + +```javascript +// 路由组件按需加载 +component: () => import('@/views/system/Users.vue') +``` + +### 3. 数据库优化 + +- 索引优化:`parent_id`、`role_id`、`route_id` +- 查询优化:使用 JOIN 减少查询次数 +- 连接池:复用数据库连接 + +--- + +## 实施步骤 + +### 阶段一:数据库准备(第1天) + +1. ✅ 检查 `sys_routes` 和 `role_route` 表结构 +2. ✅ 添加缺失字段(如 `icon`、`is_hidden` 等) +3. ✅ 创建索引 +4. ✅ 编写初始化数据 SQL(系统路由 + 角色-路由关联) + +### 阶段二:后端实现(第2天) + +1. ✅ 实现 `RoutePermission` 模块 +2. ✅ 实现 `/rbac/user/routes` 接口 +3. ✅ 实现 `/rbac/roles/{role_id}/routes` 接口 +4. ✅ 实现 `/rbac/check-route` 接口 +5. ✅ 集成到现有认证流程 + +### 阶段三:前端对接文档(第2天) + +1. ✅ 编写接口文档 +2. ✅ 编写前端集成示例(Vue3) +3. ✅ 编写路由守卫示例 +4. ✅ 编写动态菜单示例 + +### 阶段四:测试验证(第3天) + +1. ✅ 编写单元测试 +2. ✅ 编写集成测试 +3. ✅ 性能测试(缓存命中率、响应时间) +4. ✅ 安全测试(未授权访问、Token 篡改) + +--- + +## 测试方案 + +### 1. 单元测试 + +```python +# tests/test_route_permission.py +async def test_get_user_routes(): + """测试获取用户路由""" + routes = await RoutePermission.get_user_routes(user_id=5) + assert len(routes) > 0 + assert routes[0]['route_path'] == '/dashboard' + + +async def test_check_route_access_allowed(): + """测试路由访问权限(有权限)""" + has_access = await RoutePermission.check_route_access( + user_id=5, + route_path='/system/users' + ) + assert has_access == True + + +async def test_check_route_access_denied(): + """测试路由访问权限(无权限)""" + has_access = await RoutePermission.check_route_access( + user_id=9, + route_path='/system/roles' + ) + assert has_access == False +``` + +### 2. 集成测试 + +```python +# tests/test_rbac_routes_api.py +async def test_get_current_user_routes_api(): + """测试获取当前用户路由接口""" + response = await client.get( + '/rbac/user/routes', + headers={'Authorization': f'Bearer {token}'} + ) + assert response.status_code == 200 + data = response.json() + assert 'routes' in data + assert len(data['routes']) > 0 +``` + +### 3. 性能测试 + +- 缓存命中率 > 95% +- 接口响应时间 < 100ms +- 并发 1000 QPS 无压力 + +--- + +## 风险评估与应对 + +### 风险1:Redis 不可用 + +**应对**:降级策略,直接查询数据库 + +### 风险2:路由数据量大(>1000条) + +**应对**: +- 分页加载 +- 按需加载子菜单 +- 前端虚拟滚动 + +### 风险3:缓存一致性 + +**应对**: +- 更新路由时清除相关缓存 +- 设置合理的 TTL(30分钟) +- 提供手动刷新缓存接口 + +--- + +**文档版本**: v1.0 +**最后更新**: 2025-11-17 +**维护者**: DocAuditAI Team diff --git a/tmp/交叉评查-提出意见.png b/tmp/交叉评查-提出意见.png deleted file mode 100644 index ddca6a9..0000000 Binary files a/tmp/交叉评查-提出意见.png and /dev/null differ diff --git a/tmp/交叉评查上传文件页面.png b/tmp/交叉评查上传文件页面.png deleted file mode 100644 index dc1389f..0000000 Binary files a/tmp/交叉评查上传文件页面.png and /dev/null differ diff --git a/tmp/交叉评查任务列表.png b/tmp/交叉评查任务列表.png deleted file mode 100644 index 5609bb7..0000000 Binary files a/tmp/交叉评查任务列表.png and /dev/null differ