Merge remote-tracking branch 'origin/shiy-login' into PingChuan

This commit is contained in:
PingChuan
2025-11-20 20:37:08 +08:00
25 changed files with 2299 additions and 762 deletions
+7
View File
@@ -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';
}
+30 -45
View File
@@ -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 {
+27 -43
View File
@@ -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<string, string> = {
'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<CrossCheckingFileUploadResponse>(responseData);
console.log('【交叉评查上传】JSON响应解析成功:', response.data);
const extractedData = extractApiData<CrossCheckingFileUploadResponse>(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<string, string> = {};
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 {
+35 -44
View File
@@ -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 : '获取任务文档列表失败'
+184 -33
View File
@@ -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<Array<{
id: number;
name: string;
}>>(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<Array<{
id: number;
name: string;
}>>('evaluation_point_groups', params);
if (response.error) {
return { error: response.error, status: response.status };
}
// 使用extractApiData提取数据
const extractedData = extractApiData<Array<{
id: number;
name: string;
}>>(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<Array<{
id: number;
name: string;
}>>('entry_modules', params);
if (response.error) {
return { error: response.error, status: response.status };
}
// 使用extractApiData提取数据
const extractedData = extractApiData<Array<{
id: number;
name: string;
}>>(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<string, string>,
token: frontendJWT
};
// 添加筛选条件
const filter: Record<string, string> = {};
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<DocumentType[]>('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<DocumentType[]>(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<number>();
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<number, DocumentTypeGroup> = 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<string, string>
const responseWithHeaders = response as {
data: unknown;
headers: Record<string, string>
};
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<DocumentType[]>('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
};
+1 -10
View File
@@ -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
};
-36
View File
@@ -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
+35 -70
View File
@@ -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<string, string> = {
'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<FileUploadResponse>(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
};
}
+23 -22
View File
@@ -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<void
formData.append('redirect_url', encodeURIComponent(redirectUri));
try {
const response = await fetch(logoutUrl, {
method: 'POST',
const response = await axios.post(logoutUrl, formData.toString(), {
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: formData.toString(),
});
if (!response.ok) {
throw new Error(`IDaaS登出失败: ${response.status} ${response.statusText}`);
}
console.log("IDaaS单点登出请求成功");
} catch (error) {
if (axios.isAxiosError(error)) {
console.error("调用IDaaS登出接口失败:", error.response?.status, error.response?.statusText);
throw new Error(`IDaaS登出失败: ${error.response?.status} ${error.response?.statusText}`);
}
console.error("调用IDaaS登出接口失败:", error);
throw error;
}
@@ -775,18 +778,16 @@ export async function simpleRootLogin(
}
// 调用登录接口
const loginResponse = await fetch(`${API_BASE_URL}/password_login`, {
method: 'POST',
const loginResponse = await axios.post(`${API_BASE_URL}/password_login`, {
sub: username.trim(),
password: password.trim()
}, {
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
sub: username.trim(),
password: password.trim()
})
}
});
const loginResult = await loginResponse.json();
const loginResult = loginResponse.data;
console.log('登录接口返回', loginResult);
// 检查重试次数
+22 -29
View File
@@ -4,6 +4,7 @@
*/
import { API_BASE_URL } from "~/config/api-config";
import axios from 'axios';
/**
* 登录请求参数(OAuth 方式)
@@ -64,30 +65,26 @@ export async function loginWithOAuth(loginData: LoginRequest): Promise<LoginResp
console.log("📝 [Login Client] 调用后端 OAuth 登录接口:", loginUrl);
try {
const response = await fetch(loginUrl, {
method: "POST",
const response = await axios.post(loginUrl, loginData, {
headers: {
"Content-Type": "application/json",
"Accept": "application/json"
},
body: JSON.stringify(loginData)
}
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
console.error("❌ [Login Client] OAuth 登录请求失败:", response.status, errorData);
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,
+52 -77
View File
@@ -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);
}
@@ -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<RoleInfo[]> {
// 模拟网络延迟
await new Promise(resolve => setTimeout(resolve, 300));
return mockRoles;
}
/**
* 获取所有路由(树形结构)
*/
export async function getRoutes(): Promise<RouteInfo[]> {
await new Promise(resolve => setTimeout(resolve, 300));
return mockRoutes;
}
/**
* 获取指定角色的路由权限
* @param roleId 角色ID
*/
export async function getRoleRoutePermissions(roleId: number): Promise<RoleRoutePermission[]> {
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<UserInfo[]> {
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<UserInfo[]> {
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<RoleInfo, 'id' | 'created_at' | 'updated_at'>
): 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<Omit<RoleInfo, 'id' | 'created_at' | 'updated_at'>>
): 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: '角色删除成功' };
}
+4 -11
View File
@@ -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<OrganizationResponse>(
+6 -6
View File
@@ -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]);
@@ -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('意见提交成功');
// 创建新的提案对象
+3 -2
View File
@@ -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 === '智慧法务大模型'){
+7 -7
View File
@@ -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);
+2 -1
View File
@@ -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
+40 -102
View File
@@ -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<LoaderData>();
const { types, total, error, parentGroups, frontendJWT } = useLoaderData<LoaderData>();
// 获取用户角色并判断权限
const rootData = useRouteLoaderData("root") as { userRole: string };
const userRole = rootData?.userRole || 'common';
const hasEditPermission = userRole.toLowerCase().includes('provin');
// 状态管理
const [ruleGroups, setRuleGroups] = useState<RuleGroup[]>([]);
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<HTMLSelectElement>) => {
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) => (
<div className="flex items-center">
<i className="ri-file-text-line text-primary mr-2"></i>
@@ -328,13 +267,27 @@ export default function DocumentTypesList() {
{
title: "描述",
key: "description",
width: "300px",
width: "250px",
render: (_: unknown, record: DocumentTypeUI) => (
<div className="text-secondary text-sm truncate max-w-xs" title={record.description}>
{record.description}
</div>
)
},
{
title: "入口模块",
key: "entry_module",
width: "150px",
render: (_: unknown, record: DocumentTypeUI) => (
<div className="flex items-center">
{record.entry_module ? (
<span className="type-badge">{record.entry_module.name}</span>
) : (
<span className="text-gray-400"></span>
)}
</div>
)
},
{
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%]"
/>
<FilterSelect
label="所属子级评查分组"
name="groupId"
value={searchParams.get('groupId') || ''}
options={[
...(isRuleGroupSelectDisabled ? [{ value: "", label: "请先选择评查点类型" }] : []),
...ruleGroups.map(group => ({
value: group.id,
label: group.name
}))
]}
onChange={handleFilterChange}
className={`mr-3 w-[20%] ${isRuleGroupSelectDisabled ? 'opacity-50' : ''}`}
/>
<SearchFilter
label="类型名称"
+50 -24
View File
@@ -5,7 +5,7 @@ import { Card } from "~/components/ui/Card";
import { Button } from "~/components/ui/Button";
import documentTypesNewStyles from "~/styles/pages/document-types_new.css?url";
import { getAllRuleGroups, type RuleGroup } from "~/api/evaluation_points/rule-groups";
import { getDocumentType, createDocumentType, updateDocumentType } from "~/api/document-types/document-types";
import { getDocumentType, createDocumentType, updateDocumentType, getEntryModules } from "~/api/document-types/document-types";
import { getPromptTemplates, type PromptTemplateUI } from "~/api/prompts/prompts";
import { toastService } from "~/components/ui/Toast";
@@ -81,8 +81,15 @@ export async function loader({ request }: LoaderFunctionArgs) {
const groupsTree = ruleGroupsResponse.error ? [] : ruleGroupsResponse.data || [];
// 2. 获取各类型的提示词模板
const [llmExtractionTemplatesResponse, vlmExtractionTemplatesResponse, evaluationTemplatesResponse, summaryTemplatesResponse] =
// 2. 获取入口模块列表
const entryModulesResponse = await getEntryModules(frontendJWT);
if (entryModulesResponse.error) {
console.error("获取入口模块失败:", entryModulesResponse.error);
}
const entryModules = entryModulesResponse.error ? [] : (entryModulesResponse.data || []);
// 3. 获取各类型的提示词模板
const [llmExtractionTemplatesResponse, vlmExtractionTemplatesResponse, evaluationTemplatesResponse, summaryTemplatesResponse] =
await Promise.all([
getPromptTemplates({ type: TEMPLATE_TYPES.LLM_EXTRACTION }, frontendJWT),
getPromptTemplates({ type: TEMPLATE_TYPES.VLM_EXTRACTION }, frontendJWT),
@@ -103,6 +110,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
isEdit,
documentType,
ruleGroups: groupsTree,
entryModules,
llmExtractionTemplates: llmExtractionTemplatesResponse.data?.templates || [],
vlmExtractionTemplates: vlmExtractionTemplatesResponse.data?.templates || [],
evaluationTemplates: evaluationTemplatesResponse.data?.templates || [],
@@ -114,6 +122,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
isEdit: false,
documentType: undefined,
ruleGroups: [],
entryModules: [],
llmExtractionTemplates: [],
vlmExtractionTemplates: [],
evaluationTemplates: [],
@@ -132,6 +141,7 @@ export async function action({ request }: ActionFunctionArgs) {
const id = formData.get("id") as string | null;
const name = formData.get("name") as string;
const description = formData.get("description") as string;
const entryModuleId = formData.get("entry_module_id") as string;
const llmExtractionTemplateId = formData.get("llm_extraction_template") as string;
const vlmExtractionTemplateId = formData.get("vlm_extraction_template") as string;
const evaluationTemplateId = formData.get("evaluation_template") as string;
@@ -179,6 +189,7 @@ export async function action({ request }: ActionFunctionArgs) {
name,
description,
group_ids: selectedGroups,
entry_module_id: entryModuleId ? parseInt(entryModuleId) : null,
// 确保映射关系与prompt_config字段对应正确
llm_extraction_template_id: llmExtractionTemplateId ? parseInt(llmExtractionTemplateId) : null,
vlm_extraction_template_id: vlmExtractionTemplateId ? parseInt(vlmExtractionTemplateId) : null,
@@ -222,9 +233,10 @@ export default function DocumentTypeNew() {
const [searchParams] = useSearchParams();
const isEditMode = searchParams.has("id");
const {
documentType,
ruleGroups,
const {
documentType,
ruleGroups,
entryModules,
llmExtractionTemplates,
vlmExtractionTemplates,
evaluationTemplates,
@@ -244,6 +256,7 @@ export default function DocumentTypeNew() {
id: documentType?.id || "",
name: documentType?.name || "",
description: documentType?.description || "",
entryModuleId: documentType?.entry_module?.id?.toString() || "",
llmExtractionTemplateId: documentType?.llm_extraction_template_id || "",
vlmExtractionTemplateId: documentType?.vlm_extraction_template_id || "",
evaluationTemplateId: documentType?.evaluation_template_id || "",
@@ -287,6 +300,7 @@ export default function DocumentTypeNew() {
id: documentType.id,
name: documentType.name,
description: documentType.description,
entryModuleId: documentType.entry_module?.id?.toString() || "",
llmExtractionTemplateId: documentType.llm_extraction_template_id || "",
vlmExtractionTemplateId: documentType.vlm_extraction_template_id || "",
evaluationTemplateId: documentType.evaluation_template_id || "",
@@ -510,7 +524,30 @@ export default function DocumentTypeNew() {
)}
<div className="form-tip"></div>
</div>
{/* 入口模块 */}
<div className="form-group">
<label htmlFor="entry-module" className="form-label">
</label>
<select
id="entry-module"
name="entry_module_id"
className="form-select"
value={formData.entryModuleId}
onChange={handleInputChange}
disabled={isReadOnly}
>
<option value=""></option>
{entryModules.map((module: { id: number; name: string }) => (
<option key={module.id} value={module.id}>
{module.name}
</option>
))}
</select>
<div className="form-tip"></div>
</div>
{/* 类型描述 */}
<div className="form-group">
<label htmlFor="type-description" className="form-label"></label>
@@ -670,30 +707,19 @@ export default function DocumentTypeNew() {
</label>
</div>
{/* 子分组 */}
{/* 子分组 - 仅展示,不可选 */}
{group.children && group.children.length > 0 && expandedGroups[group.id] && (
group.children.map((child: RuleGroup) => (
<div
key={child.id}
className={`checkbox-item child-checkbox-item ${formData.selectedGroups.includes(child.id) ? 'checked' : ''}`}
className="checkbox-item child-checkbox-item"
style={{ paddingLeft: '2.5rem', opacity: 0.9 }}
>
<input
type="radio"
id={`group-${child.id}`}
name="checkpoint_group_ids"
value={child.id}
checked={formData.selectedGroups.includes(child.id)}
onChange={(e) => handleGroupCheckChange(child.id, e.target.checked)}
className="radio-input"
disabled={isReadOnly}
/>
<label
htmlFor={`group-${child.id}`}
className="checkbox-label"
>
<i className="ri-subtract-line" style={{ marginRight: '8px', color: '#9ca3af' }}></i>
<span className="checkbox-label">
{child.name}
<span className="group-badge child-badge"></span>
</label>
</span>
</div>
))
)}
-51
View File
@@ -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 }
);
}
}
+513
View File
@@ -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<RoleInfo[]>([]);
const [routes, setRoutes] = useState<RouteInfo[]>([]);
const [users, setUsers] = useState<UserInfo[]>([]);
const [selectedRole, setSelectedRole] = useState<RoleInfo | null>(null);
const [activeTab, setActiveTab] = useState<'permissions' | 'users'>('permissions');
const [loading, setLoading] = useState(true);
// 路由权限相关状态
const [selectedRouteIds, setSelectedRouteIds] = useState<number[]>([]);
const [roleUsers, setRoleUsers] = useState<UserInfo[]>([]);
// 加载初始数据
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 (
<div key={route.id} className="route-item" style={{ paddingLeft: `${level * 20}px` }}>
<div className="route-item-content">
<input
type="checkbox"
id={`route-${route.id}`}
checked={isChecked}
ref={el => {
if (el) el.indeterminate = isIndeterminate;
}}
onChange={(e) => {
if (hasChildren) {
handleToggleParentRoute(route, e.target.checked);
} else {
handleToggleRoute(route.id, e.target.checked);
}
}}
className="route-checkbox"
/>
<label htmlFor={`route-${route.id}`} className="route-label">
{route.icon && <i className={`${route.icon} route-icon`}></i>}
<span className="route-title">{route.route_title}</span>
<span className="route-path">{route.route_path}</span>
</label>
</div>
{hasChildren && (
<div className="route-children">
{renderRouteTree(route.children!, level + 1)}
</div>
)}
</div>
);
});
};
if (loading) {
return (
<div className="role-permissions-page">
<div className="loading-container">
<i className="ri-loader-4-line spin"></i>
<span>...</span>
</div>
</div>
);
}
return (
<div className="role-permissions-page">
{/* 页面头部 */}
<div className="page-header">
<h2 className="page-title">
<i className="ri-shield-user-line"></i>
</h2>
<div className="page-actions">
<Button
type="primary"
icon="ri-add-line"
onClick={() => {
toastService.info("创建角色功能开发中...");
}}
>
</Button>
</div>
</div>
<div className="permissions-container">
{/* 左侧:角色列表 */}
<Card className="roles-panel" title="角色列表" bodyClassName="p-0">
<div className="roles-list">
{roles.map(role => (
<div
key={role.id}
className={`role-item ${selectedRole?.id === role.id ? 'active' : ''}`}
onClick={() => handleSelectRole(role)}
>
<div className="role-info">
<div className="role-header">
<span className="role-name">{role.role_name}</span>
{role.is_system_role && (
<span className="system-badge"></span>
)}
</div>
<div className="role-key">{role.role_key}</div>
<div className="role-desc">{role.description}</div>
<div className="role-meta">
<span className="data-scope">
<i className="ri-database-line"></i>
{role.data_scope}
</span>
<span className="priority">
<i className="ri-sort-asc"></i>
: {role.priority}
</span>
</div>
</div>
{!role.is_system_role && (
<div className="role-actions">
<button
className="btn-icon"
onClick={(e) => {
e.stopPropagation();
toastService.info("编辑角色功能开发中...");
}}
title="编辑"
>
<i className="ri-edit-line"></i>
</button>
<button
className="btn-icon text-error"
onClick={(e) => {
e.stopPropagation();
if (confirm(`确定要删除角色"${role.role_name}"吗?`)) {
toastService.info("删除角色功能开发中...");
}
}}
title="删除"
>
<i className="ri-delete-bin-line"></i>
</button>
</div>
)}
</div>
))}
</div>
</Card>
{/* 右侧:角色详情和权限设置 */}
<div className="permissions-detail">
{selectedRole ? (
<>
{/* Tab 切换 */}
<Card className="tabs-card">
<div className="tabs-header">
<button
className={`tab-btn ${activeTab === 'permissions' ? 'active' : ''}`}
onClick={() => setActiveTab('permissions')}
>
<i className="ri-shield-check-line"></i>
</button>
<button
className={`tab-btn ${activeTab === 'users' ? 'active' : ''}`}
onClick={() => setActiveTab('users')}
>
<i className="ri-team-line"></i>
({roleUsers.length})
</button>
</div>
<div className="tabs-content">
{/* 路由权限Tab */}
{activeTab === 'permissions' && (
<div className="permissions-tab">
<div className="permissions-header">
<h3> "{selectedRole.role_name}" </h3>
<Button
type="primary"
icon="ri-save-line"
onClick={handleSavePermissions}
>
</Button>
</div>
<div className="routes-tree">
{renderRouteTree(routes)}
</div>
<div className="permissions-summary">
<i className="ri-information-line"></i>
<strong>{selectedRouteIds.length}</strong>
</div>
</div>
)}
{/* 用户列表Tab */}
{activeTab === 'users' && (
<div className="users-tab">
<div className="users-header">
<h3> "{selectedRole.role_name}" </h3>
<Button
type="primary"
icon="ri-user-add-line"
onClick={() => {
toastService.info("分配用户功能开发中...");
}}
>
</Button>
</div>
<div className="users-list">
{roleUsers.length > 0 ? (
roleUsers.map(user => (
<div key={user.id} className="user-card">
<div className="user-avatar">
<i className="ri-user-line"></i>
</div>
<div className="user-info">
<div className="user-name">
{user.nick_name}
{user.is_leader && (
<span className="leader-badge"></span>
)}
</div>
<div className="user-username">@{user.username}</div>
<div className="user-org">{user.ou_name}</div>
<div className="user-contact">
{user.phone_number && (
<span>
<i className="ri-phone-line"></i>
{user.phone_number}
</span>
)}
{user.email && (
<span>
<i className="ri-mail-line"></i>
{user.email}
</span>
)}
</div>
</div>
<div className="user-actions">
<button
className="btn-icon text-error"
onClick={() => {
if (confirm(`确定要移除用户"${user.nick_name}"的该角色吗?`)) {
toastService.info("移除角色功能开发中...");
}
}}
title="移除角色"
>
<i className="ri-user-unfollow-line"></i>
</button>
</div>
</div>
))
) : (
<div className="empty-state">
<i className="ri-user-line"></i>
<p></p>
</div>
)}
</div>
</div>
)}
</div>
</Card>
</>
) : (
<Card>
<div className="empty-state">
<i className="ri-shield-line"></i>
<p></p>
</div>
</Card>
)}
</div>
</div>
</div>
);
}
+139 -141
View File
@@ -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;
}
};
/**
+1 -1
View File
@@ -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样式,使用视觉上更美观的自定义复选框样式 */
+552
View File
@@ -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;
}
}