From 3850d05bdde960af434830ed497344abb1559647 Mon Sep 17 00:00:00 2001 From: yorn <1057707203@qq.com> Date: Thu, 20 Nov 2025 20:34:31 +0800 Subject: [PATCH] =?UTF-8?q?feat:=201.=20=E5=B0=86=E5=A4=A7=E9=83=A8?= =?UTF-8?q?=E5=88=86=E7=9A=84=E8=AF=B7=E6=B1=82=E4=BB=8Efetch=E6=94=B9?= =?UTF-8?q?=E6=88=90axios=E6=96=B9=E4=BE=BF=E7=AE=A1=E7=90=86=E3=80=82=202?= =?UTF-8?q?.=20=E7=BB=99=E6=96=87=E6=A1=A3=E7=B1=BB=E5=9E=8B=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=E5=85=A5=E5=8F=A3=E6=A8=A1=E5=9D=97=E5=92=8C=E7=9B=B8?= =?UTF-8?q?=E5=85=B3=E6=95=B0=E6=8D=AE=E7=9A=84=E6=B8=B2=E6=9F=93=E3=80=82?= =?UTF-8?q?=E5=B9=B6=E4=B8=94=E7=BB=99=E6=96=87=E6=A1=A3=E7=B1=BB=E5=9E=8B?= =?UTF-8?q?=E8=BF=9B=E8=A1=8C=E5=8A=9F=E8=83=BD=E4=B8=8A=E7=9A=84=E8=A7=92?= =?UTF-8?q?=E8=89=B2=E6=9D=83=E9=99=90=E5=8C=BA=E5=88=86=203.=20=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E8=A7=92=E8=89=B2=E6=9D=83=E9=99=90=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/auth/user-routes.ts | 7 + app/api/cross-checking/cross-file-result.ts | 75 +-- app/api/cross-checking/cross-files-upload.ts | 70 +-- app/api/cross-checking/cross-files.ts | 79 ++- app/api/document-types/document-types.ts | 217 +++++-- app/api/evaluation_points/rules.ts | 11 +- app/api/files/documents.ts | 36 -- app/api/files/files-upload.ts | 105 ++-- app/api/login/auth.server.ts | 45 +- app/api/login/login-client.ts | 51 +- app/api/login/oauth-client.ts | 129 ++-- app/api/role-permissions/role-permissions.ts | 560 ++++++++++++++++++ app/api/user/user-management.ts | 15 +- app/components/auth/ClientAuthGuard.tsx | 12 +- .../cross-checking/ReviewPointsList.tsx | 13 +- app/components/layout/Sidebar.tsx | 5 +- app/components/reviews/ReviewTabs.tsx | 14 +- app/routes/callback.tsx | 3 +- app/routes/document-types._index.tsx | 142 ++--- app/routes/document-types.new.tsx | 74 ++- app/routes/documents.download.tsx | 51 -- app/routes/role-permissions._index.tsx | 513 ++++++++++++++++ app/services/api.client.ts | 280 +++++---- app/styles/pages/document-types_new.css | 2 +- app/styles/pages/role-permissions.css | 552 +++++++++++++++++ 25 files changed, 2299 insertions(+), 762 deletions(-) create mode 100644 app/api/role-permissions/role-permissions.ts delete mode 100644 app/routes/documents.download.tsx create mode 100644 app/routes/role-permissions._index.tsx create mode 100644 app/styles/pages/role-permissions.css diff --git a/app/api/auth/user-routes.ts b/app/api/auth/user-routes.ts index 400fcfc..b564fe8 100644 --- a/app/api/auth/user-routes.ts +++ b/app/api/auth/user-routes.ts @@ -607,6 +607,13 @@ function convertIcon(elementIcon: string | null): string { if (!elementIcon) { return 'ri-file-line'; // 默认图标 } + + // 如果已经是 RemixIcon 格式(以 ri- 开头),直接返回 + if (elementIcon.startsWith('ri-')) { + return elementIcon; + } + + // 否则尝试从 Element UI 映射表中查找 return ICON_MAPPING[elementIcon] || 'ri-file-line'; } diff --git a/app/api/cross-checking/cross-file-result.ts b/app/api/cross-checking/cross-file-result.ts index bc8a7e6..8bc781a 100644 --- a/app/api/cross-checking/cross-file-result.ts +++ b/app/api/cross-checking/cross-file-result.ts @@ -1,4 +1,5 @@ import { postgrestGet, postgrestPut } from "../postgrest-client"; +import axios from 'axios'; /** * 从不同格式的 API 响应中提取数据 @@ -134,26 +135,18 @@ export async function submitCrossCheckingOpinion( evaluation_result_id: opinionData.reviewPointResultId }; - const response = await fetch(`${API_BASE_URL}/admin/cross_review/proposals`, { - method: 'POST', + const response = await axios.post(`${API_BASE_URL}/admin/cross_review/proposals`, requestData, { headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` - }, - body: JSON.stringify(requestData) + } }); - const data = await response.json(); - - if (!response.ok) { - throw new Error(data.message || '提交失败'); - } - return { data: { success: true, message: '意见提交成功', - data: data + data: response.data } }; } catch (error) { @@ -190,23 +183,19 @@ export async function getCrossCheckingOpinions( // 如果没传userId,默认用1 const realUserId = userId ?? 1; // 实际后端API调用,拼接API_BASE_URL - const response = await fetch(`${API_BASE_URL}/admin/cross_review/proposals/document`, { - method: 'POST', + const response = await axios.post(`${API_BASE_URL}/admin/cross_review/proposals/document`, { + user_id: realUserId, + document_id: documentId, // 如果后端需要document_id可以加上 + page, + page_size: pageSize + }, { headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` - }, - body: JSON.stringify({ - user_id: realUserId, - document_id: documentId, // 如果后端需要document_id可以加上 - page, - page_size: pageSize - }) + } }); - if (!response.ok) { - throw new Error('获取意见列表失败'); - } - const data = await response.json(); + + const data = response.data; console.log('最原始的返回data', data); // 处理新的数据结构,支持分页 const responseData = data.data || data; @@ -328,23 +317,24 @@ export async function performOpinionAction( throw new Error('无效的操作类型'); } - const response = await fetch(endpoint, { - method: actionData.action === 'withdraw_opinion' ? 'DELETE' : 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}` - }, - body: JSON.stringify(requestBody) - }); + const response = actionData.action === 'withdraw_opinion' + ? await axios.delete(endpoint, { + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + } + }) + : await axios.post(endpoint, requestBody, { + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + } + }); - const data = await response.json(); + const data = response.data; console.log('返回的意见列表数据',data); - if (!response.ok) { - throw new Error(data.message || data.error || '操作失败'); - } - return { data: { success: true, @@ -417,20 +407,15 @@ export async function checkProposalVotes( document_id: documentId }; - const response = await fetch(`${API_BASE_URL}/admin/cross_review/proposals/document/check_pending_votes`, { - method: 'POST', + const response = await axios.post(`${API_BASE_URL}/admin/cross_review/proposals/document/check_pending_votes`, requestData, { headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` - }, - body: JSON.stringify(requestData) + } }); - const data = await response.json(); + const data = response.data; - if (!response.ok) { - throw new Error(data.message || '检查失败'); - } console.log("检查投票数据",data); return { diff --git a/app/api/cross-checking/cross-files-upload.ts b/app/api/cross-checking/cross-files-upload.ts index c2c7dcf..fd7e583 100644 --- a/app/api/cross-checking/cross-files-upload.ts +++ b/app/api/cross-checking/cross-files-upload.ts @@ -1,4 +1,5 @@ import { UPLOAD_URL } from '../../config/api-config'; +import axios from 'axios'; /** * 从不同格式的 API 响应中提取数据 @@ -146,8 +147,8 @@ export async function uploadCrossCheckingDocument( // 发送请求 try { - console.log('【交叉评查上传】开始fetch请求...'); - const headers: HeadersInit = { + console.log('【交叉评查上传】开始axios请求...'); + const headers: Record = { 'X-File-Name': encodeURIComponent(fileName), }; @@ -155,50 +156,35 @@ export async function uploadCrossCheckingDocument( headers['Authorization'] = `Bearer ${token}`; } - const response = await fetch(uploadUrl, { - method: 'POST', - headers, - body: formData + const response = await axios.post(uploadUrl, formData, { + headers }); - + console.log('【交叉评查上传】收到服务器响应:', { status: response.status, statusText: response.statusText }); - - if (!response.ok) { - const errorText = await response.text(); - console.error(`【交叉评查上传】上传失败 (${response.status}): ${errorText}`); - return { - error: `上传失败: ${response.status} ${response.statusText} - ${errorText}`, - status: response.status - }; - } - - console.log('【交叉评查上传】开始解析JSON响应'); - let responseData; - try { - responseData = await response.json(); - console.log('【交叉评查上传】JSON响应解析成功:', responseData); - } catch (jsonError) { - console.error('【交叉评查上传】JSON解析失败:', jsonError); - return { - error: `解析响应JSON失败: ${jsonError instanceof Error ? jsonError.message : '未知错误'}`, - status: 500 - }; - } - - const extractedData = extractApiData(responseData); + + console.log('【交叉评查上传】JSON响应解析成功:', response.data); + + const extractedData = extractApiData(response.data); console.log('【交叉评查上传】提取的数据:', extractedData); - + if (!extractedData) { console.error('【交叉评查上传】无法提取数据'); return { error: '处理上传响应失败', status: 500 }; } - + console.log('【交叉评查上传】上传成功,返回数据'); return { data: extractedData as CrossCheckingFileUploadResponse }; - } catch (fetchError) { - console.error('【交叉评查上传】fetch请求失败:', fetchError); - return { - error: `fetch请求错误: ${fetchError instanceof Error ? fetchError.message : '未知错误'}`, + } catch (axiosError) { + console.error('【交叉评查上传】axios请求失败:', axiosError); + if (axios.isAxiosError(axiosError)) { + const errorText = axiosError.response?.data || axiosError.message; + return { + error: `上传失败: ${axiosError.response?.status || 500} ${axiosError.response?.statusText || ''} - ${errorText}`, + status: axiosError.response?.status || 500 + }; + } + return { + error: `axios请求错误: ${axiosError instanceof Error ? axiosError.message : '未知错误'}`, status: 500 }; } @@ -258,14 +244,12 @@ export async function batchUploadAndAssignCrossCheckingFiles( }; formData.append('upload_info', JSON.stringify(uploadInfo)); formData.append('assign_user_ids', JSON.stringify(assignUserIds)); - const headers: HeadersInit = {}; + const headers: Record = {}; if (token) headers['Authorization'] = `Bearer ${token}`; - const response = await fetch(uploadUrl, { - method: 'POST', - headers, - body: formData + const response = await axios.post(uploadUrl, formData, { + headers }); - const result = await response.json(); + const result = response.data; if (result && result.success) { successes.push({ file: fileInfo, result }); } else { diff --git a/app/api/cross-checking/cross-files.ts b/app/api/cross-checking/cross-files.ts index 3b5a9ce..e483dcc 100644 --- a/app/api/cross-checking/cross-files.ts +++ b/app/api/cross-checking/cross-files.ts @@ -1,5 +1,6 @@ import { API_BASE_URL } from '../../config/api-config'; import { postgrestPut } from '../postgrest-client'; +import axios from 'axios'; // 交叉评查任务状态枚举 export enum CrossCheckingTaskStatus { @@ -393,33 +394,28 @@ export async function getUserTaskDocuments(page: number = 1, pageSize: number = // 拼接绝对路径,去除多余斜杠 const base = API_BASE_URL.endsWith('/') ? API_BASE_URL.slice(0, -1) : API_BASE_URL; const url = `${base}/admin/cross_review/tasks/user_tasks`; - - const response = await fetch(url, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${jwtToken || ''}` - }, - body: JSON.stringify({ - page: page, - page_size: pageSize - }) + + const response = await axios.post(url, { + page: page, + page_size: pageSize + }, { + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${jwtToken || ''}` + } }); - - if (!response.ok) { - return { - success: false, - error: `HTTP ${response.status}: ${response.statusText}` - }; - } - - const result = await response.json(); - + return { success: true, - data: result + data: response.data }; } catch (error) { + if (axios.isAxiosError(error)) { + return { + success: false, + error: `HTTP ${error.response?.status}: ${error.response?.statusText || error.message}` + }; + } return { success: false, error: error instanceof Error ? error.message : '获取用户任务列表失败' @@ -441,33 +437,28 @@ export async function getTaskDocuments(taskId: number, page: number = 1, pageSiz const base = API_BASE_URL.endsWith('/') ? API_BASE_URL.slice(0, -1) : API_BASE_URL; const url = `${base}/admin/cross_review/tasks/${taskId}/documents`; // console.log('最终请求URL:', url); - - const response = await fetch(url, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${jwtToken || ''}` - }, - body: JSON.stringify({ - page: page, - page_size: pageSize - }) + + const response = await axios.post(url, { + page: page, + page_size: pageSize + }, { + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${jwtToken || ''}` + } }); - - if (!response.ok) { - return { - success: false, - error: `HTTP ${response.status}: ${response.statusText}` - }; - } - - const result = await response.json(); - + return { success: true, - data: result + data: response.data }; } catch (error) { + if (axios.isAxiosError(error)) { + return { + success: false, + error: `HTTP ${error.response?.status}: ${error.response?.statusText || error.message}` + }; + } return { success: false, error: error instanceof Error ? error.message : '获取任务文档列表失败' diff --git a/app/api/document-types/document-types.ts b/app/api/document-types/document-types.ts index 067ea6a..5f0ab50 100644 --- a/app/api/document-types/document-types.ts +++ b/app/api/document-types/document-types.ts @@ -25,6 +25,10 @@ export interface DocumentTypeUI { name: string; description: string; groups: DocumentTypeGroup[]; + entry_module?: { + id: number; + name: string; + } | null; llm_extraction_template_id?: number | null; vlm_extraction_template_id?: number | null; evaluation_template_id?: number | null; @@ -39,6 +43,7 @@ export interface DocumentTypeCreateDTO { name: string; description?: string; group_ids: string[]; + entry_module_id?: number | null; llm_extraction_template_id?: number | null; vlm_extraction_template_id?: number | null; evaluation_template_id?: number | null; @@ -108,27 +113,27 @@ export async function getAllEvaluationPointGroups(token?: string): Promise<{ id: number; name: string; }>>('evaluation_point_groups', params); - + if (response.error) { return { error: response.error, status: response.status }; } - + // 使用extractApiData提取数据 const extractedData = extractApiData>(response.data); - + if (!extractedData) { return { data: [] }; } - + // 转换为DocumentTypeGroup格式 const groups: DocumentTypeGroup[] = extractedData.map(item => ({ id: item.id.toString(), name: item.name })); - + return { data: groups }; } catch (error) { console.error('获取所有评查点分组失败:', error); @@ -136,6 +141,101 @@ export async function getAllEvaluationPointGroups(token?: string): Promise<{ } } +/** + * 获取父级评查分组(pid=0的分组) + * @param token JWT token (可选) + * @returns 父级评查点分组列表 + */ +export async function getParentEvaluationPointGroups(token?: string): Promise<{ + data?: DocumentTypeGroup[]; + error?: string; + status?: number; +}> { + try { + const params: PostgrestParams = { + select: 'id, name', + filter: { + 'pid': 'eq.0' + }, + order: 'id.asc', + token + }; + + const response = await postgrestGet>('evaluation_point_groups', params); + + if (response.error) { + return { error: response.error, status: response.status }; + } + + // 使用extractApiData提取数据 + const extractedData = extractApiData>(response.data); + + if (!extractedData) { + return { data: [] }; + } + + // 转换为DocumentTypeGroup格式 + const groups: DocumentTypeGroup[] = extractedData.map(item => ({ + id: item.id.toString(), + name: item.name + })); + + return { data: groups }; + } catch (error) { + console.error('获取父级评查点分组失败:', error); + return { error: error instanceof Error ? error.message : '获取父级评查点分组失败' }; + } +} + +/** + * 获取所有入口模块 + * @param token JWT token (可选) + * @returns 入口模块列表 + */ +export async function getEntryModules(token?: string): Promise<{ + data?: Array<{ id: number; name: string }>; + error?: string; + status?: number; +}> { + try { + const params: PostgrestParams = { + select: 'id, name', + order: 'id.asc', + token + }; + + const response = await postgrestGet>('entry_modules', params); + + if (response.error) { + return { error: response.error, status: response.status }; + } + + // 使用extractApiData提取数据 + const extractedData = extractApiData>(response.data); + + if (!extractedData) { + return { data: [] }; + } + + return { data: extractedData }; + } catch (error) { + console.error('获取入口模块失败:', error); + return { error: error instanceof Error ? error.message : '获取入口模块失败' }; + } +} + /** * 根据ID获取评查点分组信息 * @param ids 评查点分组ID数组 @@ -216,19 +316,22 @@ export async function getDocumentTypes(searchParams: DocumentTypeSearchParams = try { const page = searchParams.page || 1; const pageSize = searchParams.pageSize || 10; - - // 构建查询参数 + + // 构建查询参数,使用 PostgREST 的资源嵌入语法来关联查询 + // 使用外键约束名称进行关联:entry_modules!fk_document_types_entry_module const params: PostgrestParams = { select: ` id, name, description, evaluation_point_groups_ids, + entry_module_id, + entry_modules!fk_document_types_entry_module(id, name), prompt_config, created_at, updated_at, code - `, + `.replace(/\s+/g,' ').trim(), order: 'updated_at.desc', headers: { 'Prefer': 'count=exact' @@ -238,13 +341,13 @@ export async function getDocumentTypes(searchParams: DocumentTypeSearchParams = filter: {} as Record, token: frontendJWT }; - + // 添加筛选条件 const filter: Record = {}; if (searchParams.name) { filter['name'] = `ilike.%${searchParams.name}%`; } - + // 如果有分组ID筛选条件 if (searchParams.ruleType) { filter['evaluation_point_groups_ids'] = `cs.[${searchParams.ruleType}]`; @@ -264,32 +367,70 @@ export async function getDocumentTypes(searchParams: DocumentTypeSearchParams = } params.filter = filter; - + // console.log('获取文档类型列表,参数:', params); - const response = await postgrestGet('document_types', params); - + const response = await postgrestGet<(DocumentType & { + entry_modules: { id: number; name: string } | null; + })[]>('document_types', params); + if (response.error) { return { error: response.error, status: response.status }; } - - // 使用extractApiData提取数据 - const extractedData = extractApiData(response.data); - const documentTypes = extractedData || []; - - // console.log('提取的文档类型数据:', documentTypes); - // 🔧 优化:移除评查点分组查询(文档列表UI不需要此数据) - // 直接转换为UI类型,不查询关联的分组信息 - const uiTypes = documentTypes.map(type => ({ - ...convertToUIDocumentType(type), - groups: [] // 保持接口兼容性,但不填充数据 - })); - + // console.log('提取的文档类型数据:', JSON.stringify(response)); + + // 使用extractApiData提取数据 + const extractedData = extractApiData<(DocumentType & { + entry_modules: { id: number; name: string } | null; + })[]>(response.data); + const documentTypes = extractedData || []; + + + // 并发查询所有需要的评查点分组信息 + const allGroupIds = new Set(); + documentTypes.forEach(type => { + if (type.evaluation_point_groups_ids) { + const ids = Array.isArray(type.evaluation_point_groups_ids) + ? type.evaluation_point_groups_ids + : [type.evaluation_point_groups_ids as unknown as number]; + ids.forEach(id => allGroupIds.add(id)); + } + }); + + // 如果有分组ID,查询所有分组信息 + let groupsMap: Map = new Map(); + if (allGroupIds.size > 0) { + const groupsResponse = await getEvaluationPointGroupsByIds(Array.from(allGroupIds), frontendJWT); + if (groupsResponse.data) { + groupsResponse.data.forEach(group => { + groupsMap.set(parseInt(group.id, 10), group); + }); + } + } + + // 转换为UI类型,包含entry_module和groups信息 + const uiTypes = documentTypes.map(type => { + // 获取该文档类型关联的分组 + let typeGroups: DocumentTypeGroup[] = []; + if (type.evaluation_point_groups_ids) { + const ids = Array.isArray(type.evaluation_point_groups_ids) + ? type.evaluation_point_groups_ids + : [type.evaluation_point_groups_ids as unknown as number]; + typeGroups = ids.map(id => groupsMap.get(id)).filter(Boolean) as DocumentTypeGroup[]; + } + + return { + ...convertToUIDocumentType({ ...type, groups: typeGroups }), + entry_module: type.entry_modules || null, + groups: typeGroups + }; + }); + // 获取总数 let totalCount = 0; - const responseWithHeaders = response as { - data: unknown; - headers: Record + const responseWithHeaders = response as { + data: unknown; + headers: Record }; if (responseWithHeaders.headers) { const rangeHeader = responseWithHeaders.headers['content-range']; @@ -300,7 +441,7 @@ export async function getDocumentTypes(searchParams: DocumentTypeSearchParams = } } } - + return { data: { types: uiTypes, @@ -360,7 +501,10 @@ export async function deleteDocumentType(id: string, frontendJWT?: string): Prom /** * 将API返回的文档类型转换为UI文档类型 */ -function convertToUIDocumentType(type: DocumentType & { groups: DocumentTypeGroup[] }): DocumentTypeUI { +function convertToUIDocumentType(type: DocumentType & { + groups: DocumentTypeGroup[]; + entry_modules?: { id: number; name: string } | null; +}): DocumentTypeUI { // 提取提示词模板ID,确保安全处理以避免控制台警告 let llmExtractionTemplateId: number | null = null; let vlmExtractionTemplateId: number | null = null; @@ -396,6 +540,7 @@ function convertToUIDocumentType(type: DocumentType & { groups: DocumentTypeGrou name: type.name, description: type.description || '', groups: type.groups || [], + entry_module: type.entry_modules || null, llm_extraction_template_id: llmExtractionTemplateId, vlm_extraction_template_id: vlmExtractionTemplateId, evaluation_template_id: evaluationTemplateId, @@ -428,18 +573,22 @@ export async function getDocumentType(id: string, frontendJWT?: string): Promise name, description, evaluation_point_groups_ids, + entry_module_id, + entry_modules!fk_document_types_entry_module(id, name), prompt_config, created_at, updated_at, code - `, + `.replace(/\s+/g,' ').trim(), filter: { 'id': `eq.${id}` }, token: frontendJWT }; - const response = await postgrestGet('document_types', params); + const response = await postgrestGet<(DocumentType & { + entry_modules: { id: number; name: string } | null; + })[]>('document_types', params); if (response.error) { return { error: response.error, status: response.status }; @@ -573,6 +722,7 @@ export async function createDocumentType(documentType: DocumentTypeCreateDTO, fr name: documentType.name.trim(), description: documentType.description || '', evaluation_point_groups_ids: groupIds, + entry_module_id: documentType.entry_module_id || null, prompt_config: promptConfig, // code: documentType.code || null }; @@ -698,6 +848,7 @@ export async function updateDocumentType(id: string, documentType: DocumentTypeU name: documentType.name.trim(), description: documentType.description || '', evaluation_point_groups_ids: groupIds, + entry_module_id: documentType.entry_module_id || null, prompt_config: promptConfig }; diff --git a/app/api/evaluation_points/rules.ts b/app/api/evaluation_points/rules.ts index 2a3aaf2..0b24b8d 100644 --- a/app/api/evaluation_points/rules.ts +++ b/app/api/evaluation_points/rules.ts @@ -802,19 +802,10 @@ export interface RuleGroup { */ export async function getRuleTypes(documentTypeIds?: number[], token?: string): Promise<{data: RuleType[]; error?: never} | {data?: never; error: string; status?: number}> { try { - // 如果没有传入 documentTypeIds,返回空数组 - if (!documentTypeIds || documentTypeIds.length === 0) { - console.warn('getRuleTypes: 未提供 documentTypeIds'); - return { data: [] }; - } - // 1️⃣ 根据 documentTypeIds 查询 document_types 表 - const typeIdsStr = documentTypeIds.join(','); const documentTypesParams: PostgrestParams = { select: 'id, name, evaluation_point_groups_ids', - filter: { - 'id': `in.(${typeIdsStr})` - }, + filter: {}, token }; diff --git a/app/api/files/documents.ts b/app/api/files/documents.ts index 24d76ff..29462f4 100644 --- a/app/api/files/documents.ts +++ b/app/api/files/documents.ts @@ -384,42 +384,6 @@ export async function getDocumentWithNoUserId(id: string, frontendJWT?: string): -/** - * 获取文件下载链接 - * @param filePath 文件路径 - * @returns 下载链接 - */ -export async function getFileDownloadUrl(filePath: string): Promise<{ - data?: { downloadUrl: string }; - error?: string; - status?: number; -}> { - try { - if (!filePath) { - return { error: '文件路径不能为空', status: 400 }; - } - - // 这里应该调用获取文件下载链接的API - // 假设后端有这样的端点:/api/files/generate-download-url?path=xxx - // 实际项目中需要根据你的后端API调整 - - // 临时解决方案:返回Remix路由路径 - // 这将通过Remix服务器代理对文件的访问 - return { - data: { - downloadUrl: `/documents/download?path=${encodeURIComponent(filePath)}` - } - }; - - } catch (error) { - console.error('获取文件下载链接失败:', error); - return { - error: error instanceof Error ? error.message : '获取文件下载链接失败', - status: 500 - }; - } -} - /** * 更新文档信息 * @param id 文档ID diff --git a/app/api/files/files-upload.ts b/app/api/files/files-upload.ts index 3dd658c..7919109 100644 --- a/app/api/files/files-upload.ts +++ b/app/api/files/files-upload.ts @@ -1,6 +1,7 @@ import { postgrestGet, type PostgrestParams } from '../postgrest-client'; import dayjs from 'dayjs'; import { UPLOAD_URL } from '../../config/api-config'; +import axios from 'axios'; // import { API_BASE_URL } from '../client'; /** @@ -213,26 +214,15 @@ export async function uploadContractTemplate( } // 发送请求 - const response = await fetch(uploadUrl, { - method: 'POST', - headers, - body: formData + const response = await axios.post(uploadUrl, formData, { + headers }); - + console.log('【合同模板上传】服务器响应状态:', response.status); - - if (!response.ok) { - const errorText = await response.text(); - console.error('【合同模板上传】服务器返回错误:', errorText); - return { - error: `服务器错误: ${response.status} ${response.statusText}`, - status: response.status - }; - } - - const result = await response.json(); + + const result = response.data; console.log('【合同模板上传】服务器返回结果:', result); - + if (result.success) { return { data: result.result }; } else { @@ -299,26 +289,15 @@ export async function appendContractAttachments( } // 发送请求 - const response = await fetch(uploadUrl, { - method: 'POST', - headers, - body: formData + const response = await axios.post(uploadUrl, formData, { + headers }); - + console.log('【合同附件追加】服务器响应状态:', response.status); - - if (!response.ok) { - const errorText = await response.text(); - console.error('【合同附件追加】服务器返回错误:', errorText); - return { - error: `服务器错误: ${response.status} ${response.statusText}`, - status: response.status - }; - } - - const result = await response.json(); + + const result = response.data; console.log('【合同附件追加】服务器返回结果:', result); - + if (result.success) { return { data: result.result }; } else { @@ -388,12 +367,12 @@ export async function uploadDocumentToServer( // console.log('【调试】准备发送请求到服务器:', uploadUrl); // 发送请求 - // const response = await fetch(`${API_BASE_URL}/admin/documents/upload`, { + // const response = await axios.post(`${API_BASE_URL}/admin/documents/upload`, ... try { - // console.log('【调试】开始fetch请求...'); + // console.log('【调试】开始axios请求...'); // 构建请求头,只在有JWT token时添加Authorization - const headers: HeadersInit = { + const headers: Record = { 'X-File-Name': encodeURIComponent(fileName) }; @@ -401,37 +380,16 @@ export async function uploadDocumentToServer( headers['Authorization'] = `Bearer ${jwtToken}`; } - const response = await fetch(uploadUrl, { - method: 'POST', - headers, - body: formData + const response = await axios.post(uploadUrl, formData, { + headers }); - + // console.log('【调试】收到服务器响应:', { status: response.status, statusText: response.statusText }); - - if (!response.ok) { - const errorText = await response.text(); - console.error(`【调试】上传失败 (${response.status}): ${errorText}`); - return { - error: `上传失败: ${response.status} ${response.statusText} - ${errorText}`, - status: response.status - }; - } - - // console.log('【调试】开始解析JSON响应'); - let responseData; - try { - responseData = await response.json(); - // console.log('【上传调试】服务器原始JSON响应:', responseData); - // console.log('【上传调试】响应类型:', typeof responseData); - // console.log('【上传调试】响应keys:', Object.keys(responseData)); - } catch (jsonError) { - console.error('【调试】JSON解析失败:', jsonError); - return { - error: `解析响应JSON失败: ${jsonError instanceof Error ? jsonError.message : '未知错误'}`, - status: 500 - }; - } + + const responseData = response.data; + // console.log('【上传调试】服务器原始JSON响应:', responseData); + // console.log('【上传调试】响应类型:', typeof responseData); + // console.log('【上传调试】响应keys:', Object.keys(responseData)); const extractedData = extractApiData(responseData); // console.log('【上传调试】提取后的数据:', extractedData); @@ -449,10 +407,17 @@ export async function uploadDocumentToServer( // console.log('【调试】上传成功,返回数据'); return { data: extractedData }; - } catch (fetchError) { - console.error('【调试】fetch请求失败:', fetchError); - return { - error: `fetch请求错误: ${fetchError instanceof Error ? fetchError.message : '未知错误'}`, + } catch (axiosError) { + console.error('【调试】axios请求失败:', axiosError); + if (axios.isAxiosError(axiosError)) { + const errorText = axiosError.response?.data || axiosError.message; + return { + error: `上传失败: ${axiosError.response?.status || 500} ${axiosError.response?.statusText || ''} - ${errorText}`, + status: axiosError.response?.status || 500 + }; + } + return { + error: `axios请求错误: ${axiosError instanceof Error ? axiosError.message : '未知错误'}`, status: 500 }; } diff --git a/app/api/login/auth.server.ts b/app/api/login/auth.server.ts index 20c2c81..3500b4c 100644 --- a/app/api/login/auth.server.ts +++ b/app/api/login/auth.server.ts @@ -21,6 +21,7 @@ import { createCookieSessionStorage } from "@remix-run/node"; import { postgrestGet, postgrestPost, postgrestPut } from "../postgrest-client"; import { JWTUtils, type UserInfoForJWT } from "~/utils/jwt"; import { OAUTH_CONFIG, API_BASE_URL } from "~/config/api-config"; +import axios from 'axios'; /** * 用户角色类型定义 @@ -95,7 +96,7 @@ export const sessionStorage = createCookieSessionStorage({ path: "/", // Cookie 作用域为整个应用 sameSite: "lax", // CSRF 保护,允许顶级导航 secrets: ["s3cr3t"], // TODO: 应该从环境变量读取 - maxAge: 60 * 60 * 2, // 2小时,与 OAuth Token 同步 + maxAge: 60 * 60 * 8, // 8小时,确保大于等于JWT token最大有效期(通常为6小时) secure: false, // 开发环境中禁用 HTTPS 要求 }, }); @@ -369,12 +370,16 @@ export async function createUserSession(params: { if (params.frontendJWT) { session.set("frontendJWT", params.frontendJWT); } - - const cookie = await sessionStorage.commitSession(session); - // console.log("创建完整会话 - 设置Cookie:", !!cookie); - // console.log("创建完整会话 - 用户角色:", params.userRole); - // console.log("创建完整会话 - 重定向到:", params.redirectTo); - + + // 🔑 根据 tokenExpiresIn 动态设置 Cookie 的 maxAge + // 如果有 tokenExpiresIn,使用它作为 Cookie 有效期;否则使用默认值(8小时) + const cookieMaxAge = params.tokenExpiresIn || (60 * 60 * 8); // 默认8小时 + // console.log("🍪 [createUserSession] Cookie maxAge:", cookieMaxAge, "秒 (", (cookieMaxAge / 3600).toFixed(2), "小时)"); + + const cookie = await sessionStorage.commitSession(session, { + maxAge: cookieMaxAge // 🔑 动态设置 Cookie 有效期 + }); + return new Response(null, { status: 302, // HTTP 重定向状态码 headers: { @@ -487,20 +492,18 @@ async function callIDaaSLogout(accessToken: string, appId: string): Promise ({})); - console.error("❌ [Login Client] OAuth 登录请求失败:", response.status, errorData); + console.log("✅ [Login Client] OAuth 登录成功"); + return response.data; + } catch (error) { + if (axios.isAxiosError(error)) { + const errorData = error.response?.data || {}; + console.error("❌ [Login Client] OAuth 登录请求失败:", error.response?.status, errorData); return { success: false, - error: errorData.error || errorData.message || `登录失败: ${response.status}` + error: errorData.error || errorData.message || `登录失败: ${error.response?.status || 'Unknown'}` }; } - const data = await response.json(); - console.log("✅ [Login Client] OAuth 登录成功"); - - return data; - } catch (error) { console.error("❌ [Login Client] OAuth 登录请求异常:", error); return { success: false, @@ -120,33 +117,29 @@ export async function loginWithPassword( console.log("📝 [Login Client] 调用后端密码登录接口:", loginUrl); try { - const response = await fetch(loginUrl, { - method: "POST", + const response = await axios.post(loginUrl, { + username, + password + }, { 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); + console.log("✅ [Login Client] 密码登录成功"); + return response.data; + } catch (error) { + if (axios.isAxiosError(error)) { + const errorData = error.response?.data || {}; + console.error("❌ [Login Client] 密码登录请求失败:", error.response?.status, errorData); return { success: false, - error: errorData.error || errorData.message || `登录失败: ${response.status}` + error: errorData.error || errorData.message || `登录失败: ${error.response?.status || 'Unknown'}` }; } - const data = await response.json(); - console.log("✅ [Login Client] 密码登录成功"); - - return data; - } catch (error) { console.error("❌ [Login Client] 密码登录请求异常:", error); return { success: false, diff --git a/app/api/login/oauth-client.ts b/app/api/login/oauth-client.ts index 21d3018..68d8fa4 100644 --- a/app/api/login/oauth-client.ts +++ b/app/api/login/oauth-client.ts @@ -6,6 +6,8 @@ * 2. 如果需要新的网络请求,在 `OAuthClient` 中添加 */ +import axios from 'axios'; + interface OAuthConfig { serverUrl: string; clientId: string; @@ -114,46 +116,38 @@ export class OAuthClient { }); try { - // 创建 AbortController 用于超时控制 - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 15000); // 15秒超时 - - const response = await fetch(url, { - method: 'POST', + const response = await axios.post(url, data, { headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: data, - signal: controller.signal + timeout: 60000 // 60秒超时 }); - clearTimeout(timeoutId); console.log('🔧 Token响应状态:', response.status, response.statusText); - if (!response.ok) { - const errorData = await response.json(); - console.error('❌ 获取访问令牌失败:', { - status: response.status, - statusText: response.statusText, - errorData: errorData - }); - return null; - } - - const tokenResponse = await response.json() as TokenResponse; + const tokenResponse = response.data as TokenResponse; console.log('✅ 获取访问令牌成功:', { token_type: tokenResponse.token_type, expires_in: tokenResponse.expires_in, scope: tokenResponse.scope }); - + return tokenResponse; } catch (error) { - // 判断是否为超时错误 - if (error instanceof Error && error.name === 'AbortError') { - console.error('❌ 获取访问令牌超时(15秒):', error.message); + if (axios.isAxiosError(error)) { + if (error.code === 'ECONNABORTED') { + console.error('❌ 获取访问令牌超时(60秒):', error.message); + } else if (error.response) { + console.error('❌ 获取访问令牌失败:', { + status: error.response.status, + statusText: error.response.statusText, + errorData: error.response.data + }); + } else { + console.error('❌ 获取访问令牌网络错误:', error.message); + } } else { - console.error('❌ 获取访问令牌网络错误:', error); + console.error('❌ 获取访问令牌错误:', error); } return null; } @@ -168,31 +162,25 @@ export class OAuthClient { const url = `${this.config.serverUrl}/api/bff/v1.2/oauth2/userinfo`; try { - // 创建 AbortController 用于超时控制 - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 15000); // 15秒超时 - - const response = await fetch(url, { + const response = await axios.get(url, { headers: { 'Authorization': `Bearer ${accessToken}` }, - signal: controller.signal + timeout: 60000 // 60秒超时 }); - clearTimeout(timeoutId); - - if (!response.ok) { - console.error('获取用户信息失败:', response.status, response.statusText); - return null; - } - - return await response.json() as UserInfoResponse; + return response.data as UserInfoResponse; } catch (error) { - // 判断是否为超时错误 - if (error instanceof Error && error.name === 'AbortError') { - console.error('❌ 获取用户信息超时(15秒):', error.message); + if (axios.isAxiosError(error)) { + if (error.code === 'ECONNABORTED') { + console.error('❌ 获取用户信息超时(60秒):', error.message); + } else if (error.response) { + console.error('获取用户信息失败:', error.response.status, error.response.statusText); + } else { + console.error('❌ 获取用户信息网络错误:', error.message); + } } else { - console.error('❌ 获取用户信息网络错误:', error); + console.error('❌ 获取用户信息错误:', error); } return null; } @@ -219,34 +207,25 @@ export class OAuthClient { }); try { - // 创建 AbortController 用于超时控制 - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 15000); // 15秒超时 - - const response = await fetch(url, { - method: 'POST', + const response = await axios.post(url, data, { headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: data, - signal: controller.signal + timeout: 60000 // 60秒超时 }); - clearTimeout(timeoutId); - - if (!response.ok) { - const errorData = await response.json(); - console.error('刷新访问令牌失败:', errorData); - return null; - } - - return await response.json() as TokenResponse; + return response.data as TokenResponse; } catch (error) { - // 判断是否为超时错误 - if (error instanceof Error && error.name === 'AbortError') { - console.error('❌ 刷新访问令牌超时(15秒):', error.message); + if (axios.isAxiosError(error)) { + if (error.code === 'ECONNABORTED') { + console.error('❌ 刷新访问令牌超时(60秒):', error.message); + } else if (error.response) { + console.error('刷新访问令牌失败:', error.response.data); + } else { + console.error('❌ 刷新访问令牌网络错误:', error.message); + } } else { - console.error('❌ 刷新访问令牌网络错误:', error); + console.error('❌ 刷新访问令牌错误:', error); } return null; } @@ -266,25 +245,21 @@ export class OAuthClient { }); try { - // 创建 AbortController 用于超时控制 - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 15000); // 15秒超时 - - const response = await fetch(url, { - method: 'POST', + const response = await axios.post(url, data, { headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: data, - signal: controller.signal + timeout: 60000 // 60秒超时 }); - clearTimeout(timeoutId); - return response.ok; + return response.status >= 200 && response.status < 300; } catch (error) { - // 判断是否为超时错误 - if (error instanceof Error && error.name === 'AbortError') { - console.error('❌ 登出超时(15秒):', error.message); + if (axios.isAxiosError(error)) { + if (error.code === 'ECONNABORTED') { + console.error('❌ 登出超时(60秒):', error.message); + } else { + console.error('❌ 登出失败:', error); + } } else { console.error('❌ 登出失败:', error); } diff --git a/app/api/role-permissions/role-permissions.ts b/app/api/role-permissions/role-permissions.ts new file mode 100644 index 0000000..abc90ca --- /dev/null +++ b/app/api/role-permissions/role-permissions.ts @@ -0,0 +1,560 @@ +/** + * 角色权限管理 API + * 用于角色、路由权限、用户角色的管理 + */ + +// ==================== 类型定义 ==================== + +/** + * 路由信息 + */ +export interface RouteInfo { + id: number; + route_path: string; + route_name: string; + route_title: string; + component?: string; + parent_id?: number | null; + icon?: string; + sort_order: number; + is_hidden: boolean; + is_cache: boolean; + status: number; + children?: RouteInfo[]; +} + +/** + * 角色信息 + */ +export interface RoleInfo { + id: number; + role_key: string; + role_name: string; + data_scope: string; + description: string; + parent_role_id?: number | null; + priority: number; + is_system_role: boolean; + created_at: string; + updated_at: string; +} + +/** + * 角色-路由权限关联 + */ +export interface RoleRoutePermission { + id: number; + role_id: number; + route_id: number; + permission: string; // 'R' | 'RW' | 'NONE' + created_at: string; +} + +/** + * 用户信息 + */ +export interface UserInfo { + id: number; + username: string; + nick_name: string; + phone_number?: string; + email?: string; + ou_name: string; + status: number; + is_leader: boolean; +} + +/** + * 用户-角色关联 + */ +export interface UserRoleRelation { + id: number; + user_id: number; + role_id: number; + created_at: string; +} + +// ==================== 模拟数据 ==================== + +/** + * 模拟路由数据(树形结构) + */ +const mockRoutes: RouteInfo[] = [ + { + id: 1, + route_path: '/documents', + route_name: 'documents', + route_title: '文档管理', + icon: 'ri-file-text-line', + sort_order: 1, + is_hidden: false, + is_cache: true, + status: 1, + parent_id: null, + children: [ + { + id: 11, + route_path: '/documents/list', + route_name: 'documents-list', + route_title: '文档列表', + icon: 'ri-list-check', + sort_order: 1, + is_hidden: false, + is_cache: true, + status: 1, + parent_id: 1 + }, + { + id: 12, + route_path: '/documents/upload', + route_name: 'documents-upload', + route_title: '文档上传', + icon: 'ri-upload-line', + sort_order: 2, + is_hidden: false, + is_cache: true, + status: 1, + parent_id: 1 + } + ] + }, + { + id: 2, + route_path: '/cross-checking', + route_name: 'cross-checking', + route_title: '交叉评查', + icon: 'ri-exchange-line', + sort_order: 2, + is_hidden: false, + is_cache: true, + status: 1, + parent_id: null, + children: [ + { + id: 21, + route_path: '/cross-checking/tasks', + route_name: 'cross-checking-tasks', + route_title: '评查任务', + icon: 'ri-task-line', + sort_order: 1, + is_hidden: false, + is_cache: true, + status: 1, + parent_id: 2 + } + ] + }, + { + id: 3, + route_path: '/settings', + route_name: 'settings', + route_title: '系统设置', + icon: 'ri-settings-3-line', + sort_order: 3, + is_hidden: false, + is_cache: true, + status: 1, + parent_id: null, + children: [ + { + id: 31, + route_path: '/settings/document-types', + route_name: 'document-types', + route_title: '文档类型管理', + icon: 'ri-file-list-line', + sort_order: 1, + is_hidden: false, + is_cache: true, + status: 1, + parent_id: 3 + }, + { + id: 32, + route_path: '/settings/rule-groups', + route_name: 'rule-groups', + route_title: '评查点分组', + icon: 'ri-folder-line', + sort_order: 2, + is_hidden: false, + is_cache: true, + status: 1, + parent_id: 3 + }, + { + id: 33, + route_path: '/settings/prompts', + route_name: 'prompts', + route_title: '提示词管理', + icon: 'ri-message-line', + sort_order: 3, + is_hidden: false, + is_cache: true, + status: 1, + parent_id: 3 + } + ] + }, + { + id: 4, + route_path: '/role-permissions', + route_name: 'role-permissions', + route_title: '角色权限管理', + icon: 'ri-shield-user-line', + sort_order: 4, + is_hidden: false, + is_cache: true, + status: 1, + parent_id: null + } +]; + +/** + * 模拟角色数据 + */ +const mockRoles: RoleInfo[] = [ + { + id: 1, + role_key: 'admin', + role_name: '系统管理员', + data_scope: 'ALL', + description: '拥有系统所有权限', + priority: 1, + is_system_role: true, + created_at: '2024-01-01 10:00:00', + updated_at: '2024-01-01 10:00:00' + }, + { + id: 2, + role_key: 'provincial', + role_name: '省级管理员', + data_scope: 'PROVINCE', + description: '省级权限,可管理文档类型和评查点', + priority: 2, + is_system_role: false, + created_at: '2024-01-02 10:00:00', + updated_at: '2024-01-02 10:00:00' + }, + { + id: 3, + role_key: 'city_admin', + role_name: '市级管理员', + data_scope: 'CITY', + description: '市级权限,可管理本市文档', + priority: 3, + is_system_role: false, + created_at: '2024-01-03 10:00:00', + updated_at: '2024-01-03 10:00:00' + }, + { + id: 4, + role_key: 'common_user', + role_name: '普通用户', + data_scope: 'SELF', + description: '普通用户,只能查看自己的文档', + priority: 4, + is_system_role: false, + created_at: '2024-01-04 10:00:00', + updated_at: '2024-01-04 10:00:00' + }, + { + id: 5, + role_key: 'reviewer', + role_name: '评审员', + data_scope: 'DEPARTMENT', + description: '负责文档评审工作', + priority: 5, + is_system_role: false, + created_at: '2024-01-05 10:00:00', + updated_at: '2024-01-05 10:00:00' + } +]; + +/** + * 模拟角色-路由权限关联数据 + */ +const mockRoleRoutePermissions: RoleRoutePermission[] = [ + // 系统管理员拥有所有权限 + { id: 1, role_id: 1, route_id: 1, permission: 'RW', created_at: '2024-01-01 10:00:00' }, + { id: 2, role_id: 1, route_id: 11, permission: 'RW', created_at: '2024-01-01 10:00:00' }, + { id: 3, role_id: 1, route_id: 12, permission: 'RW', created_at: '2024-01-01 10:00:00' }, + { id: 4, role_id: 1, route_id: 2, permission: 'RW', created_at: '2024-01-01 10:00:00' }, + { id: 5, role_id: 1, route_id: 21, permission: 'RW', created_at: '2024-01-01 10:00:00' }, + { id: 6, role_id: 1, route_id: 3, permission: 'RW', created_at: '2024-01-01 10:00:00' }, + { id: 7, role_id: 1, route_id: 31, permission: 'RW', created_at: '2024-01-01 10:00:00' }, + { id: 8, role_id: 1, route_id: 32, permission: 'RW', created_at: '2024-01-01 10:00:00' }, + { id: 9, role_id: 1, route_id: 33, permission: 'RW', created_at: '2024-01-01 10:00:00' }, + { id: 10, role_id: 1, route_id: 4, permission: 'RW', created_at: '2024-01-01 10:00:00' }, + + // 省级管理员 + { id: 11, role_id: 2, route_id: 1, permission: 'RW', created_at: '2024-01-02 10:00:00' }, + { id: 12, role_id: 2, route_id: 11, permission: 'RW', created_at: '2024-01-02 10:00:00' }, + { id: 13, role_id: 2, route_id: 12, permission: 'RW', created_at: '2024-01-02 10:00:00' }, + { id: 14, role_id: 2, route_id: 3, permission: 'RW', created_at: '2024-01-02 10:00:00' }, + { id: 15, role_id: 2, route_id: 31, permission: 'RW', created_at: '2024-01-02 10:00:00' }, + { id: 16, role_id: 2, route_id: 32, permission: 'RW', created_at: '2024-01-02 10:00:00' }, + + // 普通用户 + { id: 17, role_id: 4, route_id: 1, permission: 'R', created_at: '2024-01-04 10:00:00' }, + { id: 18, role_id: 4, route_id: 11, permission: 'R', created_at: '2024-01-04 10:00:00' }, +]; + +/** + * 模拟用户数据 + */ +const mockUsers: UserInfo[] = [ + { + id: 1, + username: 'admin', + nick_name: '系统管理员', + phone_number: '13800138000', + email: 'admin@example.com', + ou_name: '系统管理部', + status: 1, + is_leader: true + }, + { + id: 2, + username: 'zhangsan', + nick_name: '张三', + phone_number: '13800138001', + email: 'zhangsan@example.com', + ou_name: '广东省局', + status: 1, + is_leader: true + }, + { + id: 3, + username: 'lisi', + nick_name: '李四', + phone_number: '13800138002', + email: 'lisi@example.com', + ou_name: '梅州市局', + status: 1, + is_leader: false + }, + { + id: 4, + username: 'wangwu', + nick_name: '王五', + phone_number: '13800138003', + email: 'wangwu@example.com', + ou_name: '云浮市局', + status: 1, + is_leader: false + }, + { + id: 5, + username: 'zhaoliu', + nick_name: '赵六', + phone_number: '13800138004', + email: 'zhaoliu@example.com', + ou_name: '揭阳市局', + status: 1, + is_leader: false + } +]; + +/** + * 模拟用户-角色关联数据 + */ +const mockUserRoles: UserRoleRelation[] = [ + { id: 1, user_id: 1, role_id: 1, created_at: '2024-01-01 10:00:00' }, + { id: 2, user_id: 2, role_id: 2, created_at: '2024-01-02 10:00:00' }, + { id: 3, user_id: 3, role_id: 3, created_at: '2024-01-03 10:00:00' }, + { id: 4, user_id: 4, role_id: 4, created_at: '2024-01-04 10:00:00' }, + { id: 5, user_id: 5, role_id: 5, created_at: '2024-01-05 10:00:00' } +]; + +// ==================== API 函数 ==================== + +/** + * 获取所有角色列表 + */ +export async function getRoles(): Promise { + // 模拟网络延迟 + await new Promise(resolve => setTimeout(resolve, 300)); + return mockRoles; +} + +/** + * 获取所有路由(树形结构) + */ +export async function getRoutes(): Promise { + await new Promise(resolve => setTimeout(resolve, 300)); + return mockRoutes; +} + +/** + * 获取指定角色的路由权限 + * @param roleId 角色ID + */ +export async function getRoleRoutePermissions(roleId: number): Promise { + await new Promise(resolve => setTimeout(resolve, 200)); + return mockRoleRoutePermissions.filter(p => p.role_id === roleId); +} + +/** + * 更新角色的路由权限 + * @param roleId 角色ID + * @param routeIds 路由ID数组 + */ +export async function updateRoleRoutePermissions( + roleId: number, + routeIds: number[] +): Promise<{ success: boolean; message: string }> { + await new Promise(resolve => setTimeout(resolve, 500)); + + // 在实际应用中,这里会调用后端API + console.log('更新角色权限:', { roleId, routeIds }); + + // 模拟更新本地数据 + // 删除该角色的旧权限 + const oldPermissions = mockRoleRoutePermissions.filter(p => p.role_id === roleId); + oldPermissions.forEach(p => { + const index = mockRoleRoutePermissions.indexOf(p); + if (index > -1) { + mockRoleRoutePermissions.splice(index, 1); + } + }); + + // 添加新权限 + routeIds.forEach((routeId, index) => { + mockRoleRoutePermissions.push({ + id: Date.now() + index, + role_id: roleId, + route_id: routeId, + permission: 'RW', + created_at: new Date().toISOString() + }); + }); + + return { success: true, message: '角色权限更新成功' }; +} + +/** + * 获取指定角色的用户列表 + * @param roleId 角色ID + */ +export async function getRoleUsers(roleId: number): Promise { + await new Promise(resolve => setTimeout(resolve, 200)); + + // 查找具有该角色的用户ID + const userIds = mockUserRoles + .filter(ur => ur.role_id === roleId) + .map(ur => ur.user_id); + + // 返回用户详细信息 + return mockUsers.filter(u => userIds.includes(u.id)); +} + +/** + * 获取所有用户列表 + */ +export async function getAllUsers(): Promise { + await new Promise(resolve => setTimeout(resolve, 300)); + return mockUsers; +} + +/** + * 为用户分配角色 + * @param userId 用户ID + * @param roleIds 角色ID数组 + */ +export async function assignUserRoles( + userId: number, + roleIds: number[] +): Promise<{ success: boolean; message: string }> { + await new Promise(resolve => setTimeout(resolve, 500)); + + console.log('为用户分配角色:', { userId, roleIds }); + + // 模拟更新本地数据 + // 删除该用户的旧角色 + const oldRoles = mockUserRoles.filter(ur => ur.user_id === userId); + oldRoles.forEach(ur => { + const index = mockUserRoles.indexOf(ur); + if (index > -1) { + mockUserRoles.splice(index, 1); + } + }); + + // 添加新角色 + roleIds.forEach((roleId, index) => { + mockUserRoles.push({ + id: Date.now() + index, + user_id: userId, + role_id: roleId, + created_at: new Date().toISOString() + }); + }); + + return { success: true, message: '用户角色分配成功' }; +} + +/** + * 创建新角色 + * @param roleData 角色数据 + */ +export async function createRole( + roleData: Omit +): Promise<{ success: boolean; message: string; data?: RoleInfo }> { + await new Promise(resolve => setTimeout(resolve, 500)); + + const newRole: RoleInfo = { + ...roleData, + id: Math.max(...mockRoles.map(r => r.id)) + 1, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString() + }; + + mockRoles.push(newRole); + + return { success: true, message: '角色创建成功', data: newRole }; +} + +/** + * 更新角色信息 + * @param roleId 角色ID + * @param roleData 角色数据 + */ +export async function updateRole( + roleId: number, + roleData: Partial> +): Promise<{ success: boolean; message: string }> { + await new Promise(resolve => setTimeout(resolve, 500)); + + const roleIndex = mockRoles.findIndex(r => r.id === roleId); + if (roleIndex === -1) { + return { success: false, message: '角色不存在' }; + } + + mockRoles[roleIndex] = { + ...mockRoles[roleIndex], + ...roleData, + updated_at: new Date().toISOString() + }; + + return { success: true, message: '角色更新成功' }; +} + +/** + * 删除角色 + * @param roleId 角色ID + */ +export async function deleteRole(roleId: number): Promise<{ success: boolean; message: string }> { + await new Promise(resolve => setTimeout(resolve, 500)); + + const role = mockRoles.find(r => r.id === roleId); + if (!role) { + return { success: false, message: '角色不存在' }; + } + + if (role.is_system_role) { + return { success: false, message: '系统角色不能删除' }; + } + + const roleIndex = mockRoles.indexOf(role); + mockRoles.splice(roleIndex, 1); + + return { success: true, message: '角色删除成功' }; +} diff --git a/app/api/user/user-management.ts b/app/api/user/user-management.ts index 450f132..7fd1fd4 100644 --- a/app/api/user/user-management.ts +++ b/app/api/user/user-management.ts @@ -1,5 +1,6 @@ import { get } from '../axios-client'; import { API_BASE_URL } from '../../config/api-config'; +import axios from 'axios'; // 用户信息接口 export interface UserInfo { @@ -56,24 +57,16 @@ export async function getOrganizationTree(includeUsers: boolean = true, jwtToken let responseData: OrganizationResponse; if (jwtToken) { - // 如果提供了JWT Token,则使用fetch并携带Authorization头 + // 如果提供了JWT Token,则使用axios并携带Authorization头 const url = `${API_BASE_URL}/admin/users/organizations?include_users=${includeUsers}`; - const response = await fetch(url, { + const response = await axios.get(url, { headers: { 'Authorization': `Bearer ${jwtToken}`, 'Content-Type': 'application/json' } }); - if (!response.ok) { - const errorText = await response.text(); - console.error('获取组织架构失败 (fetch):', errorText); - return { - success: false, - error: `HTTP error! status: ${response.status}, ${errorText}` - }; - } - responseData = await response.json(); + responseData = response.data; } else { // 否则,使用原有的get方法 const response = await get( diff --git a/app/components/auth/ClientAuthGuard.tsx b/app/components/auth/ClientAuthGuard.tsx index cd5e499..b5f30cf 100644 --- a/app/components/auth/ClientAuthGuard.tsx +++ b/app/components/auth/ClientAuthGuard.tsx @@ -33,11 +33,11 @@ export function ClientAuthGuard({ isPublicPath }: ClientAuthGuardProps) { const token = localStorage.getItem('access_token'); const authenticated = isAuthenticated(); - console.log('🔍 [Auth Guard] 认证检查', { - token: token ? `${token.substring(0, 20)}...` : null, - authenticated, - pathname: location.pathname - }); + // console.log('🔍 [Auth Guard] 认证检查', { + // token: token ? `${token.substring(0, 20)}...` : null, + // authenticated, + // pathname: location.pathname + // }); if (!authenticated) { console.log('🔒 [Auth Guard] 未认证,重定向到登录页'); @@ -48,7 +48,7 @@ export function ClientAuthGuard({ isPublicPath }: ClientAuthGuardProps) { // 跳转到登录页,并传递重定向目标 navigate(`/login?redirect=${encodeURIComponent(redirectTo)}`, { replace: true }); } else { - console.log('✅ [Auth Guard] 已认证,允许访问'); + // console.log('✅ [Auth Guard] 已认证,允许访问'); } }, [isPublicPath, navigate, location.pathname]); diff --git a/app/components/cross-checking/ReviewPointsList.tsx b/app/components/cross-checking/ReviewPointsList.tsx index 35f5549..eaa9f73 100644 --- a/app/components/cross-checking/ReviewPointsList.tsx +++ b/app/components/cross-checking/ReviewPointsList.tsx @@ -32,6 +32,7 @@ import { } from '../../api/cross-checking/cross-file-result'; import { useFetcher, useNavigate } from '@remix-run/react'; import { API_BASE_URL } from '~/config/api-config'; +import axios from 'axios'; // import '../../styles/components/TooltipStyles.css'; /** @@ -768,18 +769,16 @@ export function ReviewPointsList({ } // 打印最终请求体 // console.log('最终请求体:', data); - // 用原生 fetch + application/json 提交 + // 用 axios + application/json 提交 try { - const response = await fetch(`${API_BASE_URL.replace(/\/$/, '')}/admin/cross_review/proposals`, { - method: 'POST', + const response = await axios.post(`${API_BASE_URL.replace(/\/$/, '')}/admin/cross_review/proposals`, data, { headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${userInfo.frontend_jwt}`, - }, - body: JSON.stringify(data) + } }); - const result = await response.json(); - if (response.ok) { + const result = response.data; + if (response.status === 200) { toastService.success('意见提交成功'); // 创建新的提案对象 diff --git a/app/components/layout/Sidebar.tsx b/app/components/layout/Sidebar.tsx index d7d87d4..d946488 100644 --- a/app/components/layout/Sidebar.tsx +++ b/app/components/layout/Sidebar.tsx @@ -37,7 +37,7 @@ export function Sidebar({ onToggle, collapsed, userRole, frontendJWT = '' }: Sid // 获取用户路由权限 useEffect(() => { - console.log('🔍 [Sidebar] useEffect 触发,开始获取路由权限'); + // console.log('🔍 [Sidebar] useEffect 触发,开始获取路由权限'); const fetchUserRoutes = async () => { setIsLoadingRoutes(true); @@ -69,7 +69,7 @@ export function Sidebar({ onToggle, collapsed, userRole, frontendJWT = '' }: Sid // 如果需要重定向到首页 if (result.shouldRedirectToHome) { - console.log('🔄 [Sidebar] 重定向到首页'); + // console.log('🔄 [Sidebar] 重定向到首页'); navigate('/'); return; } @@ -158,6 +158,7 @@ export function Sidebar({ onToggle, collapsed, userRole, frontendJWT = '' }: Sid // 处理菜单项:清理子菜单结构 const processedMenuItems: MenuItem[] = menuItems.filter(item =>{ + // console.log('菜单项:', item.title, 'Icon:', item.icon) // 如果是省局访问 if(isPort51707){ if (selectedModuleName === '智慧法务大模型'){ diff --git a/app/components/reviews/ReviewTabs.tsx b/app/components/reviews/ReviewTabs.tsx index 0b8553d..3d466da 100644 --- a/app/components/reviews/ReviewTabs.tsx +++ b/app/components/reviews/ReviewTabs.tsx @@ -11,6 +11,7 @@ import { Button } from '~/components/ui/Button'; import { toastService } from '~/components/ui/Toast'; // import { DOCUMENT_URL } from "~/api/axios-client"; import { uploadContractTemplate } from '~/api/files/files-upload'; +import axios from 'axios'; interface ReviewTabsProps { activeTab: string; @@ -65,14 +66,13 @@ export function ReviewTabs({ activeTab, onTabChange, children, fileInfo, onConfi // 使用 PDF 代理路由获取文件,自动添加 JWT 认证 const downloadUrl = `/api/pdf-proxy?path=${encodeURIComponent(fileInfo.path || '')}`; - // 使用fetch获取文件内容 - const response = await fetch(downloadUrl); - if (!response.ok) { - throw new Error(`下载失败: ${response.status} ${response.statusText}`); - } + // 使用axios获取文件内容 + const response = await axios.get(downloadUrl, { + responseType: 'blob' + }); - // 将响应转换为Blob - const blob = await response.blob(); + // axios已经返回Blob + const blob = response.data; // 创建Blob URL const blobUrl = URL.createObjectURL(blob); diff --git a/app/routes/callback.tsx b/app/routes/callback.tsx index d3c4a77..57052a7 100644 --- a/app/routes/callback.tsx +++ b/app/routes/callback.tsx @@ -179,6 +179,7 @@ export async function loader({ request }: LoaderFunctionArgs) { console.log("✅ [Callback] 后端登录成功,JWT token 已获取"); const frontendJWT = loginResponse.data.access_token; const savedUserInfo = loginResponse.data.user_info; + const backExpiresIn = loginResponse.data.expires_in || (60 * 60 * 8) // 🔑 提取后端返回的签发时间并转换为时间戳 let tokenIssuedAt = Date.now(); // 默认使用当前时间 @@ -235,7 +236,7 @@ export async function loader({ request }: LoaderFunctionArgs) { redirectTo: callbackUrl.toString(), // 先跳转到 callback 页面保存 token accessToken: tokenResponse.access_token, refreshToken: tokenResponse.refresh_token, - tokenExpiresIn: tokenResponse.expires_in, + tokenExpiresIn: backExpiresIn, tokenIssuedAt: tokenIssuedAt, // 🔑 传递后端返回的签发时间 userInfo: enhancedUserInfo, frontendJWT diff --git a/app/routes/document-types._index.tsx b/app/routes/document-types._index.tsx index 1fb862f..029720a 100644 --- a/app/routes/document-types._index.tsx +++ b/app/routes/document-types._index.tsx @@ -6,14 +6,14 @@ import { Card } from "~/components/ui/Card"; import { Button } from "~/components/ui/Button"; import { Pagination } from "~/components/ui/Pagination"; import { FilterPanel, FilterSelect, SearchFilter } from "~/components/ui/FilterPanel"; -import { getRuleTypes, getRuleGroupsByType, type RuleType, type RuleGroup } from "~/api/evaluation_points/rules"; import { toastService } from "~/components/ui/Toast"; -import { - getDocumentTypes, - deleteDocumentType, - type DocumentTypeUI, +import { + getDocumentTypes, + deleteDocumentType, + type DocumentTypeUI, type DocumentTypeSearchParams, - type DocumentTypeGroup + type DocumentTypeGroup, + getParentEvaluationPointGroups } from "~/api/document-types/document-types"; import documentTypesStyles from "~/styles/pages/document-types_index.css?url"; @@ -40,8 +40,7 @@ interface LoaderData { pageSize: number; currentPage: number; error?: string; - groups: DocumentTypeGroup[]; - ruleTypes: RuleType[]; + parentGroups: DocumentTypeGroup[]; frontendJWT?: string | null; } @@ -69,11 +68,11 @@ export async function loader({ request }: LoaderFunctionArgs) { }; // 并行获取文档类型数据和父级评查点分组 - const ruleTypesResponse = await getRuleTypes(undefined, frontendJWT); - if(ruleTypesResponse.error){ - console.error("获取父级评查点分组失败:", ruleTypesResponse.error); + const parentGroupsResponse = await getParentEvaluationPointGroups(frontendJWT); + if(parentGroupsResponse.error){ + console.error("获取父级评查点分组失败:", parentGroupsResponse.error); } - const ruleTypes = ruleTypesResponse.error ? [] : ruleTypesResponse.data; + const parentGroups = parentGroupsResponse.error ? [] : (parentGroupsResponse.data || []); const typesResponse = await getDocumentTypes(searchParams, frontendJWT); if(typesResponse.error){ @@ -81,16 +80,16 @@ export async function loader({ request }: LoaderFunctionArgs) { throw new Error(typesResponse.error); } const typesResult = typesResponse.data?.types || []; - - // console.log('文档类型数据:', typesResult.data?.types); - // console.log('父级评查点分组:', groupsResult.data); - + + // console.log('文档类型数据:', typesResult); + // console.log('父级评查点分组:', parentGroups); + return Response.json({ types: typesResult, total: typesResponse.data?.total || typesResult.length, pageSize, currentPage: page, - ruleTypes, + parentGroups, frontendJWT }); } catch (error) { @@ -140,58 +139,18 @@ export default function DocumentTypesList() { const [isDeleting, setIsDeleting] = useState(false); // 获取加载器数据 - const { types, total, error, ruleTypes, frontendJWT } = useLoaderData(); + const { types, total, error, parentGroups, frontendJWT } = useLoaderData(); // 获取用户角色并判断权限 const rootData = useRouteLoaderData("root") as { userRole: string }; const userRole = rootData?.userRole || 'common'; const hasEditPermission = userRole.toLowerCase().includes('provin'); - - // 状态管理 - const [ruleGroups, setRuleGroups] = useState([]); - const [loadingGroups, setLoadingGroups] = useState(false); - - // 获取当前的ruleType值 - const ruleTypeParam = searchParams.get('ruleType'); + // 获取搜索参数 const name = searchParams.get('name') || ''; const currentPage = parseInt(searchParams.get('page') || String(1), 10); const pageSize = parseInt(searchParams.get('pageSize') || String(10), 10); - // 判断是否禁用子级评查分组选择,true表示禁用,false表示不禁用 - const isRuleGroupSelectDisabled = loadingGroups || !ruleTypeParam || ruleGroups.length === 0; - - // 当评查点类型变化时,加载对应的子级评查分组 - useEffect(() => { - // 如果选择了"全部"或未选择,则清空子级评查分组 - if (!ruleTypeParam || ruleTypeParam === 'all') { - setRuleGroups([]); - return; - } - - // 加载当前类型的子级评查分组 - const loadRuleGroups = async () => { - setLoadingGroups(true); - try { - const response = await getRuleGroupsByType(ruleTypeParam, frontendJWT || undefined); - if (response.data) { - setRuleGroups(response.data); - } else if (response.error) { - console.error('加载子级规则组失败:', response.error); - setRuleGroups([]); - } - } catch (error) { - console.error('加载子级规则组出错:', error); - toastService.error('加载子级规则组出错:' + error); - setRuleGroups([]); - } finally { - setLoadingGroups(false); - } - }; - - loadRuleGroups(); - }, [ruleTypeParam]); - // 处理loader加载数据的时候的错误 useEffect(() => { if(error){ @@ -216,36 +175,16 @@ export default function DocumentTypesList() { const handleFilterChange = (e: React.ChangeEvent) => { const { name, value } = e.target; const newParams = new URLSearchParams(searchParams); - - // 如果是子级评查分组选择,但是当前应该被禁用,则不处理 - if (name === 'groupId' && isRuleGroupSelectDisabled) { - return; - } - + if (value) { newParams.set(name, value); - - // 如果是评查点类型变更,清空子级评查分组选择 - if (name === 'ruleType') { - newParams.delete('groupId'); - // 如果选择了"全部"或空值,也清空子级评查分组选择 - if (value === '' || value === 'all') { - setRuleGroups([]); - } - } } else { newParams.delete(name); - - // 如果清除评查点类型,也清除规则组 - if (name === 'ruleType') { - newParams.delete('groupId'); - setRuleGroups([]); - } } - + // 切换筛选条件时,重置到第一页 newParams.set('page', '1'); - + setSearchParams(newParams); }; @@ -317,7 +256,7 @@ export default function DocumentTypesList() { { title: "文档类型名称", key: "name", - width: "200px", + width: "180px", render: (_: unknown, record: DocumentTypeUI) => (
@@ -328,13 +267,27 @@ export default function DocumentTypesList() { { title: "描述", key: "description", - width: "300px", + width: "250px", render: (_: unknown, record: DocumentTypeUI) => (
{record.description}
) }, + { + title: "入口模块", + key: "entry_module", + width: "150px", + render: (_: unknown, record: DocumentTypeUI) => ( +
+ {record.entry_module ? ( + {record.entry_module.name} + ) : ( + 暂无关联入口 + )} +
+ ) + }, { title: "关联的评查点分组", key: "groups", @@ -436,29 +389,14 @@ export default function DocumentTypesList() { name="ruleType" value={searchParams.get('ruleType') || ''} options={[ - ...(ruleTypes || []).map(type => ({ - value: type.id, - label: type.name + ...(parentGroups || []).map(group => ({ + value: group.id, + label: group.name })) ]} onChange={handleFilterChange} className="mr-3 w-[20%]" /> - - ({ - value: group.id, - label: group.name - })) - ]} - onChange={handleFilterChange} - className={`mr-3 w-[20%] ${isRuleGroupSelectDisabled ? 'opacity-50' : ''}`} - /> 例如:销售合同、采购合同、专卖许可证等
- + + {/* 入口模块 */} +
+ + +
选择此文档类型对应的入口模块(可选)
+
+ {/* 类型描述 */}
@@ -670,30 +707,19 @@ export default function DocumentTypeNew() {
- {/* 子分组 */} + {/* 子分组 - 仅展示,不可选 */} {group.children && group.children.length > 0 && expandedGroups[group.id] && ( group.children.map((child: RuleGroup) => (
- handleGroupCheckChange(child.id, e.target.checked)} - className="radio-input" - disabled={isReadOnly} - /> - +
)) )} diff --git a/app/routes/documents.download.tsx b/app/routes/documents.download.tsx deleted file mode 100644 index 44ff67a..0000000 --- a/app/routes/documents.download.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { LoaderFunctionArgs } from "@remix-run/node"; -import { postgrestGet } from "~/api/postgrest-client"; -import { getUserSession } from "~/api/login/auth.server"; - -/** - * 文档下载路由 - 处理文档下载请求 - * 通过重定向到带有授权的连接来允许下载文件 - */ -export async function loader({ request }: LoaderFunctionArgs) { - try { - const { frontendJWT } = await getUserSession(request); - // 获取文件路径参数 - const url = new URL(request.url); - const filePath = url.searchParams.get("path"); - - if (!filePath) { - return new Response("缺少文件路径参数", { status: 400 }); - } - - // 调用Minio API获取带有授权的预签名URL - // 这里假设后端有一个生成预签名URL的API - const response = await postgrestGet<{ presignedUrl: string }>( - '/minio/presign', - { - filter: { - 'object_path': `eq.${filePath}`, - 'expires_in': 'eq.300' // 5分钟有效期 - }, - token: frontendJWT - } - ); - - if (response.error) { - console.error("获取文件下载链接失败:", response.error); - return new Response("获取文件下载链接失败", { status: 500 }); - } - - if (!response.data?.presignedUrl) { - return new Response("无法获取文件下载链接", { status: 404 }); - } - - // 重定向到预签名URL,这样浏览器就能直接下载文件 - return Response.redirect(response.data.presignedUrl); - } catch (error) { - console.error("文件下载处理失败:", error); - return new Response( - "文件下载处理失败: " + (error instanceof Error ? error.message : "未知错误"), - { status: 500 } - ); - } -} \ No newline at end of file diff --git a/app/routes/role-permissions._index.tsx b/app/routes/role-permissions._index.tsx new file mode 100644 index 0000000..a3da41b --- /dev/null +++ b/app/routes/role-permissions._index.tsx @@ -0,0 +1,513 @@ +import { useState, useEffect } from "react"; +import { ClientLoaderFunctionArgs, ClientActionFunctionArgs } from "@remix-run/react"; +import { Card } from "~/components/ui/Card"; +import { Button } from "~/components/ui/Button"; +import { toastService } from "~/components/ui/Toast"; +import { + getRoles, + getRoutes, + getRoleRoutePermissions, + updateRoleRoutePermissions, + getRoleUsers, + getAllUsers, + assignUserRoles, + createRole, + updateRole, + deleteRole, + type RoleInfo, + type RouteInfo, + type UserInfo +} from "~/api/role-permissions/role-permissions"; +import rolePermissionsStyles from "~/styles/pages/role-permissions.css?url"; + +// 引入样式 +export function links() { + return [ + { rel: "stylesheet", href: rolePermissionsStyles } + ]; +} + +// 页面元数据 +export const meta = () => { + return [ + { title: "角色权限管理 - 中国烟草AI合同及卷宗审核系统" }, + { name: "description", content: "管理系统角色和权限分配" } + ]; +}; + +// ClientLoader - 加载初始数据 +export async function clientLoader({ request }: ClientLoaderFunctionArgs) { + try { + const [roles, routes, users] = await Promise.all([ + getRoles(), + getRoutes(), + getAllUsers() + ]); + + return { + roles, + routes, + users + }; + } catch (error) { + console.error("加载数据失败:", error); + return { + roles: [], + routes: [], + users: [] + }; + } +} + +// ClientAction - 处理用户操作 +export async function clientAction({ request }: ClientActionFunctionArgs) { + const formData = await request.formData(); + const action = formData.get("action") as string; + + try { + switch (action) { + case "updatePermissions": { + const roleId = parseInt(formData.get("roleId") as string); + const routeIds = JSON.parse(formData.get("routeIds") as string); + const result = await updateRoleRoutePermissions(roleId, routeIds); + return result; + } + + case "assignUserRoles": { + const userId = parseInt(formData.get("userId") as string); + const roleIds = JSON.parse(formData.get("roleIds") as string); + const result = await assignUserRoles(userId, roleIds); + return result; + } + + case "createRole": { + const roleData = JSON.parse(formData.get("roleData") as string); + const result = await createRole(roleData); + return result; + } + + case "updateRole": { + const roleId = parseInt(formData.get("roleId") as string); + const roleData = JSON.parse(formData.get("roleData") as string); + const result = await updateRole(roleId, roleData); + return result; + } + + case "deleteRole": { + const roleId = parseInt(formData.get("roleId") as string); + const result = await deleteRole(roleId); + return result; + } + + default: + return { success: false, message: "未知操作" }; + } + } catch (error) { + console.error("操作失败:", error); + return { + success: false, + message: error instanceof Error ? error.message : "操作失败" + }; + } +} + +// 主组件 +export default function RolePermissions() { + const [roles, setRoles] = useState([]); + const [routes, setRoutes] = useState([]); + const [users, setUsers] = useState([]); + const [selectedRole, setSelectedRole] = useState(null); + const [activeTab, setActiveTab] = useState<'permissions' | 'users'>('permissions'); + const [loading, setLoading] = useState(true); + + // 路由权限相关状态 + const [selectedRouteIds, setSelectedRouteIds] = useState([]); + const [roleUsers, setRoleUsers] = useState([]); + + // 加载初始数据 + useEffect(() => { + loadData(); + }, []); + + const loadData = async () => { + setLoading(true); + try { + const [rolesData, routesData, usersData] = await Promise.all([ + getRoles(), + getRoutes(), + getAllUsers() + ]); + + setRoles(rolesData); + setRoutes(routesData); + setUsers(usersData); + + // 默认选中第一个角色 + if (rolesData.length > 0) { + handleSelectRole(rolesData[0]); + } + } catch (error) { + console.error("加载数据失败:", error); + toastService.error("加载数据失败"); + } finally { + setLoading(false); + } + }; + + // 选择角色 + const handleSelectRole = async (role: RoleInfo) => { + setSelectedRole(role); + + // 加载该角色的权限 + const permissions = await getRoleRoutePermissions(role.id); + const routeIds = permissions.map(p => p.route_id); + setSelectedRouteIds(routeIds); + + // 加载该角色的用户列表 + const users = await getRoleUsers(role.id); + setRoleUsers(users); + }; + + // 递归获取所有路由ID(包括子路由) + const getAllRouteIds = (routes: RouteInfo[]): number[] => { + let ids: number[] = []; + routes.forEach(route => { + ids.push(route.id); + if (route.children && route.children.length > 0) { + ids = ids.concat(getAllRouteIds(route.children)); + } + }); + return ids; + }; + + // 切换路由权限 + const handleToggleRoute = (routeId: number, checked: boolean) => { + if (checked) { + setSelectedRouteIds([...selectedRouteIds, routeId]); + } else { + setSelectedRouteIds(selectedRouteIds.filter(id => id !== routeId)); + } + }; + + // 切换父路由(包括所有子路由) + const handleToggleParentRoute = (route: RouteInfo, checked: boolean) => { + const childIds = route.children ? getAllRouteIds(route.children) : []; + const allIds = [route.id, ...childIds]; + + if (checked) { + const newIds = [...selectedRouteIds, ...allIds].filter( + (id, index, self) => self.indexOf(id) === index + ); + setSelectedRouteIds(newIds); + } else { + setSelectedRouteIds( + selectedRouteIds.filter(id => !allIds.includes(id)) + ); + } + }; + + // 保存权限 + const handleSavePermissions = async () => { + if (!selectedRole) return; + + try { + const formData = new FormData(); + formData.append("action", "updatePermissions"); + formData.append("roleId", selectedRole.id.toString()); + formData.append("routeIds", JSON.stringify(selectedRouteIds)); + + const response = await fetch("/role-permissions", { + method: "POST", + body: formData + }); + + const result = await response.json(); + + if (result.success) { + toastService.success(result.message); + } else { + toastService.error(result.message); + } + } catch (error) { + console.error("保存权限失败:", error); + toastService.error("保存权限失败"); + } + }; + + // 渲染路由树 + const renderRouteTree = (routes: RouteInfo[], level = 0) => { + return routes.map(route => { + const hasChildren = route.children && route.children.length > 0; + const isChecked = selectedRouteIds.includes(route.id); + const allChildIds = hasChildren ? getAllRouteIds(route.children!) : []; + const checkedChildCount = allChildIds.filter(id => + selectedRouteIds.includes(id) + ).length; + const isIndeterminate = + hasChildren && checkedChildCount > 0 && checkedChildCount < allChildIds.length; + + return ( +
+
+ { + if (el) el.indeterminate = isIndeterminate; + }} + onChange={(e) => { + if (hasChildren) { + handleToggleParentRoute(route, e.target.checked); + } else { + handleToggleRoute(route.id, e.target.checked); + } + }} + className="route-checkbox" + /> + +
+ + {hasChildren && ( +
+ {renderRouteTree(route.children!, level + 1)} +
+ )} +
+ ); + }); + }; + + if (loading) { + return ( +
+
+ + 加载中... +
+
+ ); + } + + return ( +
+ {/* 页面头部 */} +
+

+ + 角色权限管理 +

+
+ +
+
+ +
+ {/* 左侧:角色列表 */} + +
+ {roles.map(role => ( +
handleSelectRole(role)} + > +
+
+ {role.role_name} + {role.is_system_role && ( + 系统角色 + )} +
+
{role.role_key}
+
{role.description}
+
+ + + {role.data_scope} + + + + 优先级: {role.priority} + +
+
+ {!role.is_system_role && ( +
+ + +
+ )} +
+ ))} +
+
+ + {/* 右侧:角色详情和权限设置 */} +
+ {selectedRole ? ( + <> + {/* Tab 切换 */} + +
+ + +
+ +
+ {/* 路由权限Tab */} + {activeTab === 'permissions' && ( +
+
+

为角色 "{selectedRole.role_name}" 分配路由权限

+ +
+ +
+ {renderRouteTree(routes)} +
+ +
+ + 已选择 {selectedRouteIds.length} 个路由权限 +
+
+ )} + + {/* 用户列表Tab */} + {activeTab === 'users' && ( +
+
+

拥有角色 "{selectedRole.role_name}" 的用户

+ +
+ +
+ {roleUsers.length > 0 ? ( + roleUsers.map(user => ( +
+
+ +
+
+
+ {user.nick_name} + {user.is_leader && ( + 负责人 + )} +
+
@{user.username}
+
{user.ou_name}
+
+ {user.phone_number && ( + + + {user.phone_number} + + )} + {user.email && ( + + + {user.email} + + )} +
+
+
+ +
+
+ )) + ) : ( +
+ +

暂无用户拥有此角色

+
+ )} +
+
+ )} +
+
+ + ) : ( + +
+ +

请选择一个角色查看详情

+
+
+ )} +
+
+
+ ); +} diff --git a/app/services/api.client.ts b/app/services/api.client.ts index dde15a6..b1479d1 100644 --- a/app/services/api.client.ts +++ b/app/services/api.client.ts @@ -1,6 +1,7 @@ import { CHAT_CONFIG, ContentType, SSE_TIMEOUT } from '../config/chat'; import type { Feedbacktype, ThoughtItem, VisionFile, MessageEnd, MessageReplace } from '../types/dify_chat'; import { unicodeToChar } from '../utils/chat-utils'; +import axios from 'axios'; // 基础请求选项 // 注意:客户端调用Remix API routes,不需要手动添加Authorization @@ -321,7 +322,7 @@ const handleStream = ( * }); * ``` */ -const baseFetch = (url: string, fetchOptions: any, needAllResponseContent: boolean = false) => { +const baseFetch = async (url: string, fetchOptions: any, needAllResponseContent: boolean = false) => { const options = Object.assign({}, baseOptions, fetchOptions); // 调用Remix API routes(如 /api/conversations) @@ -329,51 +330,46 @@ const baseFetch = (url: string, fetchOptions: any, needAllResponseContent: boole const urlWithPrefix = `${CHAT_CONFIG.API_URL}/${url.replace(/^\//, '')}`; const { body } = options; + let data = body; if (body && typeof body === 'object') { // 不再添加user参数,服务端会从JWT自动提取 - options.body = JSON.stringify(body); + data = body; } - return fetch(urlWithPrefix, options) - .then((res: Response) => { - if (!res.ok) { - console.error('❌ Request failed:', { - status: res.status, - statusText: res.statusText, - url: urlWithPrefix - }); - if (res.status === 422) { - return res.text().then(text => { - let errorMessage = text; - try { - const data = JSON.parse(text); - errorMessage = data.message || data.error || text; - } catch (e) { - // 如果不是JSON,使用原始文本 - } - throw new Error(errorMessage); - }); - } - throw new Error(`${res.status}: ${res.statusText}`); - } - - if (needAllResponseContent) { - return res.text().then(text => { - try { - return JSON.parse(text); - } catch (e) { - return text; - } - }); - } - - const data = res.json(); - return data; - }) - .catch((err) => { - console.error('❌ Request error:', err.message); - throw err; + try { + const response = await axios({ + url: urlWithPrefix, + method: options.method || 'GET', + data: data, + headers: options.headers, + withCredentials: true, // 等同于 credentials: 'include' }); + + if (needAllResponseContent) { + return response.data; + } + + return response.data; + } catch (err) { + if (axios.isAxiosError(err)) { + console.error('❌ Request failed:', { + status: err.response?.status, + statusText: err.response?.statusText, + url: urlWithPrefix + }); + + if (err.response?.status === 422) { + const errorData = err.response.data; + const errorMessage = errorData?.message || errorData?.error || JSON.stringify(errorData); + throw new Error(errorMessage); + } + + throw new Error(`${err.response?.status || 500}: ${err.response?.statusText || err.message}`); + } + + console.error('❌ Request error:', (err as Error).message); + throw err; + } }; /** @@ -532,25 +528,25 @@ export const fetchConversations = async () => { const url = `${CHAT_CONFIG.API_URL}/conversations?${params}`; console.log('📋 [API Client] 获取会话列表:', { url, apiUrl: CHAT_CONFIG.API_URL }); - return fetch(url, { - method: 'GET', - credentials: 'include', // 携带cookie - }).then(res => { - console.log('📋 [API Client] 会话列表响应:', { status: res.status, ok: res.ok }); - if (!res.ok) { - return res.text().then(text => { - console.error('❌ [API Client] 获取会话列表失败:', { status: res.status, body: text }); - throw new Error(`Failed to fetch conversations: ${res.status} - ${text}`); - }); - } - return res.json().then(data => { - console.log('📋 [API Client] 会话列表数据:', data); - return data; + try { + const response = await axios.get(url, { + withCredentials: true, // 携带cookie }); - }).catch(err => { + + console.log('📋 [API Client] 会话列表响应:', { status: response.status }); + console.log('📋 [API Client] 会话列表数据:', response.data); + return response.data; + } catch (err) { + if (axios.isAxiosError(err)) { + console.error('❌ [API Client] 获取会话列表失败:', { + status: err.response?.status, + body: err.response?.data + }); + throw new Error(`Failed to fetch conversations: ${err.response?.status} - ${JSON.stringify(err.response?.data)}`); + } console.error('❌ [API Client] 会话列表请求异常:', err); throw err; - }); + } }; /** @@ -582,15 +578,17 @@ export const fetchChatList = async (conversationId: string) => { // 不再传递user参数,服务端会从JWT自动提取 }); - return fetch(`${CHAT_CONFIG.API_URL}/messages?${params}`, { - method: 'GET', - credentials: 'include', // 携带cookie - }).then(res => { - if (!res.ok) { - throw new Error(`Failed to fetch chat list: ${res.status}`); + try { + const response = await axios.get(`${CHAT_CONFIG.API_URL}/messages?${params}`, { + withCredentials: true, // 携带cookie + }); + return response.data; + } catch (err) { + if (axios.isAxiosError(err)) { + throw new Error(`Failed to fetch chat list: ${err.response?.status}`); } - return res.json(); - }); + throw err; + } }; /** @@ -620,25 +618,24 @@ export const fetchAppParams = async () => { const url = `${CHAT_CONFIG.API_URL}/parameters`; console.log('⚙️ [API Client] 获取应用参数:', { url, apiUrl: CHAT_CONFIG.API_URL }); - return fetch(url, { - method: 'GET', - credentials: 'include', // 携带cookie - }).then(res => { - console.log('⚙️ [API Client] 应用参数响应:', { status: res.status, ok: res.ok }); - if (!res.ok) { - return res.text().then(text => { - console.error('❌ [API Client] 获取应用参数失败:', { status: res.status, body: text }); - throw new Error(`Failed to fetch app params: ${res.status} - ${text}`); - }); - } - return res.json().then(data => { - console.log('⚙️ [API Client] 应用参数数据:', data); - return data; + try { + const response = await axios.get(url, { + withCredentials: true, // 携带cookie }); - }).catch(err => { + console.log('⚙️ [API Client] 应用参数响应:', { status: response.status }); + console.log('⚙️ [API Client] 应用参数数据:', response.data); + return response.data; + } catch (err) { + if (axios.isAxiosError(err)) { + console.error('❌ [API Client] 获取应用参数失败:', { + status: err.response?.status, + body: err.response?.data + }); + throw new Error(`Failed to fetch app params: ${err.response?.status} - ${JSON.stringify(err.response?.data)}`); + } console.error('❌ [API Client] 应用参数请求异常:', err); throw err; - }); + } }; /** @@ -669,19 +666,20 @@ export const fetchAppParams = async () => { export const updateFeedback = async ({ url, body }: { url: string; body: Feedbacktype }) => { const messageId = url.split('/').pop(); // 从URL中提取messageId - return fetch(`${CHAT_CONFIG.API_URL}/messages/${messageId}/feedbacks`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - credentials: 'include', // 携带cookie - body: JSON.stringify(body), // 不再添加user参数 - }).then(res => { - if (!res.ok) { - throw new Error(`Failed to update feedback: ${res.status}`); + try { + const response = await axios.post(`${CHAT_CONFIG.API_URL}/messages/${messageId}/feedbacks`, body, { + headers: { + 'Content-Type': 'application/json', + }, + withCredentials: true, // 携带cookie + }); + return response.data; + } catch (err) { + if (axios.isAxiosError(err)) { + throw new Error(`Failed to update feedback: ${err.response?.status}`); } - return res.json(); - }); + throw err; + } }; /** @@ -705,22 +703,23 @@ export const updateFeedback = async ({ url, body }: { url: string; body: Feedbac * ``` */ export const generateConversationName = async (id: string) => { - return fetch(`${CHAT_CONFIG.API_URL}/conversations/${id}/name`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - credentials: 'include', // 携带cookie - body: JSON.stringify({ + try { + const response = await axios.post(`${CHAT_CONFIG.API_URL}/conversations/${id}/name`, { auto_generate: true, // 不再添加user参数 - }), - }).then(res => { - if (!res.ok) { - throw new Error(`Failed to generate conversation name: ${res.status}`); + }, { + headers: { + 'Content-Type': 'application/json', + }, + withCredentials: true, // 携带cookie + }); + return response.data; + } catch (err) { + if (axios.isAxiosError(err)) { + throw new Error(`Failed to generate conversation name: ${err.response?.status}`); } - return res.json(); - }); + throw err; + } }; /** @@ -749,23 +748,24 @@ export const generateConversationName = async (id: string) => { * ``` */ export const renameConversation = async (id: string, name: string, autoGenerate: boolean = false) => { - return fetch(`${CHAT_CONFIG.API_URL}/conversations/${id}/name`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - credentials: 'include', // 携带cookie - body: JSON.stringify({ + try { + const response = await axios.post(`${CHAT_CONFIG.API_URL}/conversations/${id}/name`, { name: autoGenerate ? undefined : name, auto_generate: autoGenerate, // 不再添加user参数 - }), - }).then(res => { - if (!res.ok) { - throw new Error(`Failed to rename conversation: ${res.status}`); + }, { + headers: { + 'Content-Type': 'application/json', + }, + withCredentials: true, // 携带cookie + }); + return response.data; + } catch (err) { + if (axios.isAxiosError(err)) { + throw new Error(`Failed to rename conversation: ${err.response?.status}`); } - return res.json(); - }); + throw err; + } }; /** @@ -790,31 +790,29 @@ export const renameConversation = async (id: string, name: string, autoGenerate: export const deleteConversation = async (id: string) => { console.log('🗑️ [API Client] 删除会话:', id); - return fetch(`${CHAT_CONFIG.API_URL}/conversations/${id}`, { - method: 'DELETE', - headers: { - 'Content-Type': 'application/json', - }, - credentials: 'include', // 携带cookie - // 不再发送body和user参数 - }).then(async res => { - console.log('🗑️ [API Client] 删除会话响应:', { - status: res.status, - ok: res.ok, - statusText: res.statusText + try { + const response = await axios.delete(`${CHAT_CONFIG.API_URL}/conversations/${id}`, { + headers: { + 'Content-Type': 'application/json', + }, + withCredentials: true, // 携带cookie + // 不再发送body和user参数 }); - if (!res.ok) { - // 尝试读取错误详情 - const errorText = await res.text(); - console.error('❌ [API Client] 删除会话失败详情:', errorText); - throw new Error(`Failed to delete conversation: ${res.status}`); - } + console.log('🗑️ [API Client] 删除会话响应:', { + status: response.status, + statusText: response.statusText + }); - const data = await res.json(); - console.log('🗑️ [API Client] 删除会话数据:', data); - return data; - }); + console.log('🗑️ [API Client] 删除会话数据:', response.data); + return response.data; + } catch (err) { + if (axios.isAxiosError(err)) { + console.error('❌ [API Client] 删除会话失败详情:', err.response?.data); + throw new Error(`Failed to delete conversation: ${err.response?.status}`); + } + throw err; + } }; /** diff --git a/app/styles/pages/document-types_new.css b/app/styles/pages/document-types_new.css index 039390e..6cc2a43 100644 --- a/app/styles/pages/document-types_new.css +++ b/app/styles/pages/document-types_new.css @@ -100,7 +100,7 @@ } .document-type-new-page .child-badge { - @apply bg-[rgba(0,104,1,0.61)] text-white; + @apply bg-[rgba(0,104,1,0.71)] text-white; } /* 添加checkbox-input样式,使用视觉上更美观的自定义复选框样式 */ diff --git a/app/styles/pages/role-permissions.css b/app/styles/pages/role-permissions.css new file mode 100644 index 0000000..e22f157 --- /dev/null +++ b/app/styles/pages/role-permissions.css @@ -0,0 +1,552 @@ +/* 角色权限管理页面样式 */ + +.role-permissions-page { + padding: 20px; + background: #f5f7fa; + min-height: calc(100vh - 60px); +} + +/* 页面头部 */ +.role-permissions-page .page-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; +} + +.role-permissions-page .page-title { + font-size: 24px; + font-weight: 600; + color: #303133; + display: flex; + align-items: center; + gap: 10px; + margin: 0; +} + +.role-permissions-page .page-title i { + font-size: 28px; + color: var(--color-primary); +} + +.role-permissions-page .page-actions { + display: flex; + gap: 12px; +} + +/* 主容器布局 */ +.permissions-container { + display: grid; + grid-template-columns: 380px 1fr; + gap: 20px; + align-items: start; +} + +/* 左侧角色面板 */ +.roles-panel { + height: calc(100vh - 140px); + overflow: hidden; + display: flex; + flex-direction: column; +} + +.roles-list { + overflow-y: auto; + flex: 1; +} + +/* 角色列表项 */ +.role-item { + padding: 16px; + border-bottom: 1px solid #ebeef5; + cursor: pointer; + transition: all 0.2s; + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 12px; +} + +.role-item:hover { + background: #f5f7fa; +} + +.role-item.active { + background: var(--color-primary-light); + border-left: 3px solid var(--color-primary); +} + +.role-info { + flex: 1; + min-width: 0; +} + +.role-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 6px; +} + +.role-name { + font-size: 16px; + font-weight: 600; + color: #303133; +} + +.system-badge { + display: inline-block; + padding: 2px 8px; + background: #e6f7ff; + color: #1890ff; + border: 1px solid #91d5ff; + border-radius: 4px; + font-size: 12px; + font-weight: normal; +} + +.role-key { + font-size: 12px; + color: #909399; + font-family: 'Courier New', monospace; + margin-bottom: 6px; +} + +.role-desc { + font-size: 13px; + color: #606266; + margin-bottom: 8px; + line-height: 1.5; +} + +.role-meta { + display: flex; + gap: 16px; + font-size: 12px; + color: #909399; +} + +.role-meta span { + display: flex; + align-items: center; + gap: 4px; +} + +.role-meta i { + font-size: 14px; +} + +.role-actions { + display: flex; + gap: 4px; + opacity: 0; + transition: opacity 0.2s; +} + +.role-item:hover .role-actions { + opacity: 1; +} + +.btn-icon { + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + border: none; + background: transparent; + border-radius: 4px; + cursor: pointer; + color: #606266; + transition: all 0.2s; +} + +.btn-icon:hover { + background: #e6e8eb; + color: #303133; +} + +.btn-icon.text-error:hover { + background: #fef0f0; + color: var(--color-error); +} + +/* 右侧详情面板 */ +.permissions-detail { + min-height: calc(100vh - 140px); +} + +/* Tab 样式 */ +.tabs-card { + overflow: hidden; +} + +.tabs-header { + display: flex; + border-bottom: 2px solid #e4e7ed; + background: #fafbfc; + padding: 0 20px; +} + +.tab-btn { + padding: 14px 24px; + border: none; + background: transparent; + color: #606266; + font-size: 15px; + font-weight: 500; + cursor: pointer; + border-bottom: 3px solid transparent; + margin-bottom: -2px; + transition: all 0.2s; + display: flex; + align-items: center; + gap: 8px; +} + +.tab-btn i { + font-size: 18px; +} + +.tab-btn:hover { + color: var(--color-primary); +} + +.tab-btn.active { + color: var(--color-primary); + border-bottom-color: var(--color-primary); + background: white; +} + +.tabs-content { + padding: 24px; + min-height: 500px; +} + +/* 权限Tab */ +.permissions-tab { + display: flex; + flex-direction: column; + gap: 20px; +} + +.permissions-header { + display: flex; + justify-content: space-between; + align-items: center; + padding-bottom: 16px; + border-bottom: 1px solid #e4e7ed; +} + +.permissions-header h3 { + font-size: 18px; + font-weight: 600; + color: #303133; + margin: 0; +} + +/* 路由树 */ +.routes-tree { + max-height: 600px; + overflow-y: auto; + border: 1px solid #e4e7ed; + border-radius: 6px; + padding: 16px; + background: #fafbfc; +} + +.route-item { + margin-bottom: 8px; +} + +.route-item-content { + display: flex; + align-items: center; + padding: 10px 12px; + border-radius: 6px; + transition: background 0.2s; +} + +.route-item-content:hover { + background: #e6e8eb; +} + +.route-checkbox { + width: 18px; + height: 18px; + margin-right: 10px; + cursor: pointer; + accent-color: var(--color-primary); +} + +.route-label { + display: flex; + align-items: center; + gap: 10px; + flex: 1; + cursor: pointer; + font-size: 14px; +} + +.route-icon { + font-size: 18px; + color: var(--color-primary); +} + +.route-title { + font-weight: 500; + color: #303133; +} + +.route-path { + color: #909399; + font-size: 13px; + font-family: 'Courier New', monospace; +} + +.route-children { + margin-top: 8px; +} + +.permissions-summary { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 16px; + background: #ecf5ff; + border: 1px solid #b3d8ff; + border-radius: 6px; + color: #606266; + font-size: 14px; +} + +.permissions-summary i { + font-size: 18px; + color: #409eff; +} + +.permissions-summary strong { + color: var(--color-primary); + font-weight: 600; +} + +/* 用户Tab */ +.users-tab { + display: flex; + flex-direction: column; + gap: 20px; +} + +.users-header { + display: flex; + justify-content: space-between; + align-items: center; + padding-bottom: 16px; + border-bottom: 1px solid #e4e7ed; +} + +.users-header h3 { + font-size: 18px; + font-weight: 600; + color: #303133; + margin: 0; +} + +.users-list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 16px; + max-height: 600px; + overflow-y: auto; +} + +/* 用户卡片 */ +.user-card { + display: flex; + gap: 12px; + padding: 16px; + border: 1px solid #e4e7ed; + border-radius: 8px; + background: white; + transition: all 0.2s; +} + +.user-card:hover { + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1); + border-color: var(--color-primary); +} + +.user-avatar { + width: 56px; + height: 56px; + border-radius: 50%; + background: linear-gradient(135deg, var(--color-primary), #00a870); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.user-avatar i { + font-size: 28px; + color: white; +} + +.user-info { + flex: 1; + min-width: 0; +} + +.user-name { + font-size: 16px; + font-weight: 600; + color: #303133; + margin-bottom: 4px; + display: flex; + align-items: center; + gap: 8px; +} + +.leader-badge { + display: inline-block; + padding: 2px 8px; + background: #fff7e6; + color: #fa8c16; + border: 1px solid #ffd591; + border-radius: 4px; + font-size: 12px; + font-weight: normal; +} + +.user-username { + font-size: 13px; + color: #909399; + margin-bottom: 4px; +} + +.user-org { + font-size: 13px; + color: #606266; + margin-bottom: 8px; +} + +.user-contact { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 12px; + color: #909399; +} + +.user-contact span { + display: flex; + align-items: center; + gap: 4px; +} + +.user-contact i { + font-size: 14px; +} + +.user-actions { + display: flex; + flex-direction: column; + gap: 4px; +} + +/* 空状态 */ +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 60px 20px; + color: #909399; + text-align: center; +} + +.empty-state i { + font-size: 64px; + color: #dcdfe6; + margin-bottom: 16px; +} + +.empty-state p { + font-size: 14px; + margin: 0; +} + +/* 加载状态 */ +.loading-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 400px; + gap: 16px; + color: #606266; +} + +.loading-container i { + font-size: 48px; + color: var(--color-primary); +} + +.spin { + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +/* 响应式布局 */ +@media (max-width: 1200px) { + .permissions-container { + grid-template-columns: 320px 1fr; + } + + .users-list { + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + } +} + +@media (max-width: 992px) { + .permissions-container { + grid-template-columns: 1fr; + } + + .roles-panel { + height: auto; + max-height: 400px; + } + + .users-list { + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + } +} + +@media (max-width: 768px) { + .role-permissions-page { + padding: 12px; + } + + .role-permissions-page .page-header { + flex-direction: column; + align-items: flex-start; + gap: 12px; + } + + .tabs-header { + overflow-x: auto; + } + + .users-list { + grid-template-columns: 1fr; + } +}