fix: 1. 继续对齐交叉评查的接口,完善创建交叉评查的逻辑 和 相关组件的渲染布局。
2. 文档的基本信息修改改用接口。 3. 重新完善角色权限管理的页面逻辑。 4.将评查点列表中的返回逻辑改用浏览器的记忆返回。
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { postgrestGet, postgrestPut } from "../postgrest-client";
|
||||
import axios from 'axios';
|
||||
import { API_BASE_URL } from '../../config/api-config';
|
||||
|
||||
/**
|
||||
* 从不同格式的 API 响应中提取数据
|
||||
@@ -83,32 +84,50 @@ async function safeGetJWT(jwtToken?: string): Promise<string> {
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前登录用户是否是发起人
|
||||
* 检查用户是否有权确认完成文档评查
|
||||
*
|
||||
* 🔥 接口文档: auth_doc/交叉评查接口文档.md 接口11
|
||||
* 📍 API地址: GET /api/v2/cross_review/tasks/{task_id}/can-confirm
|
||||
*
|
||||
* @param taskId 任务ID
|
||||
* @param userId 用户ID
|
||||
* @returns 是否是发起人
|
||||
* @param frontendJWT JWT token
|
||||
* @returns 是否有权确认完成
|
||||
*/
|
||||
export async function findIsProposer(taskId: string | number, userId: number | undefined, frontendJWT?: string): Promise<boolean> {
|
||||
// 通过postgrest的get请求去cross_examination_tasks表中进行查找assignee_id是否等于userId
|
||||
const response = await postgrestGet(`/api/postgrest/proxy/cross_examination_tasks`, {
|
||||
select: 'assigner_id',
|
||||
filter: {
|
||||
id: `eq.${taskId}`
|
||||
},
|
||||
token: frontendJWT
|
||||
});
|
||||
if (response.error) {
|
||||
console.error('获取任务数据失败:', response.error);
|
||||
try {
|
||||
if (!taskId) {
|
||||
console.error('任务ID不能为空');
|
||||
return false;
|
||||
}
|
||||
|
||||
// 调用新的接口检查用户是否有权确认完成
|
||||
// GET /api/v2/cross_review/tasks/{task_id}/can-confirm
|
||||
const response = await axios.get(
|
||||
`${API_BASE_URL}/api/v2/cross_review/tasks/${taskId}/can-confirm`,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${frontendJWT}`
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const data = response.data;
|
||||
console.log('[findIsProposer] 检查权限响应:', data);
|
||||
|
||||
// 返回 can_confirm 字段,表示是否有权确认完成
|
||||
// 有权限的用户:任务创建者(assigner_id) 或 主要负责人(principal_user_ids)
|
||||
return data?.can_confirm === true;
|
||||
} catch (error) {
|
||||
console.error('[findIsProposer] 检查权限失败:', error);
|
||||
|
||||
// 正确处理 axios 错误响应
|
||||
if (axios.isAxiosError(error) && error.response?.data) {
|
||||
console.error('[findIsProposer] 错误详情:', error.response.data);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
const data = extractApiData<{assigner_id: number}[]>(response.data);
|
||||
// console.log('data', data, userId);
|
||||
|
||||
if (data && data.length > 0) {
|
||||
return data[0].assigner_id.toString() === userId?.toString();
|
||||
}
|
||||
return false;
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -180,8 +199,6 @@ export async function submitCrossCheckingOpinion(
|
||||
* @param jwtToken JWT token
|
||||
* @returns 意见列表和总数
|
||||
*/
|
||||
import { API_BASE_URL } from '../../config/api-config';
|
||||
|
||||
export async function getCrossCheckingOpinions(
|
||||
documentId: string | number,
|
||||
page: number = 1,
|
||||
|
||||
@@ -210,6 +210,7 @@ export async function uploadCrossCheckingDocument(
|
||||
* @param docType 文档类型(如 XZCF、XZXK)
|
||||
* @param taskType 任务类型(如 市局间交叉评查、区局间交叉评查)
|
||||
* @param token JWT Token
|
||||
* @param principalUserIds 负责人ID数组(包含主要负责人和额外负责人)
|
||||
*/
|
||||
export async function batchUploadAndAssignCrossCheckingFiles(
|
||||
files: CrossCheckingUploadedFile[],
|
||||
@@ -222,17 +223,18 @@ export async function batchUploadAndAssignCrossCheckingFiles(
|
||||
taskName: string,
|
||||
docType: string,
|
||||
taskType: string = '市局间交叉评查',
|
||||
token: string | null = null
|
||||
token: string | null = null,
|
||||
principalUserIds: number[] = []
|
||||
): Promise<{
|
||||
successes: Array<{file: CrossCheckingUploadedFile; result: Record<string, unknown>}>;
|
||||
failures: Array<{file: CrossCheckingUploadedFile; error: string}>;
|
||||
}> {
|
||||
const successes: Array<{file: CrossCheckingUploadedFile; result: Record<string, unknown>}> = [];
|
||||
const failures: Array<{file: CrossCheckingUploadedFile; error: string}> = [];
|
||||
const uploadEndpoint = '/cross_review/documents/upload_and_assign';
|
||||
const uploadUrl = UPLOAD_URL + uploadEndpoint;
|
||||
const uploadEndpoint = '/admin/v2/documents/cross_review/documents/upload_and_assign';
|
||||
const uploadUrl = API_BASE_URL + uploadEndpoint;
|
||||
|
||||
// console.log('[批量上传] 任务类型:', taskType, '文档类型:', docType);
|
||||
// console.log('[批量上传] 任务类型:', taskType, '文档类型:', docType, '负责人ID:', leaderId);
|
||||
|
||||
|
||||
for (const fileInfo of files) {
|
||||
@@ -254,6 +256,11 @@ export async function batchUploadAndAssignCrossCheckingFiles(
|
||||
formData.append('upload_info', JSON.stringify(uploadInfo));
|
||||
formData.append('assign_user_ids', JSON.stringify(assignUserIds));
|
||||
|
||||
// 添加负责人ID数组(包含主要负责人和额外负责人)
|
||||
if (principalUserIds.length > 0) {
|
||||
formData.append('principal_user_ids', JSON.stringify(principalUserIds));
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
|
||||
@@ -107,9 +107,9 @@ export function mapUIToReviewStatus(status: string): number {
|
||||
}
|
||||
|
||||
|
||||
|
||||
// TODO: 是否需要出一个公共的接口,文件上传/文档列表 都需要用到:点击查看/开始审核 更新这个文件的审核状态(只有待审核的状态才会进行更新调用)
|
||||
/**
|
||||
* 更新文件的审核状态
|
||||
* 更新文件的审核状态
|
||||
* @param id 文件ID
|
||||
* @param auditStatus 审核状态
|
||||
* @param userId 用户ID
|
||||
@@ -144,7 +144,7 @@ export async function updateDocumentAuditStatus(id: string, auditStatus: number,
|
||||
}
|
||||
);
|
||||
|
||||
console.log('📝 [updateDocumentAuditStatus] postgrestPut响应:', response);
|
||||
// console.log('📝 [updateDocumentAuditStatus] postgrestPut响应:', response);
|
||||
|
||||
if (response.error) {
|
||||
console.warn('⚠️ [updateDocumentAuditStatus] postgrestPut返回错误,但操作可能已成功:', response.error);
|
||||
|
||||
@@ -239,11 +239,11 @@ export async function getRulesList(params: RulesQueryParams): Promise<{data: Rul
|
||||
}
|
||||
|
||||
// 🔑 添加地区过滤
|
||||
if (user_role === 'provincial_admin') {
|
||||
queryParams.append('area', '省级');
|
||||
} else if (area) {
|
||||
queryParams.append('area', area);
|
||||
}
|
||||
// if (user_role === 'provincial_admin') {
|
||||
// queryParams.append('area', '省级');
|
||||
// } else if (area) {
|
||||
// queryParams.append('area', area);
|
||||
// }
|
||||
|
||||
// 添加关键词搜索(后端会同时搜索 name 和 code)
|
||||
if (keyword) {
|
||||
@@ -251,6 +251,8 @@ export async function getRulesList(params: RulesQueryParams): Promise<{data: Rul
|
||||
queryParams.append('code', keyword);
|
||||
}
|
||||
|
||||
// console.log('🔍 [getRulesList] 查询参数:', queryParams.toString());
|
||||
|
||||
// 调用 FastAPI 接口
|
||||
const response = await apiRequest<{
|
||||
data: EvaluationPointData[];
|
||||
@@ -275,7 +277,7 @@ export async function getRulesList(params: RulesQueryParams): Promise<{data: Rul
|
||||
return { error: '接口返回数据格式不正确', status: 500 };
|
||||
}
|
||||
|
||||
console.log('✅ [getRulesList] 成功获取评查点列表,共', response.data.total, '条');
|
||||
// console.log('✅ [getRulesList] 成功获取评查点列表,共', response.data.total, '条');
|
||||
|
||||
// 🆕 直接映射后端返回的数据到前端 Rule 模型
|
||||
// 后端已包含 ruleType、groupName、groupId 字段,无需额外处理
|
||||
@@ -308,9 +310,11 @@ export async function getRulesList(params: RulesQueryParams): Promise<{data: Rul
|
||||
description: point.description || '',
|
||||
isActive: point.is_enabled,
|
||||
createdAt: formatDate(point.created_at || ''),
|
||||
updatedAt: formatDate(point.updated_at || '')
|
||||
updatedAt: formatDate(point.updated_at || ''),
|
||||
area: point.area || ''
|
||||
};
|
||||
});
|
||||
console.log('✅ [getRulesList] 成功映射评查点列表数据', response.data.data[0]);
|
||||
|
||||
return {
|
||||
data: {
|
||||
@@ -950,9 +954,8 @@ export async function batchUpdateRuleStatus(
|
||||
// 逐个验证并更新
|
||||
for (const id of ids) {
|
||||
try {
|
||||
|
||||
// 执行更新
|
||||
const updateResult = await updateRule(id, { isActive: is_enabled }, token);
|
||||
// 执行更新 - 使用 updateEvaluationPoint,只传入 is_enabled 字段
|
||||
const updateResult = await updateEvaluationPoint(id, { is_enabled }, token);
|
||||
if (updateResult.error) {
|
||||
failedIds.push(id);
|
||||
errors.push({ id, error: updateResult.error });
|
||||
|
||||
+46
-28
@@ -431,9 +431,17 @@ export async function getDocumentTypesByIds(ids: number[], frontendJWT?: string)
|
||||
|
||||
/**
|
||||
* 更新文档信息
|
||||
*
|
||||
* 使用 PATCH 方法调用 /api/postgrest/proxy/documents 接口
|
||||
* 后端会自动注入 user_id 过滤条件,确保用户只能更新自己的文档
|
||||
*
|
||||
* @param id 文档ID
|
||||
* @param document 部分文档数据
|
||||
* @param document 部分文档数据(可更新字段:document_number, audit_status, is_test_document, remark)
|
||||
* @param userId 用户ID(用于权限验证)
|
||||
* @param frontendJWT JWT Token(可选,如不传则使用 localStorage 中的 access_token)
|
||||
* @returns 更新结果
|
||||
*
|
||||
* @see auth_doc/document_update_api.md 接口文档
|
||||
*/
|
||||
export async function updateDocument(id: string, document: Partial<DocumentUI> & { remark?: string }, userId: string, frontendJWT?: string): Promise<{
|
||||
data?: DocumentUI;
|
||||
@@ -444,57 +452,67 @@ export async function updateDocument(id: string, document: Partial<DocumentUI> &
|
||||
if (!id) {
|
||||
return { error: '文档ID不能为空', status: 400 };
|
||||
}
|
||||
|
||||
|
||||
if (!userId) {
|
||||
return { error: '用户身份验证失败', status: 401 };
|
||||
}
|
||||
|
||||
|
||||
// 准备API数据 - 将UI数据转换为API格式
|
||||
// 根据文档,可更新字段:document_number, audit_status, is_test_document, remark
|
||||
const apiDocument: Partial<Document> = {};
|
||||
|
||||
|
||||
if (document.documentNumber !== undefined) {
|
||||
apiDocument.document_number = document.documentNumber;
|
||||
}
|
||||
|
||||
// if (document.type !== undefined) {
|
||||
// apiDocument.type_id = parseInt(document.type);
|
||||
// }
|
||||
|
||||
|
||||
if (document.auditStatus !== undefined) {
|
||||
apiDocument.audit_status = document.auditStatus;
|
||||
}
|
||||
|
||||
|
||||
if (document.isTest !== undefined) {
|
||||
apiDocument.is_test_document = document.isTest;
|
||||
}
|
||||
|
||||
|
||||
if (document.remark !== undefined) {
|
||||
apiDocument.remark = document.remark;
|
||||
}
|
||||
|
||||
// console.log('更新文档API数据:', apiDocument);
|
||||
|
||||
const response = await postgrestPut<Document, Partial<Document>>(
|
||||
'/api/postgrest/proxy/documents',
|
||||
apiDocument,
|
||||
{
|
||||
id: parseInt(id),
|
||||
user_id: parseInt(userId) // 确保只能更新自己的文档
|
||||
},
|
||||
frontendJWT
|
||||
|
||||
// console.log('📤 [updateDocument] 更新文档API数据:', apiDocument);
|
||||
|
||||
// 使用 axios-client 的 apiRequest 方法(支持自定义 headers)
|
||||
// 接口路径: /api/postgrest/proxy/documents?id=eq.{id}
|
||||
// 后端会自动注入 user_id 过滤条件(根据JWT中的用户信息)
|
||||
const { apiRequest } = await import('../axios-client');
|
||||
const response = await apiRequest<Document[]>(
|
||||
`/api/postgrest/proxy/documents?id=eq.${id}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
data: apiDocument,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(frontendJWT ? { 'Authorization': `Bearer ${frontendJWT}` } : {})
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
if (response.error) {
|
||||
console.error('更新文档API错误:', response.error);
|
||||
console.error('❌ [updateDocument] 更新文档API错误:', response.error);
|
||||
return { error: response.error, status: response.status };
|
||||
}
|
||||
|
||||
// 获取更新后的完整文档数据
|
||||
|
||||
// 检查返回数据
|
||||
// 成功时返回更新后的文档数组,空数组表示文档不存在或无权访问
|
||||
const responseData = response.data;
|
||||
if (!responseData || (Array.isArray(responseData) && responseData.length === 0)) {
|
||||
return { error: '文档不存在或无权访问', status: 404 };
|
||||
}
|
||||
|
||||
// 获取更新后的完整文档数据(包含关联的文档类型信息)
|
||||
const updatedResponse = await getDocument(id, userId, frontendJWT);
|
||||
|
||||
|
||||
return updatedResponse;
|
||||
} catch (error) {
|
||||
console.error('更新文档信息失败:', error);
|
||||
console.error('❌ [updateDocument] 更新文档信息失败:', error);
|
||||
return {
|
||||
error: error instanceof Error ? error.message : '更新文档信息失败',
|
||||
status: 500
|
||||
|
||||
@@ -3,6 +3,20 @@
|
||||
* 用于角色、路由权限、用户角色的管理
|
||||
*/
|
||||
|
||||
import { get, post, put, del } from '~/api/axios-client';
|
||||
|
||||
// ==================== 类型定义 ====================
|
||||
|
||||
/**
|
||||
* API 响应类型
|
||||
*/
|
||||
interface ApiResponse<T> {
|
||||
data?: T;
|
||||
error?: string;
|
||||
status?: number;
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
// ==================== 常量定义 ====================
|
||||
|
||||
/**
|
||||
@@ -36,6 +50,8 @@ function handleApiResponse<T>(response: ApiResponse<any>): T {
|
||||
/**
|
||||
* API权限信息(关联到路由的权限)
|
||||
* v3.0新增:每个路由可以关联多个API操作权限
|
||||
* v3.6新增:支持通用权限(related_routes 字段)
|
||||
* v3.7新增:is_shared 字段由后端直接返回
|
||||
*/
|
||||
export interface ApiPermission {
|
||||
id: number;
|
||||
@@ -43,6 +59,9 @@ export interface ApiPermission {
|
||||
display_name: string; // 显示名称,如 "创建评查点分组"
|
||||
api_method: string; // HTTP方法:GET | POST | PUT | DELETE
|
||||
api_path: string; // API路径,如 "/api/v3/evaluation-point-groups"
|
||||
route_id?: number | null; // v3.6: 关联的路由ID(独立权限使用)
|
||||
related_routes?: number[] | null; // v3.6: 关联的多个路由ID(通用权限使用)
|
||||
is_shared?: boolean; // v3.7: 是否为通用权限(后端返回)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -167,10 +186,6 @@ export async function getRoles(params?: {
|
||||
include_system?: boolean;
|
||||
}): Promise<RoleInfo[]> {
|
||||
try {
|
||||
// 导入 axios-client 的 get 函数
|
||||
const { get } = await import('~/api/axios-client');
|
||||
|
||||
|
||||
// 使用 axios-client 的 get 函数调用真实后端API
|
||||
const response = await get<any>(`/api/v3/rbac/roles`, params || {});
|
||||
|
||||
@@ -242,9 +257,6 @@ export async function getRoleDetail(roleId: number): Promise<RoleInfo | null> {
|
||||
*/
|
||||
export async function getRoutes(): Promise<RouteInfo[]> {
|
||||
try {
|
||||
const { get } = await import('~/api/axios-client');
|
||||
|
||||
|
||||
// 调用后端API获取当前用户的路由(provincial_admin应该有所有路由权限)
|
||||
const response = await get<any>('/rbac/user/routes');
|
||||
|
||||
@@ -278,8 +290,6 @@ export async function getRoutes(): Promise<RouteInfo[]> {
|
||||
children: route.children ? route.children.map(mapRouteData) : undefined
|
||||
});
|
||||
|
||||
console.log('获取当前用户的路由', routes.map(mapRouteData) )
|
||||
|
||||
return routes.map(mapRouteData);
|
||||
} catch (error) {
|
||||
console.error('❌ [getRoutes] 获取路由数据失败:', error);
|
||||
@@ -288,15 +298,69 @@ export async function getRoutes(): Promise<RouteInfo[]> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v3.7: 获取所有路由列表(管理员接口)
|
||||
* 使用 /api/v3/routes 接口获取所有路由
|
||||
* @param format 返回格式(tree/flat)
|
||||
* @param includeHidden 是否包含隐藏路由
|
||||
*/
|
||||
export async function getAllRoutes(
|
||||
format: 'tree' | 'flat' = 'tree',
|
||||
includeHidden = false
|
||||
): Promise<RouteInfo[]> {
|
||||
try {
|
||||
const response = await get<any>('/api/v3/routes', {
|
||||
format,
|
||||
include_hidden: includeHidden
|
||||
});
|
||||
|
||||
if (response.error) {
|
||||
console.error('❌ [getAllRoutes] API调用失败:', response.error);
|
||||
throw new Error(response.error);
|
||||
}
|
||||
|
||||
// 后端响应格式: 直接返回路由数组(树形或平铺)
|
||||
let routes: any[] = [];
|
||||
if (response.data) {
|
||||
// 如果是包装格式 { code, message, data: [...] }
|
||||
if (response.data.code === 200 && Array.isArray(response.data.data)) {
|
||||
routes = response.data.data;
|
||||
}
|
||||
// 如果直接是数组
|
||||
else if (Array.isArray(response.data)) {
|
||||
routes = response.data;
|
||||
}
|
||||
}
|
||||
|
||||
// 将后端数据转换为前端RouteInfo格式
|
||||
const mapRouteData = (route: any): RouteInfo => ({
|
||||
id: route.id,
|
||||
route_path: route.route_path,
|
||||
route_name: route.route_name,
|
||||
route_title: route.route_title,
|
||||
icon: route.icon || '',
|
||||
sort_order: route.sort_order || 0,
|
||||
is_hidden: route.is_hidden || false,
|
||||
is_cache: route.is_cache !== false,
|
||||
status: route.status || 1,
|
||||
parent_id: route.parent_id || null,
|
||||
component: route.component,
|
||||
children: route.children ? route.children.map(mapRouteData) : undefined
|
||||
});
|
||||
|
||||
return routes.map(mapRouteData);
|
||||
} catch (error) {
|
||||
console.error('❌ [getAllRoutes] 获取路由数据失败:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定角色的路由权限
|
||||
* @param roleId 角色ID
|
||||
*/
|
||||
export async function getRoleRoutePermissions(roleId: number): Promise<RoleRoutePermission[]> {
|
||||
try {
|
||||
// 导入 axios-client 的 get 函数
|
||||
const { get } = await import('~/api/axios-client');
|
||||
|
||||
// 使用 axios-client 的 get 函数调用真实后端API
|
||||
const response = await get<any>(`/rbac/roles/${roleId}/routes`);
|
||||
|
||||
@@ -340,8 +404,6 @@ export async function getRoleRoutesWithPermissions(roleId: number): Promise<{
|
||||
selectedPermissionIds: number[];
|
||||
}> {
|
||||
try {
|
||||
const { get } = await import('~/api/axios-client');
|
||||
|
||||
const response = await get<any>(`/rbac/roles/${roleId}/routes`);
|
||||
|
||||
if (response.error) {
|
||||
@@ -444,8 +506,6 @@ export async function saveRoleApiPermissions(
|
||||
permissionIds: number[]
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const { post } = await import('~/api/axios-client');
|
||||
|
||||
// 构建权限配置
|
||||
const permissions = permissionIds.map(id => ({
|
||||
permission_id: id,
|
||||
@@ -492,10 +552,6 @@ export async function updateRoleRoutePermissions(
|
||||
routeIds: number[]
|
||||
): Promise<{ success: boolean; message: string; code?: number }> {
|
||||
try {
|
||||
// 导入 axios-client 的 put 函数
|
||||
const { put } = await import('~/api/axios-client');
|
||||
|
||||
|
||||
// 使用 axios-client 的 put 函数调用真实后端API
|
||||
const response = await put<any>(`/rbac/roles/${roleId}/routes`, {
|
||||
route_ids: routeIds,
|
||||
@@ -563,7 +619,6 @@ export async function getRoleUsers(
|
||||
): Promise<UserInfo[]> {
|
||||
try {
|
||||
// 导入 axios-client 的 get 函数
|
||||
const { get } = await import('~/api/axios-client');
|
||||
|
||||
// 构建查询参数对象
|
||||
const queryParams: Record<string, any> = {};
|
||||
@@ -618,7 +673,6 @@ export async function getAllUsers(params?: {
|
||||
}): Promise<UserInfo[]> {
|
||||
try {
|
||||
// 导入 axios-client 的 get 函数
|
||||
const { get } = await import('~/api/axios-client');
|
||||
|
||||
// 构建查询参数对象
|
||||
const queryParams: Record<string, any> = {};
|
||||
@@ -683,7 +737,6 @@ export async function assignUserRoles(
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
// 导入 axios-client 的 post 函数
|
||||
const { post } = await import('~/api/axios-client');
|
||||
|
||||
// 使用 axios-client 的 post 函数调用真实后端API
|
||||
const response = await post<any>(`/api/v3/rbac/users/${userId}/roles`, {
|
||||
@@ -729,7 +782,6 @@ export async function revokeUserRole(
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
// 导入 axios-client 的 del 函数
|
||||
const { del } = await import('~/api/axios-client');
|
||||
|
||||
// 使用 axios-client 的 del 函数调用真实后端API
|
||||
const response = await del<any>(`/api/v3/rbac/users/${userId}/roles/${roleId}`);
|
||||
@@ -763,7 +815,6 @@ export async function createRole(
|
||||
): Promise<{ success: boolean; message: string; data?: RoleInfo }> {
|
||||
try {
|
||||
// 导入 axios-client 的 post 函数
|
||||
const { post } = await import('~/api/axios-client');
|
||||
|
||||
// 使用 axios-client 的 post 函数调用真实后端API
|
||||
const response = await post<any>(`/api/v3/rbac/roles`, {
|
||||
@@ -817,7 +868,6 @@ export async function updateRole(
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
// 导入 axios-client 的 put 函数
|
||||
const { put } = await import('~/api/axios-client');
|
||||
|
||||
const updatePayload: any = {};
|
||||
|
||||
@@ -854,7 +904,6 @@ export async function deleteRole(
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
// 导入 axios-client 的 del 函数
|
||||
const { del } = await import('~/api/axios-client');
|
||||
|
||||
const url = `/api/v3/rbac/roles/${roleId}${force ? '?force=true' : ''}`;
|
||||
|
||||
@@ -1011,7 +1060,6 @@ export async function deletePermission(
|
||||
*/
|
||||
export async function getRolePermissions(roleId: number): Promise<RolePermissionDetail[]> {
|
||||
try {
|
||||
const { get } = await import('~/api/axios-client');
|
||||
|
||||
// v3.4: 使用文档规范的API路径(查询参数方式)
|
||||
const response = await get<any>('/api/v3/rbac/role-permissions', {
|
||||
@@ -1143,7 +1191,6 @@ export async function revokeRolePermission(
|
||||
*/
|
||||
export async function getUserRoles(userId: number): Promise<RoleInfo[]> {
|
||||
try {
|
||||
const { get } = await import('~/api/axios-client');
|
||||
|
||||
const response = await get<any>(`/api/v3/rbac/users/${userId}/roles`);
|
||||
|
||||
@@ -1179,3 +1226,85 @@ export async function getUserRoles(userId: number): Promise<RoleInfo[]> {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v3.7: 获取指定路由的所有权限(包含通用权限)
|
||||
* 使用专用 API 接口 /api/v3/routes/{route_id}/permissions
|
||||
* @param routeId 路由ID
|
||||
* @returns 权限列表(包含独立权限和通用权限,带 is_shared 标识)
|
||||
*/
|
||||
export async function getRoutePermissions(routeId: number): Promise<ApiPermission[]> {
|
||||
try {
|
||||
|
||||
// v3.7: 使用专用 API 接口获取路由权限(包含通用权限)
|
||||
const response = await get<any>(`/api/v3/routes/${routeId}/permissions`);
|
||||
|
||||
if (response.error) {
|
||||
console.error('❌ [getRoutePermissions] API调用失败:', response.error);
|
||||
return [];
|
||||
}
|
||||
|
||||
// 处理响应数据
|
||||
// 后端响应格式: { route_id, route_path, route_title, permissions: [...] }
|
||||
let permissions: any[] = [];
|
||||
if (response.data) {
|
||||
// 如果是包装格式 { code, message, data: { permissions: [...] } }
|
||||
if (response.data.code === 200 && response.data.data?.permissions) {
|
||||
permissions = response.data.data.permissions;
|
||||
}
|
||||
// 如果直接返回 { route_id, permissions: [...] }
|
||||
else if (response.data.permissions) {
|
||||
permissions = response.data.permissions;
|
||||
}
|
||||
// 如果直接是数组
|
||||
else if (Array.isArray(response.data)) {
|
||||
permissions = response.data;
|
||||
}
|
||||
}
|
||||
|
||||
// 转换为 ApiPermission 格式,保留 is_shared 字段
|
||||
return permissions.map(p => ({
|
||||
id: p.id,
|
||||
permission_key: p.permission_key,
|
||||
display_name: p.display_name,
|
||||
api_method: p.api_method || '',
|
||||
api_path: p.api_path || '',
|
||||
route_id: p.route_id,
|
||||
related_routes: p.related_routes,
|
||||
is_shared: p.is_shared || false // v3.7: 使用后端返回的 is_shared 字段
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('❌ [getRoutePermissions] 获取路由权限失败:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v3.7: 判断权限是否为通用权限
|
||||
* 优先使用后端返回的 is_shared 字段,降级时使用 related_routes 判断
|
||||
* @param permission 权限对象
|
||||
* @returns 是否为通用权限
|
||||
*/
|
||||
export function isSharedPermission(permission: ApiPermission): boolean {
|
||||
// v3.7: 优先使用后端返回的 is_shared 字段
|
||||
if (permission.is_shared !== undefined) {
|
||||
return permission.is_shared === true;
|
||||
}
|
||||
// 降级:通过 related_routes 字段判断
|
||||
return permission.related_routes !== null &&
|
||||
permission.related_routes !== undefined &&
|
||||
Array.isArray(permission.related_routes) &&
|
||||
permission.related_routes.length > 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* v3.6: 获取通用权限关联的所有路由ID
|
||||
* @param permission 权限对象
|
||||
* @returns 路由ID数组
|
||||
*/
|
||||
export function getRelatedRouteIds(permission: ApiPermission): number[] {
|
||||
if (isSharedPermission(permission)) {
|
||||
return permission.related_routes || [];
|
||||
}
|
||||
return permission.route_id ? [permission.route_id] : [];
|
||||
}
|
||||
|
||||
@@ -1,17 +1,34 @@
|
||||
interface PageHeaderProps {
|
||||
title: string;
|
||||
onSave?: () => void;
|
||||
onBack?: () => void;
|
||||
showSaveButton?: boolean;
|
||||
showBackButton?: boolean;
|
||||
}
|
||||
|
||||
export function PageHeader({ title, onSave, showSaveButton = true }: PageHeaderProps) {
|
||||
export function PageHeader({
|
||||
title,
|
||||
onSave,
|
||||
onBack,
|
||||
showSaveButton = true,
|
||||
showBackButton = true
|
||||
}: PageHeaderProps) {
|
||||
return (
|
||||
<div className="flex justify-between items-center pb-2 mb-4 border-b border-gray-200">
|
||||
<h1 className="text-xl font-medium text-gray-800">{title}</h1>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
{showBackButton && (
|
||||
<button
|
||||
type="button"
|
||||
className="ant-btn ant-btn-default"
|
||||
onClick={onBack}
|
||||
>
|
||||
<i className="ri-arrow-left-line mr-1"></i> 返回
|
||||
</button>
|
||||
)}
|
||||
{showSaveButton && (
|
||||
<button
|
||||
type="button"
|
||||
<button
|
||||
type="button"
|
||||
className="ant-btn ant-btn-primary"
|
||||
onClick={onSave}
|
||||
>
|
||||
|
||||
@@ -12,6 +12,9 @@ type MultiCascaderProps = {
|
||||
value?: string[];
|
||||
onChange?: (value: string[]) => void;
|
||||
placeholder?: string;
|
||||
maxHeight?: number; // 下拉框最大高度,默认300px
|
||||
searchable?: boolean; // 是否显示搜索框,默认false
|
||||
searchPlaceholder?: string; // 搜索框占位符
|
||||
};
|
||||
|
||||
// 获取所有叶子节点的值
|
||||
@@ -42,17 +45,87 @@ const isSomeChildrenChecked = (option: Option, selected: string[]): boolean => {
|
||||
return leafValues.some(value => selected.includes(value));
|
||||
};
|
||||
|
||||
const MultiCascader: React.FC<MultiCascaderProps> = ({
|
||||
options,
|
||||
defaultValue = [],
|
||||
// 递归过滤选项,保留匹配项及其父级
|
||||
const filterOptions = (options: Option[], keyword: string): Option[] => {
|
||||
if (!keyword.trim()) return options;
|
||||
|
||||
const lowerKeyword = keyword.toLowerCase();
|
||||
|
||||
return options.reduce<Option[]>((acc, option) => {
|
||||
// 检查当前节点是否匹配
|
||||
const isCurrentMatch = option.label.toLowerCase().includes(lowerKeyword);
|
||||
|
||||
// 递归检查子节点
|
||||
const filteredChildren = option.children ? filterOptions(option.children, keyword) : [];
|
||||
|
||||
// 如果当前节点匹配,或者有匹配的子节点,则保留
|
||||
if (isCurrentMatch || filteredChildren.length > 0) {
|
||||
acc.push({
|
||||
...option,
|
||||
children: isCurrentMatch
|
||||
? option.children // 如果父节点匹配,保留所有子节点
|
||||
: filteredChildren.length > 0
|
||||
? filteredChildren // 如果只是子节点匹配,只保留匹配的子节点
|
||||
: option.children
|
||||
});
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, []);
|
||||
};
|
||||
|
||||
// 获取过滤后需要展开的所有父节点
|
||||
const getExpandedKeysForFilter = (options: Option[], keyword: string): Set<string> => {
|
||||
const keys = new Set<string>();
|
||||
if (!keyword.trim()) return keys;
|
||||
|
||||
const lowerKeyword = keyword.toLowerCase();
|
||||
|
||||
const traverse = (opts: Option[], parentKeys: string[]) => {
|
||||
for (const opt of opts) {
|
||||
const isMatch = opt.label.toLowerCase().includes(lowerKeyword);
|
||||
const hasChildren = opt.children && opt.children.length > 0;
|
||||
|
||||
if (hasChildren) {
|
||||
// 递归检查子节点
|
||||
const childMatches = traverse(opt.children!, [...parentKeys, opt.value]);
|
||||
|
||||
// 如果子节点有匹配,展开当前节点
|
||||
if (childMatches) {
|
||||
keys.add(opt.value);
|
||||
parentKeys.forEach(k => keys.add(k));
|
||||
}
|
||||
}
|
||||
|
||||
// 如果当前节点匹配且有父节点,展开父节点
|
||||
if (isMatch && parentKeys.length > 0) {
|
||||
parentKeys.forEach(k => keys.add(k));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
traverse(options, []);
|
||||
return keys;
|
||||
};
|
||||
|
||||
const MultiCascader: React.FC<MultiCascaderProps> = ({
|
||||
options,
|
||||
defaultValue = [],
|
||||
value,
|
||||
onChange,
|
||||
placeholder = '请选择'
|
||||
onChange,
|
||||
placeholder = '请选择',
|
||||
maxHeight = 300,
|
||||
searchable = false,
|
||||
searchPlaceholder = '搜索...'
|
||||
}) => {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [selected, setSelected] = useState<string[]>(value ?? defaultValue);
|
||||
const [expandedKeys, setExpandedKeys] = useState<Set<string>>(new Set());
|
||||
const [searchKeyword, setSearchKeyword] = useState('');
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 当外部 value 变化时,同步内部状态
|
||||
useEffect(() => {
|
||||
@@ -71,6 +144,27 @@ const MultiCascader: React.FC<MultiCascaderProps> = ({
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
// 搜索关键词变化时自动展开匹配项
|
||||
useEffect(() => {
|
||||
if (searchKeyword.trim()) {
|
||||
const keysToExpand = getExpandedKeysForFilter(options, searchKeyword);
|
||||
setExpandedKeys(keysToExpand);
|
||||
}
|
||||
}, [searchKeyword, options]);
|
||||
|
||||
// 下拉框打开时聚焦搜索框
|
||||
useEffect(() => {
|
||||
if (visible && searchable && searchInputRef.current) {
|
||||
setTimeout(() => searchInputRef.current?.focus(), 100);
|
||||
}
|
||||
if (!visible) {
|
||||
setSearchKeyword(''); // 关闭时清空搜索
|
||||
}
|
||||
}, [visible, searchable]);
|
||||
|
||||
// 过滤后的选项
|
||||
const filteredOptions = searchable ? filterOptions(options, searchKeyword) : options;
|
||||
|
||||
const handleItemCheck = (option: Option, checked: boolean) => {
|
||||
const leafValues = getAllLeafValues(option);
|
||||
let newSelected: string[];
|
||||
@@ -175,9 +269,46 @@ const MultiCascader: React.FC<MultiCascaderProps> = ({
|
||||
</span>
|
||||
</div>
|
||||
{visible && (
|
||||
<div className="absolute z-10 bg-white border border-gray-300 rounded shadow-lg mt-1 w-full max-h-100 overflow-y-auto">
|
||||
<div className="p-2">
|
||||
{options.map(option => renderOption(option))}
|
||||
<div className="absolute z-10 bg-white border border-gray-300 rounded shadow-lg mt-1 w-full">
|
||||
{/* 搜索框 */}
|
||||
{searchable && (
|
||||
<div className="p-2 border-b border-gray-200">
|
||||
<div className="relative">
|
||||
<i className="ri-search-line absolute left-2 top-1/2 -translate-y-1/2 text-gray-400"></i>
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
className="w-full pl-8 pr-3 py-1.5 text-sm border border-gray-300 rounded focus:outline-none focus:ring-1 focus:ring-[var(--color-primary,#00684a)] focus:border-[var(--color-primary,#00684a)]"
|
||||
placeholder={searchPlaceholder}
|
||||
value={searchKeyword}
|
||||
onChange={(e) => setSearchKeyword(e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
{searchKeyword && (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSearchKeyword('');
|
||||
}}
|
||||
>
|
||||
<i className="ri-close-line"></i>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* 选项列表 */}
|
||||
<div className="p-2 overflow-y-auto" style={{ maxHeight: `${maxHeight}px` }}>
|
||||
{filteredOptions.length > 0 ? (
|
||||
filteredOptions.map(option => renderOption(option))
|
||||
) : (
|
||||
<div className="text-center text-gray-400 py-4 text-sm">
|
||||
<i className="ri-search-line text-lg block mb-1"></i>
|
||||
无匹配结果
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
+120
-53
@@ -19,6 +19,10 @@ interface TableProps<T> {
|
||||
emptyText?: React.ReactNode;
|
||||
className?: string;
|
||||
onRow?: (record: T, index: number) => React.HTMLAttributes<HTMLTableRowElement>;
|
||||
scroll?: {
|
||||
x?: number | string;
|
||||
y?: number | string;
|
||||
};
|
||||
}
|
||||
|
||||
export function Table<T extends Record<string, any>>({
|
||||
@@ -30,6 +34,7 @@ export function Table<T extends Record<string, any>>({
|
||||
emptyText = '暂无数据',
|
||||
className = '',
|
||||
onRow,
|
||||
scroll,
|
||||
}: TableProps<T>) {
|
||||
// 防御性检查:确保 dataSource 始终是数组
|
||||
const safeDataSource = dataSource || [];
|
||||
@@ -41,63 +46,125 @@ export function Table<T extends Record<string, any>>({
|
||||
return String(record[rowKey]);
|
||||
};
|
||||
|
||||
// 是否启用固定表头滚动
|
||||
const hasScrollY = scroll?.y !== undefined;
|
||||
|
||||
// 渲染表头
|
||||
const renderHeader = () => (
|
||||
<thead>
|
||||
<tr>
|
||||
{columns.map((column, index) => (
|
||||
<th
|
||||
key={column.key || column.dataIndex?.toString() || index}
|
||||
className={column.className}
|
||||
style={{
|
||||
width: column.width,
|
||||
textAlign: column.align || 'left',
|
||||
}}
|
||||
>
|
||||
{column.title}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
);
|
||||
|
||||
// 渲染表体
|
||||
const renderBody = () => (
|
||||
<tbody>
|
||||
{safeDataSource.length > 0 ? (
|
||||
safeDataSource.map((record, index) => (
|
||||
<tr
|
||||
key={getRowKey(record, index)}
|
||||
{...(onRow ? onRow(record, index) : {})}
|
||||
>
|
||||
{columns.map((column, colIndex) => (
|
||||
<td
|
||||
key={column.key || column.dataIndex?.toString() || colIndex}
|
||||
style={{ textAlign: column.align || 'left' }}
|
||||
>
|
||||
{column.render
|
||||
? column.render(
|
||||
column.dataIndex ? record[column.dataIndex] : undefined,
|
||||
record,
|
||||
index
|
||||
)
|
||||
: column.dataIndex
|
||||
? record[column.dataIndex]
|
||||
: undefined}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={columns.length}
|
||||
className="ant-table-empty py-6 text-center text-gray-500"
|
||||
>
|
||||
{emptyText}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
);
|
||||
|
||||
// 如果有垂直滚动,使用固定表头布局
|
||||
if (hasScrollY) {
|
||||
return (
|
||||
<div className={`ant-table-wrapper ${className} ${loading ? 'ant-table-loading' : ''}`}>
|
||||
<div className="ant-table-container">
|
||||
{/* 固定表头 */}
|
||||
<div className="ant-table-header">
|
||||
<table className={`ant-table ${bordered ? 'ant-table-bordered' : ''}`} style={{ tableLayout: 'fixed' }}>
|
||||
<colgroup>
|
||||
{columns.map((column, index) => (
|
||||
<col key={index} style={{ width: column.width }} />
|
||||
))}
|
||||
</colgroup>
|
||||
{renderHeader()}
|
||||
</table>
|
||||
</div>
|
||||
{/* 可滚动表体 */}
|
||||
<div
|
||||
className="ant-table-body"
|
||||
style={{
|
||||
maxHeight: typeof scroll.y === 'number' ? `${scroll.y}px` : scroll.y,
|
||||
overflowY: 'auto',
|
||||
overflowX: scroll?.x ? 'auto' : 'hidden'
|
||||
}}
|
||||
>
|
||||
<table className={`ant-table ${bordered ? 'ant-table-bordered' : ''}`} style={{ tableLayout: 'fixed' }}>
|
||||
<colgroup>
|
||||
{columns.map((column, index) => (
|
||||
<col key={index} style={{ width: column.width }} />
|
||||
))}
|
||||
</colgroup>
|
||||
{renderBody()}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div className="ant-table-loading-indicator">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-5 h-5 border-2 border-primary border-t-transparent rounded-full animate-spin"></div>
|
||||
<span className="text-gray-600">加载中...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 默认布局(无滚动)
|
||||
return (
|
||||
<div className={`ant-table-wrapper ${className} ${loading ? 'ant-table-loading' : ''}`}>
|
||||
<table className={`ant-table ${bordered ? 'ant-table-bordered' : ''}`}>
|
||||
<thead>
|
||||
<tr>
|
||||
{columns.map((column, index) => (
|
||||
<th
|
||||
key={column.key || column.dataIndex?.toString() || index}
|
||||
className={column.className}
|
||||
style={{
|
||||
width: column.width,
|
||||
textAlign: column.align || 'left',
|
||||
}}
|
||||
>
|
||||
{column.title}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{safeDataSource.length > 0 ? (
|
||||
safeDataSource.map((record, index) => (
|
||||
<tr
|
||||
key={getRowKey(record, index)}
|
||||
{...(onRow ? onRow(record, index) : {})}
|
||||
>
|
||||
{columns.map((column, colIndex) => (
|
||||
<td
|
||||
key={column.key || column.dataIndex?.toString() || colIndex}
|
||||
style={{ textAlign: column.align || 'left' }}
|
||||
>
|
||||
{column.render
|
||||
? column.render(
|
||||
column.dataIndex ? record[column.dataIndex] : undefined,
|
||||
record,
|
||||
index
|
||||
)
|
||||
: column.dataIndex
|
||||
? record[column.dataIndex]
|
||||
: undefined}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={columns.length}
|
||||
className="ant-table-empty py-6 text-center text-gray-500"
|
||||
>
|
||||
{emptyText}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
{renderHeader()}
|
||||
{renderBody()}
|
||||
</table>
|
||||
|
||||
|
||||
{loading && (
|
||||
<div className="ant-table-loading-indicator">
|
||||
<div className="flex items-center space-x-2">
|
||||
|
||||
+23
-17
@@ -46,14 +46,14 @@ const portConfigs: Record<string, Partial<ApiConfig>> = {
|
||||
'51703': {
|
||||
// baseUrl: 'http://172.16.0.56:8073',
|
||||
// documentUrl: 'http://172.16.0.56:8073/docauditai/',
|
||||
// uploadUrl: 'http://172.16.0.56:8073/admin/documents',
|
||||
// uploadUrl: 'http://172.16.0.56:8073/api/v2/documents',
|
||||
|
||||
// collaboraUrl: 'http://172.16.0.81:9980',
|
||||
// appUrl: 'http://172.16.0.34:51703',
|
||||
|
||||
baseUrl: 'http://10.79.97.17:8000',
|
||||
documentUrl: 'http://10.79.97.17:8000/docauditai/',
|
||||
uploadUrl: 'http://10.79.97.17:8000/admin/documents',
|
||||
uploadUrl: 'http://10.79.97.17:8000/api/v2/documents',
|
||||
|
||||
collaboraUrl: 'http://10.79.97.17:9980',
|
||||
appUrl: 'http://10.79.97.17:51703',
|
||||
@@ -66,11 +66,17 @@ const portConfigs: Record<string, Partial<ApiConfig>> = {
|
||||
|
||||
// 云浮
|
||||
'51704': {
|
||||
baseUrl: 'http://10.79.97.17:8001',
|
||||
documentUrl: 'http://10.79.97.17:8001/docauditai/',
|
||||
uploadUrl: 'http://10.79.97.17:8001/admin/documents',
|
||||
collaboraUrl: 'http://10.79.97.17:9980',
|
||||
appUrl: 'http://10.79.97.17:51704',
|
||||
baseUrl: 'http://172.16.0.55:8001',
|
||||
documentUrl: 'http://172.16.0.55:8001/docauditai/',
|
||||
uploadUrl: 'http://172.16.0.55:8001/api/v2/documents',
|
||||
collaboraUrl: 'http://172.16.0.81:9980',
|
||||
appUrl: 'http://172.16.0.34:51704',
|
||||
|
||||
// baseUrl: 'http://10.79.97.17:8001',
|
||||
// documentUrl: 'http://10.79.97.17:8001/docauditai/',
|
||||
// uploadUrl: 'http://10.79.97.17:8001/api/v2/documents',
|
||||
// collaboraUrl: 'http://10.79.97.17:9980',
|
||||
// appUrl: 'http://10.79.97.17:51704',
|
||||
oauth: {
|
||||
redirectUri: 'http://10.79.97.17:51704/callback'
|
||||
}
|
||||
@@ -80,7 +86,7 @@ const portConfigs: Record<string, Partial<ApiConfig>> = {
|
||||
'51705': {
|
||||
baseUrl: 'http://10.79.97.17:8002',
|
||||
documentUrl: 'http://10.79.97.17:8002/docauditai/',
|
||||
uploadUrl: 'http://10.79.97.17:8002/admin/documents',
|
||||
uploadUrl: 'http://10.79.97.17:8002/api/v2/documents',
|
||||
collaboraUrl: 'http://10.79.97.17:9980',
|
||||
appUrl: 'http://10.79.97.17:51705',
|
||||
oauth: {
|
||||
@@ -92,7 +98,7 @@ const portConfigs: Record<string, Partial<ApiConfig>> = {
|
||||
'51706': {
|
||||
baseUrl: 'http://10.79.97.17:8003',
|
||||
documentUrl: 'http://10.79.97.17:8003/docauditai/',
|
||||
uploadUrl: 'http://10.79.97.17:8003/admin/documents',
|
||||
uploadUrl: 'http://10.79.97.17:8003/api/v2/documents',
|
||||
collaboraUrl: 'http://10.79.97.17:9980',
|
||||
appUrl: 'http://10.79.97.17:51706',
|
||||
oauth: {
|
||||
@@ -105,7 +111,7 @@ const portConfigs: Record<string, Partial<ApiConfig>> = {
|
||||
'51707': {
|
||||
baseUrl: 'http://10.79.97.17:8004',
|
||||
documentUrl: 'http://10.79.97.17:8004/docauditai/',
|
||||
uploadUrl: 'http://10.79.97.17:8004/admin/documents',
|
||||
uploadUrl: 'http://10.79.97.17:8004/api/v2/documents',
|
||||
collaboraUrl: 'http://10.79.97.17:9980',
|
||||
appUrl: 'http://10.79.97.17:51707',
|
||||
oauth: {
|
||||
@@ -117,23 +123,23 @@ const portConfigs: Record<string, Partial<ApiConfig>> = {
|
||||
'51708': {
|
||||
baseUrl: 'http://10.79.97.17:8005',
|
||||
documentUrl: 'http://10.79.97.17:8005/docauditai/',
|
||||
uploadUrl: 'http://10.79.97.17:8005/admin/documents',
|
||||
uploadUrl: 'http://10.79.97.17:8005/api/v2/documents',
|
||||
collaboraUrl: 'http://10.79.97.17:9980',
|
||||
appUrl: 'http://10.79.97.17:51708'
|
||||
},
|
||||
};
|
||||
|
||||
// 不同环境的默认配置
|
||||
// 由于合同模板的上传,后续的的uploadUrl都不需要/upload,直接写/admin/documents,由程序自动添加/upload或/upload_contract_template
|
||||
// 由于合同模板的上传,后续的的uploadUrl都不需要/upload,直接写/api/v2/documents,由程序自动添加/upload或/upload_contract_template
|
||||
const configs: Record<string, ApiConfig> = {
|
||||
// 开发环境
|
||||
development: {
|
||||
// baseUrl: 'http://172.16.0.58:8073', // FastAPI后端(包含/dify代理)
|
||||
// documentUrl: 'http://172.16.0.58:8073/docauditai/',
|
||||
// uploadUrl: 'http://172.16.0.58:8073/admin/documents',
|
||||
// uploadUrl: 'http://172.16.0.58:8073/api/v2/documents',
|
||||
baseUrl: 'http://172.16.0.55:8073', // FastAPI后端(包含/dify代理)
|
||||
documentUrl: 'http://172.16.0.55:8073/docauditai/',
|
||||
uploadUrl: 'http://172.16.0.55:8073/admin/documents',
|
||||
uploadUrl: 'http://172.16.0.55:8073/api/v2/documents',
|
||||
|
||||
collaboraUrl: 'http://172.16.0.81:9980',
|
||||
appUrl: 'http://172.16.0.34:5173',
|
||||
@@ -155,7 +161,7 @@ const configs: Record<string, ApiConfig> = {
|
||||
testing: {
|
||||
baseUrl: 'http://172.16.0.55:8073', // FastAPI后端(包含/dify代理)
|
||||
documentUrl: 'http://172.16.0.55:8073/docauditai/',
|
||||
uploadUrl: 'http://172.16.0.55:8073/admin/documents',
|
||||
uploadUrl: 'http://172.16.0.55:8073/api/v2/documents',
|
||||
collaboraUrl: 'http://172.16.0.81:9980',
|
||||
// appUrl: 'http://10.79.97.17:51703',
|
||||
appUrl: 'http://172.16.0.34:5183',
|
||||
@@ -178,7 +184,7 @@ const configs: Record<string, ApiConfig> = {
|
||||
// minio
|
||||
documentUrl: 'http://10.76.244.156:9000/docauditai/',
|
||||
// 文件上传
|
||||
uploadUrl: 'http://10.79.97.17:8000/admin/documents',
|
||||
uploadUrl: 'http://10.79.97.17:8000/api/v2/documents',
|
||||
collaboraUrl: 'http://10.79.97.17:9980',
|
||||
appUrl: 'http://10.79.97.17:51703',
|
||||
oauth: {
|
||||
@@ -201,7 +207,7 @@ const configs: Record<string, ApiConfig> = {
|
||||
staging: {
|
||||
baseUrl: 'http://172.16.0.119:9000/admin', // FastAPI后端(包含/dify代理)
|
||||
documentUrl: 'http://nas.7bm.co:9000/docauditai/',
|
||||
uploadUrl: 'http://172.16.0.119:8000/admin/documents/upload',
|
||||
uploadUrl: 'http://172.16.0.119:8000/api/v2/documents',
|
||||
collaboraUrl: 'http://10.79.97.17:9980',
|
||||
appUrl: 'http://172.16.0.119:3000',
|
||||
oauth: {
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface Rule {
|
||||
checkMethod: 'automatic' | 'manual' | 'mixed';
|
||||
prompt: string;
|
||||
isActive: boolean;
|
||||
area?: string; // 地区
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
@@ -184,6 +184,9 @@ export default function Index() {
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
// 🔑 清除各页面的筛选条件缓存(切换入口模块时重置)
|
||||
sessionStorage.removeItem('rules.searchParams');
|
||||
|
||||
// 🔑 存储到 sessionStorage(用于客户端请求)
|
||||
if (typeIds.length > 0) {
|
||||
sessionStorage.setItem('documentTypeIds', JSON.stringify(typeIds));
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Button } from '~/components/ui/Button';
|
||||
import { Card } from '~/components/ui/Card';
|
||||
import { Tag } from '~/components/ui/Tag';
|
||||
import { DocumentListModal } from '~/components/cross-checking';
|
||||
import { usePermission } from '~/hooks/usePermission';
|
||||
|
||||
import crossCheckingStyles from "~/styles/pages/cross-checking_index.css?url";
|
||||
import { Table } from '~/components/ui/Table';
|
||||
@@ -249,6 +250,11 @@ export default function CrossCheckingIndex() {
|
||||
const navigate = useNavigate();
|
||||
const fetcher = useFetcher();
|
||||
|
||||
// 权限控制
|
||||
const { canCreate, canView } = usePermission();
|
||||
const canCreateTask = canCreate('cross_review');
|
||||
const canViewTask = canView('cross_review');
|
||||
|
||||
// 状态管理
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [hasAutoOpened, setHasAutoOpened] = useState(false); // 标记是否已自动打开模态框
|
||||
@@ -390,11 +396,16 @@ export default function CrossCheckingIndex() {
|
||||
|
||||
// 渲染操作按钮
|
||||
const renderOperation = (task: CrossCheckingTask) => {
|
||||
// 无权限时不显示操作按钮
|
||||
if (!canViewTask) {
|
||||
return <span className="text-gray-400">-</span>;
|
||||
}
|
||||
|
||||
switch (task.status) {
|
||||
case CrossCheckingTaskStatus.PENDING:
|
||||
return (
|
||||
<Button
|
||||
type="primary"
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
className="operation-btn primary"
|
||||
onClick={() => handleViewResult(task.id,task.taskName)}
|
||||
@@ -405,8 +416,8 @@ export default function CrossCheckingIndex() {
|
||||
);
|
||||
case CrossCheckingTaskStatus.IN_PROGRESS:
|
||||
return (
|
||||
<Button
|
||||
type="default"
|
||||
<Button
|
||||
type="default"
|
||||
size="small"
|
||||
className="operation-btn secondary"
|
||||
onClick={() => handleViewResult(task.id,task.taskName)}
|
||||
@@ -417,8 +428,8 @@ export default function CrossCheckingIndex() {
|
||||
);
|
||||
case CrossCheckingTaskStatus.COMPLETED:
|
||||
return (
|
||||
<Button
|
||||
type="default"
|
||||
<Button
|
||||
type="default"
|
||||
size="small"
|
||||
className="operation-btn secondary"
|
||||
onClick={() => handleViewResult(task.id,task.taskName)}
|
||||
@@ -739,9 +750,11 @@ export default function CrossCheckingIndex() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button type="primary" icon="ri-add-line" to="/cross-checking/upload">
|
||||
创建任务
|
||||
</Button>
|
||||
{canCreateTask && (
|
||||
<Button type="primary" icon="ri-add-line" to="/cross-checking/upload">
|
||||
创建任务
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 筛选区域 */}
|
||||
|
||||
@@ -179,7 +179,10 @@ export default function CrossCheckingUpload() {
|
||||
type: 'CITY', // 使用枚举值,默认为市局间交叉评查
|
||||
});
|
||||
// 步骤2状态
|
||||
const [groupChecked, setGroupChecked] = useState<string[]>(userInfo?.user_id ? [`user_${userInfo.user_id}`] : []);
|
||||
// 当前用户ID(用于标识主要负责人,不可取消勾选)
|
||||
const currentUserId = userInfo?.user_id ? `user_${userInfo.user_id}` : null;
|
||||
const [groupChecked, setGroupChecked] = useState<string[]>(currentUserId ? [currentUserId] : []);
|
||||
const [leaderIds, setLeaderIds] = useState<string[]>([]); // 额外的负责人ID数组(不包含当前用户)
|
||||
const [userSelectionState, setUserSelectionState] = useState<UserSelectionState>({
|
||||
treeData: DEFAULT_TREE,
|
||||
loading: false,
|
||||
@@ -192,15 +195,12 @@ export default function CrossCheckingUpload() {
|
||||
const [remark] = useState<string>("");
|
||||
const [isTestDocument] = useState<boolean>(false);
|
||||
|
||||
// 文件管理状态
|
||||
const [singleFiles, setSingleFiles] = useState<CrossCheckingUploadedFile[]>([]);
|
||||
const [multipleFiles, setMultipleFiles] = useState<CrossCheckingUploadedFile[]>([]);
|
||||
const [uploadType, setUploadType] = useState<'none' | 'single' | 'multiple'>('none');
|
||||
// 文件管理状态 - 简化为单文件上传
|
||||
const [uploadedFile, setUploadedFile] = useState<CrossCheckingUploadedFile | null>(null);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
|
||||
// 引用
|
||||
const singleUploadRef = useRef<UploadAreaRef>(null);
|
||||
const multipleUploadRef = useRef<UploadAreaRef>(null);
|
||||
const uploadRef = useRef<UploadAreaRef>(null);
|
||||
|
||||
|
||||
|
||||
@@ -218,147 +218,62 @@ export default function CrossCheckingUpload() {
|
||||
console.log("案卷类型切换为:", selectedType?.name, "ID:", docTypeId);
|
||||
};
|
||||
|
||||
// 清空所有文件
|
||||
// 清空文件
|
||||
const clearAllFiles = () => {
|
||||
setSingleFiles([]);
|
||||
setMultipleFiles([]);
|
||||
setUploadType('none');
|
||||
// 重置文件输入框
|
||||
singleUploadRef.current?.resetFileInput();
|
||||
multipleUploadRef.current?.resetFileInput();
|
||||
setUploadedFile(null);
|
||||
uploadRef.current?.resetFileInput();
|
||||
};
|
||||
|
||||
// 处理单案件文件选择
|
||||
const handleSingleFilesSelected = (files: FileList) => {
|
||||
if (uploadType === 'multiple') {
|
||||
toastService.warning("已选择多案件导入方式,无法选择单案件文件");
|
||||
return;
|
||||
}
|
||||
// 获取文件类型信息
|
||||
const getFileTypeInfo = (file: File) => {
|
||||
const fileName = file.name.toLowerCase();
|
||||
const isPdf = file.type === 'application/pdf' || fileName.endsWith('.pdf');
|
||||
const isDocx = file.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' || fileName.endsWith('.docx');
|
||||
const isZip = file.type === 'application/zip' || file.type === 'application/x-zip-compressed' || fileName.endsWith('.zip');
|
||||
const is7z = file.type === 'application/x-7z-compressed' || fileName.endsWith('.7z');
|
||||
|
||||
const validFiles: CrossCheckingUploadedFile[] = [];
|
||||
let hasInvalidFiles = false;
|
||||
return { isPdf, isDocx, isZip, is7z };
|
||||
};
|
||||
|
||||
Array.from(files).forEach(file => {
|
||||
const isPdf = file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf');
|
||||
const isDocx = file.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' ||
|
||||
file.name.toLowerCase().endsWith('.docx');
|
||||
// 处理文件选择(合并单案件和多案件)
|
||||
const handleFileSelected = (files: FileList) => {
|
||||
if (files.length === 0) return;
|
||||
|
||||
if (isPdf || isDocx) {
|
||||
validFiles.push({
|
||||
id: generateFileId(),
|
||||
file,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
uploadType: 'single'
|
||||
});
|
||||
} else {
|
||||
hasInvalidFiles = true;
|
||||
}
|
||||
});
|
||||
// 只取第一个文件
|
||||
const file = files[0];
|
||||
const { isPdf, isDocx, isZip, is7z } = getFileTypeInfo(file);
|
||||
|
||||
if (hasInvalidFiles) {
|
||||
messageService.error('只能上传PDF或DOCX格式的文件', {
|
||||
// 验证文件类型
|
||||
if (!isPdf && !isDocx && !isZip && !is7z) {
|
||||
messageService.error('只能上传 PDF、DOCX 文件或 ZIP、7Z 压缩包', {
|
||||
title: '文件类型错误',
|
||||
confirmText: '确定',
|
||||
});
|
||||
}
|
||||
|
||||
if (validFiles.length > 0) {
|
||||
setSingleFiles(prev => [...prev, ...validFiles]);
|
||||
setUploadType('single');
|
||||
console.log("选择单案件文件:", validFiles.length, "个");
|
||||
}
|
||||
};
|
||||
|
||||
// 处理多案件文件选择
|
||||
const handleMultipleFilesSelected = (files: FileList) => {
|
||||
if (uploadType === 'single') {
|
||||
toastService.warning("已选择单案件导入方式,无法选择多案件文件");
|
||||
return;
|
||||
}
|
||||
|
||||
const validFiles: CrossCheckingUploadedFile[] = [];
|
||||
let hasInvalidFiles = false;
|
||||
// 确定文件上传类型
|
||||
const uploadType: 'single' | 'multiple' = (isZip || is7z) ? 'multiple' : 'single';
|
||||
|
||||
Array.from(files).forEach(file => {
|
||||
const isZip = file.type === 'application/zip' ||
|
||||
file.type === 'application/x-zip-compressed' ||
|
||||
file.name.toLowerCase().endsWith('.zip');
|
||||
const is7z = file.type === 'application/x-7z-compressed' ||
|
||||
file.name.toLowerCase().endsWith('.7z');
|
||||
|
||||
if (isZip || is7z) {
|
||||
validFiles.push({
|
||||
id: generateFileId(),
|
||||
file,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
uploadType: 'multiple'
|
||||
});
|
||||
} else {
|
||||
hasInvalidFiles = true;
|
||||
}
|
||||
setUploadedFile({
|
||||
id: generateFileId(),
|
||||
file,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
uploadType
|
||||
});
|
||||
|
||||
if (hasInvalidFiles) {
|
||||
messageService.error('只能上传ZIP或7Z格式的压缩文件', {
|
||||
title: '文件类型错误',
|
||||
confirmText: '确定',
|
||||
});
|
||||
}
|
||||
|
||||
if (validFiles.length > 0) {
|
||||
setMultipleFiles(prev => [...prev, ...validFiles]);
|
||||
setUploadType('multiple');
|
||||
console.log("选择多案件文件:", validFiles.length, "个");
|
||||
}
|
||||
console.log("选择文件:", file.name, "类型:", uploadType);
|
||||
};
|
||||
|
||||
// 删除单个文件
|
||||
const handleRemoveFile = (fileId: string, type: 'single' | 'multiple') => {
|
||||
// 删除文件
|
||||
const handleRemoveFile = () => {
|
||||
if (isUploading) {
|
||||
toastService.warning("上传进行中,无法删除文件");
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === 'single') {
|
||||
setSingleFiles(prev => {
|
||||
const newFiles = prev.filter(f => f.id !== fileId);
|
||||
if (newFiles.length === 0) {
|
||||
setUploadType('none');
|
||||
singleUploadRef.current?.resetFileInput();
|
||||
}
|
||||
return newFiles;
|
||||
});
|
||||
} else {
|
||||
setMultipleFiles(prev => {
|
||||
const newFiles = prev.filter(f => f.id !== fileId);
|
||||
if (newFiles.length === 0) {
|
||||
setUploadType('none');
|
||||
multipleUploadRef.current?.resetFileInput();
|
||||
}
|
||||
return newFiles;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 清空文件列表
|
||||
const handleClearFiles = (type: 'single' | 'multiple') => {
|
||||
if (isUploading) {
|
||||
toastService.warning("上传进行中,无法清空文件");
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === 'single') {
|
||||
setSingleFiles([]);
|
||||
singleUploadRef.current?.resetFileInput();
|
||||
} else {
|
||||
setMultipleFiles([]);
|
||||
multipleUploadRef.current?.resetFileInput();
|
||||
}
|
||||
setUploadType('none');
|
||||
clearAllFiles();
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -382,11 +297,11 @@ export default function CrossCheckingUpload() {
|
||||
}
|
||||
|
||||
// 验证步骤3:文件上传
|
||||
const filesToUpload = uploadType === 'single' ? singleFiles : multipleFiles;
|
||||
if (filesToUpload.length === 0) {
|
||||
if (!uploadedFile) {
|
||||
toastService.error("请先选择要上传的文件");
|
||||
return;
|
||||
}
|
||||
const filesToUpload = [uploadedFile];
|
||||
|
||||
// 验证选择了案卷类型
|
||||
if (!selectedDocTypeId) {
|
||||
@@ -437,6 +352,20 @@ export default function CrossCheckingUpload() {
|
||||
// console.log("requireParam", requireParam)
|
||||
// return;
|
||||
|
||||
// 构建负责人ID数组:当前用户(主要负责人)+ 额外负责人
|
||||
const principalUserIds: number[] = [];
|
||||
// 添加当前用户作为主要负责人
|
||||
if (currentUserId) {
|
||||
principalUserIds.push(parseInt(currentUserId.replace('user_', '')));
|
||||
}
|
||||
// 添加额外的负责人
|
||||
leaderIds.forEach(id => {
|
||||
const numId = parseInt(id.replace('user_', ''));
|
||||
if (!principalUserIds.includes(numId)) {
|
||||
principalUserIds.push(numId);
|
||||
}
|
||||
});
|
||||
|
||||
// 使用文档类型名称作为 doc_type
|
||||
const uploadResult = await batchUploadAndAssignCrossCheckingFiles(
|
||||
filesToUpload,
|
||||
@@ -449,7 +378,8 @@ export default function CrossCheckingUpload() {
|
||||
taskInfo.name,
|
||||
selectedDocType.code, // 使用文档类型code
|
||||
taskInfo.type, // 使用任务类型(市局间交叉评查 或 区局间交叉评查)
|
||||
frontendJWT
|
||||
frontendJWT,
|
||||
principalUserIds // 负责人ID数组
|
||||
);
|
||||
|
||||
|
||||
@@ -563,7 +493,7 @@ export default function CrossCheckingUpload() {
|
||||
// 小组多选逻辑 - 默认不选择任何项
|
||||
|
||||
// 检查是否可以完成
|
||||
const canComplete = (singleFiles.length > 0 || multipleFiles.length > 0) && !isUploading;
|
||||
const canComplete = uploadedFile !== null && !isUploading;
|
||||
// const navigation = useNavigation();
|
||||
// 由于 isSubmitting 未被使用,暂时移除该行代码
|
||||
|
||||
@@ -738,8 +668,18 @@ export default function CrossCheckingUpload() {
|
||||
placeholder="请选择评查小组成员"
|
||||
value={groupChecked}
|
||||
onChange={(values: string[]) => {
|
||||
setGroupChecked(values);
|
||||
// 确保当前用户始终被选中
|
||||
let newValues = values;
|
||||
if (currentUserId && !values.includes(currentUserId)) {
|
||||
newValues = [currentUserId, ...values];
|
||||
}
|
||||
setGroupChecked(newValues);
|
||||
// 移除已被取消选中的负责人
|
||||
setLeaderIds(prev => prev.filter(id => newValues.includes(id)));
|
||||
}}
|
||||
maxHeight={460}
|
||||
searchable={true}
|
||||
searchPlaceholder="搜索成员..."
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -749,12 +689,20 @@ export default function CrossCheckingUpload() {
|
||||
<div className="p-4">
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-3">已选择的评查小组成员</h4>
|
||||
{groupChecked.length > 0 ? (
|
||||
<div className="space-y-2 max-h-64 overflow-y-auto">
|
||||
{groupChecked.map((member, index) => {
|
||||
<div className="space-y-2 max-h-[460px] overflow-y-auto">
|
||||
{/* 将当前用户排在第一位 */}
|
||||
{[...groupChecked].sort((a, b) => {
|
||||
if (a === currentUserId) return -1;
|
||||
if (b === currentUserId) return 1;
|
||||
return 0;
|
||||
}).map((member, index) => {
|
||||
let displayName: string = member;
|
||||
let displayOrg = '';
|
||||
|
||||
if (member.startsWith('user_')) {
|
||||
const isUser = member.startsWith('user_');
|
||||
const isCurrentUser = member === currentUserId;
|
||||
const isLeader = leaderIds.includes(member);
|
||||
|
||||
if (isUser) {
|
||||
// 查找真实用户名
|
||||
const userName = findUserNameById(userSelectionState.treeData, member) || member.replace('user_', '');
|
||||
displayName = userName;
|
||||
@@ -765,11 +713,59 @@ export default function CrossCheckingUpload() {
|
||||
displayName = parts[parts.length - 1];
|
||||
displayOrg = parts.slice(0, -1).join(' - ') || '组织';
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<div key={index} className="bg-white p-2 rounded text-xs border">
|
||||
<div className="font-medium text-gray-800">{displayName}</div>
|
||||
<div className="text-gray-500 mt-1">{displayOrg}</div>
|
||||
<div
|
||||
key={index}
|
||||
className={`bg-white p-2 rounded text-xs border flex items-center justify-between ${
|
||||
isCurrentUser
|
||||
? 'border-amber-400 bg-amber-50'
|
||||
: isLeader
|
||||
? 'border-[var(--color-primary)] bg-[rgba(0,104,74,0.05)]'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-gray-800 flex items-center gap-2">
|
||||
{displayName}
|
||||
{isCurrentUser && (
|
||||
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-amber-500 text-white">
|
||||
<i className="ri-user-star-fill mr-0.5"></i>主要负责人
|
||||
</span>
|
||||
)}
|
||||
{!isCurrentUser && isLeader && (
|
||||
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-[var(--color-primary)] text-white">
|
||||
<i className="ri-star-fill mr-0.5"></i>负责人
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-gray-500 mt-1">{displayOrg}</div>
|
||||
</div>
|
||||
{/* 当前用户不能取消选中,也不显示设为负责人按钮 */}
|
||||
{isCurrentUser ? (
|
||||
<span className="ml-2 px-2 py-1 rounded text-[10px] bg-gray-100 text-gray-400 cursor-not-allowed">
|
||||
不可更改
|
||||
</span>
|
||||
) : isUser ? (
|
||||
<button
|
||||
type="button"
|
||||
className={`ml-2 px-2 py-1 rounded text-[10px] transition-colors ${
|
||||
isLeader
|
||||
? 'bg-gray-100 text-gray-500 hover:bg-gray-200'
|
||||
: 'bg-[var(--color-primary-light)] text-[var(--color-primary)] hover:bg-[var(--color-primary)] hover:text-white'
|
||||
}`}
|
||||
onClick={() => {
|
||||
if (isLeader) {
|
||||
setLeaderIds(prev => prev.filter(id => id !== member));
|
||||
} else {
|
||||
setLeaderIds(prev => [...prev, member]);
|
||||
}
|
||||
}}
|
||||
title={isLeader ? '取消负责人' : '设为负责人'}
|
||||
>
|
||||
{isLeader ? '取消负责人' : '设为负责人'}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -781,18 +777,32 @@ export default function CrossCheckingUpload() {
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-4 pt-3 border-t border-gray-200">
|
||||
<div className="text-xs text-gray-500">
|
||||
共选择 {groupChecked.length} 名成员
|
||||
<div className="text-xs text-gray-500 flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span>共选择 {groupChecked.length} 名成员</span>
|
||||
<span className="text-amber-600">
|
||||
<i className="ri-user-star-fill mr-1"></i>
|
||||
主要负责人: 1 人
|
||||
</span>
|
||||
</div>
|
||||
{leaderIds.length > 0 && (
|
||||
<div className="flex items-center justify-end">
|
||||
<span className="text-[var(--color-primary)]">
|
||||
<i className="ri-star-fill mr-1"></i>
|
||||
额外负责人: {leaderIds.length} 人
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* 按钮区域移到卡片内部 */}
|
||||
<div className="flex justify-between items-center mt-6">
|
||||
<Button
|
||||
type="default"
|
||||
<Button
|
||||
type="default"
|
||||
icon="ri-arrow-left-line"
|
||||
onClick={() => {
|
||||
console.log('点击返回列表按钮');
|
||||
@@ -847,132 +857,116 @@ export default function CrossCheckingUpload() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 文件上传区域 */}
|
||||
{/* 文件上传区域 - 左右布局 */}
|
||||
<input type="hidden" name="selectedDocTypeId" value={selectedDocTypeId || ''} />
|
||||
<input type="hidden" name="uploadType" value={uploadType} />
|
||||
|
||||
{/* 上传框区域 */}
|
||||
<div className="upload-section">
|
||||
{/* 单案件导入 */}
|
||||
<div className="upload-item">
|
||||
<div className="upload-item-header">
|
||||
<i className="upload-item-icon ri-file-text-line"></i>
|
||||
<span>单案件导入</span>
|
||||
</div>
|
||||
<div className="flex gap-6">
|
||||
{/* 左侧:上传区域 */}
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-gray-700 mb-3">上传文件</div>
|
||||
<UploadArea
|
||||
ref={singleUploadRef}
|
||||
onFilesSelected={handleSingleFilesSelected}
|
||||
ref={uploadRef}
|
||||
onFilesSelected={handleFileSelected}
|
||||
className="custom-upload-area"
|
||||
accept=".pdf,.docx"
|
||||
multiple={true}
|
||||
icon="ri-file-upload-line"
|
||||
buttonText="选择文件"
|
||||
mainText="点击或拖拽文件到此区域上传"
|
||||
tipText={
|
||||
<div className="upload-tip-error">
|
||||
请上传案件相关PDF或DOCX文件
|
||||
</div>
|
||||
}
|
||||
disabled={uploadType === 'multiple' || isUploading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 多案件导入 */}
|
||||
<div className="upload-item">
|
||||
<div className="upload-item-header">
|
||||
<i className="upload-item-icon ri-file-list-line"></i>
|
||||
<span>多案件导入</span>
|
||||
</div>
|
||||
<UploadArea
|
||||
ref={multipleUploadRef}
|
||||
onFilesSelected={handleMultipleFilesSelected}
|
||||
className="custom-upload-area"
|
||||
accept=".zip,.7z"
|
||||
accept=".pdf,.docx,.zip,.7z"
|
||||
multiple={false}
|
||||
icon="ri-folder-zip-line"
|
||||
icon="ri-upload-cloud-2-line"
|
||||
buttonText="选择文件"
|
||||
mainText="点击或拖拽文件到此区域上传"
|
||||
tipText={
|
||||
<div className="upload-tip-error">
|
||||
请上传多个案件作为压缩包zip、7z文件
|
||||
<div className="text-gray-500 text-xs mt-2">
|
||||
<div>支持文件类型:</div>
|
||||
<div className="flex flex-wrap gap-2 mt-1 justify-center">
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded bg-red-50 text-red-600">
|
||||
<i className="ri-file-pdf-line mr-1"></i>PDF
|
||||
</span>
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded bg-blue-50 text-blue-600">
|
||||
<i className="ri-file-word-2-line mr-1"></i>DOCX
|
||||
</span>
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded bg-orange-50 text-orange-600">
|
||||
<i className="ri-folder-zip-line mr-1"></i>ZIP/7Z
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
disabled={uploadType === 'single' || isUploading}
|
||||
disabled={isUploading || uploadedFile !== null}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 文件预览区域 */}
|
||||
{(singleFiles.length > 0 || multipleFiles.length > 0) && (
|
||||
<div className="mt-6 bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="text-sm font-medium text-gray-700">
|
||||
已选择 {uploadType === 'single' ? singleFiles.length : multipleFiles.length} 个文件
|
||||
</div>
|
||||
<Button
|
||||
type="default"
|
||||
size="small"
|
||||
icon="ri-delete-bin-line"
|
||||
onClick={() => handleClearFiles(uploadType === 'single' ? 'single' : 'multiple')}
|
||||
disabled={isUploading}
|
||||
>
|
||||
清空
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 单案件文件列表 */}
|
||||
{uploadType === 'single' && singleFiles.length > 0 && (
|
||||
<div className="max-h-32 overflow-y-auto space-y-1">
|
||||
{singleFiles.map((file) => {
|
||||
const isDocx = file.name.toLowerCase().endsWith('.docx');
|
||||
const isPdf = file.name.toLowerCase().endsWith('.pdf');
|
||||
return (
|
||||
<div key={file.id} className="flex items-center justify-between bg-white p-2 rounded border">
|
||||
<div className="flex items-center space-x-2 flex-1 min-w-0">
|
||||
{isPdf && <i className="ri-file-pdf-line text-red-500"></i>}
|
||||
{isDocx && <i className="ri-file-word-2-line text-blue-500"></i>}
|
||||
{!isPdf && !isDocx && <i className="ri-file-line text-gray-500"></i>}
|
||||
<span className="text-sm truncate">{file.name}</span>
|
||||
<span className="text-xs text-gray-500">{formatFileSize(file.size)}</span>
|
||||
{/* 右侧:文件信息展示 */}
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-gray-700 mb-3">文件信息</div>
|
||||
<div className="border border-gray-200 rounded-lg bg-gray-50 min-h-[200px] p-4">
|
||||
{uploadedFile ? (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* 文件图标和类型 */}
|
||||
<div className="flex items-center justify-center mb-4">
|
||||
{(() => {
|
||||
const fileName = uploadedFile.name.toLowerCase();
|
||||
const isPdf = fileName.endsWith('.pdf');
|
||||
const isDocx = fileName.endsWith('.docx');
|
||||
const isZip = fileName.endsWith('.zip');
|
||||
const is7z = fileName.endsWith('.7z');
|
||||
|
||||
if (isPdf) return <i className="ri-file-pdf-line text-5xl text-red-500"></i>;
|
||||
if (isDocx) return <i className="ri-file-word-2-line text-5xl text-blue-500"></i>;
|
||||
if (isZip || is7z) return <i className="ri-folder-zip-line text-5xl text-orange-500"></i>;
|
||||
return <i className="ri-file-line text-5xl text-gray-500"></i>;
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* 文件详情 */}
|
||||
<div className="flex-1 space-y-3">
|
||||
<div className="bg-white rounded-md p-3 border border-gray-200">
|
||||
<div className="text-xs text-gray-500 mb-1">文件名</div>
|
||||
<div className="text-sm font-medium text-gray-800 truncate" title={uploadedFile.name}>
|
||||
{uploadedFile.name}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveFile(file.id, 'single')}
|
||||
className="text-red-500 hover:text-red-700 p-1"
|
||||
disabled={isUploading}
|
||||
>
|
||||
<i className="ri-close-line"></i>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 多案件文件列表 */}
|
||||
{uploadType === 'multiple' && multipleFiles.length > 0 && (
|
||||
<div className="max-h-32 overflow-y-auto space-y-1">
|
||||
{multipleFiles.map((file) => (
|
||||
<div key={file.id} className="flex items-center justify-between bg-white p-2 rounded border">
|
||||
<div className="flex items-center space-x-2 flex-1 min-w-0">
|
||||
<i className="ri-folder-zip-line text-orange-500"></i>
|
||||
<span className="text-sm truncate">{file.name}</span>
|
||||
<span className="text-xs text-gray-500">{formatFileSize(file.size)}</span>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="bg-white rounded-md p-3 border border-gray-200">
|
||||
<div className="text-xs text-gray-500 mb-1">文件大小</div>
|
||||
<div className="text-sm font-medium text-gray-800">
|
||||
{formatFileSize(uploadedFile.size)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-md p-3 border border-gray-200">
|
||||
<div className="text-xs text-gray-500 mb-1">导入类型</div>
|
||||
<div className="text-sm font-medium text-gray-800">
|
||||
{uploadedFile.uploadType === 'single' ? (
|
||||
<span className="text-blue-600">单案件导入</span>
|
||||
) : (
|
||||
<span className="text-orange-600">多案件导入</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 删除按钮 */}
|
||||
<div className="mt-4 pt-3 border-t border-gray-200">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveFile(file.id, 'multiple')}
|
||||
className="text-red-500 hover:text-red-700 p-1"
|
||||
onClick={handleRemoveFile}
|
||||
disabled={isUploading}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-2 text-sm text-red-600 bg-red-50 hover:bg-red-100 rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<i className="ri-close-line"></i>
|
||||
<i className="ri-delete-bin-line"></i>
|
||||
删除文件
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full flex flex-col items-center justify-center text-gray-400">
|
||||
<i className="ri-file-line text-4xl mb-2"></i>
|
||||
<span className="text-sm">暂未选择文件</span>
|
||||
<span className="text-xs mt-1">请在左侧上传区域选择文件</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 完成按钮 */}
|
||||
<div className="flex justify-between items-center mt-8">
|
||||
@@ -1001,7 +995,7 @@ export default function CrossCheckingUpload() {
|
||||
{/* 文件选择状态提示 */}
|
||||
{!canComplete && !isUploading && (
|
||||
<div className="text-center mt-4 text-gray-500 text-sm">
|
||||
请至少选择一种导入方式的文件
|
||||
请选择要上传的文件
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1016,9 +1010,9 @@ export default function CrossCheckingUpload() {
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-blue-700">
|
||||
{isCreatingTask
|
||||
? `正在创建交叉评查任务:${taskInfo.name}`
|
||||
: `正在上传 ${uploadType === 'single' ? singleFiles.length : multipleFiles.length} 个文件,请稍候`
|
||||
{isCreatingTask
|
||||
? `正在创建交叉评查任务:${taskInfo.name}`
|
||||
: `正在上传文件 ${uploadedFile?.name},请稍候`
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -20,6 +20,8 @@ import {
|
||||
deleteRole,
|
||||
revokeUserRole,
|
||||
getUserRoles,
|
||||
getRoutePermissions,
|
||||
isSharedPermission,
|
||||
type RoleInfo,
|
||||
type RouteInfo,
|
||||
type UserInfo,
|
||||
@@ -857,9 +859,18 @@ export default function RolePermissions() {
|
||||
// 存储每个路由的 permissions(routeId -> permissions[])
|
||||
const [routePermissionsMap, setRoutePermissionsMap] = useState<Map<number, ApiPermission[]>>(new Map());
|
||||
|
||||
// v3.9: 父子路由折叠状态(存储已展开的父路由ID)
|
||||
const [collapsedRouteIds, setCollapsedRouteIds] = useState<number[]>([]);
|
||||
|
||||
// 保存权限的 loading 状态
|
||||
const [savingPermissions, setSavingPermissions] = useState(false);
|
||||
|
||||
// v3.8: 加载角色权限的 loading 状态
|
||||
const [loadingPermissions, setLoadingPermissions] = useState(false);
|
||||
|
||||
// v3.8: 路由ID到路由信息的映射(用于显示通用权限关联的路由名称)
|
||||
const [routeIdToInfoMap, setRouteIdToInfoMap] = useState<Map<number, { title: string; path: string }>>(new Map());
|
||||
|
||||
// 加载初始数据
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
@@ -933,6 +944,22 @@ export default function RolePermissions() {
|
||||
setRoutes(routesData);
|
||||
setUsers(filteredUsers);
|
||||
|
||||
// v3.8: 构建路由ID到路由信息的映射
|
||||
const buildRouteIdMap = (routes: RouteInfo[]): Map<number, { title: string; path: string }> => {
|
||||
const map = new Map<number, { title: string; path: string }>();
|
||||
const traverse = (routeList: RouteInfo[]) => {
|
||||
routeList.forEach(route => {
|
||||
map.set(route.id, { title: route.route_title, path: route.route_path });
|
||||
if (route.children) {
|
||||
traverse(route.children);
|
||||
}
|
||||
});
|
||||
};
|
||||
traverse(routes);
|
||||
return map;
|
||||
};
|
||||
setRouteIdToInfoMap(buildRouteIdMap(routesData));
|
||||
|
||||
// 默认选中第一个角色(使用过滤后的列表)
|
||||
if (filteredRoles.length > 0) {
|
||||
handleSelectRole(filteredRoles[0]);
|
||||
@@ -953,68 +980,96 @@ export default function RolePermissions() {
|
||||
// 选择角色
|
||||
const handleSelectRole = async (role: RoleInfo) => {
|
||||
setSelectedRole(role);
|
||||
setLoadingPermissions(true); // v3.8: 开始加载权限
|
||||
|
||||
// 动态导入权限映射工具
|
||||
const { mapPermissions } = await import('~/utils/permission-mapper');
|
||||
try {
|
||||
// 动态导入权限映射工具
|
||||
const { mapPermissions } = await import('~/utils/permission-mapper');
|
||||
|
||||
// v3.0: 并行加载数据
|
||||
const [routesResult, rolePermissions, users] = await Promise.all([
|
||||
getRoleRoutesWithPermissions(role.id),
|
||||
getRolePermissions(role.id), // 获取该角色已分配的权限
|
||||
getRoleUsers(role.id)
|
||||
]);
|
||||
// v3.0: 并行加载数据
|
||||
const [routesResult, rolePermissions, users] = await Promise.all([
|
||||
getRoleRoutesWithPermissions(role.id),
|
||||
getRolePermissions(role.id), // 获取该角色已分配的权限
|
||||
getRoleUsers(role.id)
|
||||
]);
|
||||
|
||||
const { routes: routesWithPerms, selectedRouteIds: routeIds } = routesResult;
|
||||
const { routes: routesWithPerms, selectedRouteIds: routeIds } = routesResult;
|
||||
|
||||
// 构建原始权限映射(未映射的,用于保存)
|
||||
const originalPermMap = new Map<number, ApiPermission[]>();
|
||||
// 存储所有原始权限的列表
|
||||
const allOriginalPerms: ApiPermission[] = [];
|
||||
const extractOriginalPermissions = (routes: RouteInfo[]) => {
|
||||
routes.forEach(route => {
|
||||
if (route.permissions && route.permissions.length > 0) {
|
||||
originalPermMap.set(route.id, route.permissions);
|
||||
allOriginalPerms.push(...route.permissions);
|
||||
}
|
||||
if (route.children) {
|
||||
extractOriginalPermissions(route.children);
|
||||
// v3.6: 为每个路由获取权限(包含通用权限)
|
||||
// 收集所有路由ID
|
||||
const collectAllRouteIds = (routes: RouteInfo[]): number[] => {
|
||||
let ids: number[] = [];
|
||||
routes.forEach(route => {
|
||||
ids.push(route.id);
|
||||
if (route.children) {
|
||||
ids = ids.concat(collectAllRouteIds(route.children));
|
||||
}
|
||||
});
|
||||
return ids;
|
||||
};
|
||||
|
||||
const allRouteIds = collectAllRouteIds(routesWithPerms);
|
||||
|
||||
// v3.6: 并行获取每个路由的权限(包含通用权限)
|
||||
const routePermissionsPromises = allRouteIds.map(async (routeId) => {
|
||||
const permissions = await getRoutePermissions(routeId);
|
||||
return { routeId, permissions };
|
||||
});
|
||||
|
||||
const routePermissionsResults = await Promise.all(routePermissionsPromises);
|
||||
|
||||
// 构建原始权限映射(用于保存)
|
||||
const originalPermMap = new Map<number, ApiPermission[]>();
|
||||
const allOriginalPerms: ApiPermission[] = [];
|
||||
// 用于去重通用权限(通用权限可能在多个路由下出现,但只需要保存一次)
|
||||
const seenPermissionIds = new Set<number>();
|
||||
|
||||
routePermissionsResults.forEach(({ routeId, permissions }) => {
|
||||
if (permissions.length > 0) {
|
||||
originalPermMap.set(routeId, permissions);
|
||||
permissions.forEach(p => {
|
||||
if (!seenPermissionIds.has(p.id)) {
|
||||
seenPermissionIds.add(p.id);
|
||||
allOriginalPerms.push(p);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
extractOriginalPermissions(routesWithPerms);
|
||||
|
||||
// 存储原始权限
|
||||
setOriginalRoutePermissionsMap(originalPermMap);
|
||||
setOriginalAllPermissions(allOriginalPerms);
|
||||
// 存储原始权限
|
||||
setOriginalRoutePermissionsMap(originalPermMap);
|
||||
setOriginalAllPermissions(allOriginalPerms);
|
||||
|
||||
// 构建映射后的权限映射(用于显示)
|
||||
const displayPermMap = new Map<number, ApiPermission[]>();
|
||||
const extractDisplayPermissions = (routes: RouteInfo[]) => {
|
||||
routes.forEach(route => {
|
||||
if (route.permissions && route.permissions.length > 0) {
|
||||
const mappedPermissions = mapPermissions(route.permissions);
|
||||
displayPermMap.set(route.id, mappedPermissions);
|
||||
}
|
||||
if (route.children) {
|
||||
extractDisplayPermissions(route.children);
|
||||
// 构建映射后的权限映射(用于显示)
|
||||
const displayPermMap = new Map<number, ApiPermission[]>();
|
||||
routePermissionsResults.forEach(({ routeId, permissions }) => {
|
||||
if (permissions.length > 0) {
|
||||
const mappedPermissions = mapPermissions(permissions) as ApiPermission[];
|
||||
displayPermMap.set(routeId, mappedPermissions);
|
||||
}
|
||||
});
|
||||
};
|
||||
extractDisplayPermissions(routesWithPerms);
|
||||
|
||||
// v3.5: 修复BUG - 只筛选 grant_type=GRANT 的权限
|
||||
// BUG说明:之前没有检查 grant_type,导致 DENY 的权限也被显示为勾选
|
||||
// 修改前:const assignedPermissionIds = rolePermissions.map(p => p.permission_id);
|
||||
const assignedPermissionIds = rolePermissions
|
||||
.filter(p => p.grant_type === 'GRANT')
|
||||
.map(p => p.permission_id);
|
||||
// v3.5: 修复BUG - 只筛选 grant_type=GRANT 的权限
|
||||
// BUG说明:之前没有检查 grant_type,导致 DENY 的权限也被显示为勾选
|
||||
// 修改前:const assignedPermissionIds = rolePermissions.map(p => p.permission_id);
|
||||
const assignedPermissionIds = rolePermissions
|
||||
.filter(p => p.grant_type === 'GRANT')
|
||||
.map(p => p.permission_id);
|
||||
|
||||
// 存储状态
|
||||
setRoutePermissionsMap(displayPermMap); // 用于显示
|
||||
setSelectedRouteIds(routeIds);
|
||||
setSelectedPermissionIds(assignedPermissionIds); // 使用原始权限ID
|
||||
setExpandedRouteIds([]); // 重置展开状态
|
||||
setRoleUsers(users);
|
||||
// console.log('🔑 [RolePermissions v3.0] 过滤前的已分配权限ID长度:', rolePermissions);
|
||||
|
||||
// 存储状态
|
||||
setRoutePermissionsMap(displayPermMap); // 用于显示
|
||||
setSelectedRouteIds(routeIds);
|
||||
setSelectedPermissionIds(assignedPermissionIds); // 使用原始权限ID
|
||||
setExpandedRouteIds([]); // 重置展开状态
|
||||
setRoleUsers(users);
|
||||
} catch (error) {
|
||||
console.error('加载角色权限失败:', error);
|
||||
toastService.error('加载角色权限失败');
|
||||
} finally {
|
||||
setLoadingPermissions(false); // v3.8: 结束加载权限
|
||||
}
|
||||
};
|
||||
|
||||
// 递归查找路由
|
||||
@@ -1144,6 +1199,35 @@ export default function RolePermissions() {
|
||||
);
|
||||
};
|
||||
|
||||
// v3.9: 切换父子路由折叠状态
|
||||
const handleToggleCollapse = (routeId: number) => {
|
||||
setCollapsedRouteIds(prev =>
|
||||
prev.includes(routeId)
|
||||
? prev.filter(id => id !== routeId)
|
||||
: [...prev, routeId]
|
||||
);
|
||||
};
|
||||
|
||||
// v3.9: 全部展开/全部折叠
|
||||
const handleExpandAll = () => {
|
||||
setCollapsedRouteIds([]);
|
||||
};
|
||||
|
||||
const handleCollapseAll = () => {
|
||||
// 收集所有有子路由的路由ID
|
||||
const collectParentRouteIds = (routeList: RouteInfo[]): number[] => {
|
||||
let ids: number[] = [];
|
||||
routeList.forEach(route => {
|
||||
if (route.children && route.children.length > 0) {
|
||||
ids.push(route.id);
|
||||
ids = ids.concat(collectParentRouteIds(route.children));
|
||||
}
|
||||
});
|
||||
return ids;
|
||||
};
|
||||
setCollapsedRouteIds(collectParentRouteIds(routes));
|
||||
};
|
||||
|
||||
// v3.0: 判断是否是"所有权限"项(用于过滤)
|
||||
const isAllPermission = (permission: ApiPermission): boolean => {
|
||||
const key = permission.permission_key?.toLowerCase() || '';
|
||||
@@ -1158,13 +1242,25 @@ export default function RolePermissions() {
|
||||
return permissions.filter(p => !isAllPermission(p));
|
||||
};
|
||||
|
||||
// v3.0: 切换单个API权限
|
||||
const handleTogglePermission = (permissionId: number, checked: boolean) => {
|
||||
// v3.7: 切换单个API权限(支持通用权限同步)
|
||||
const handleTogglePermission = (permission: ApiPermission, checked: boolean) => {
|
||||
const permissionId = permission.id;
|
||||
|
||||
if (checked) {
|
||||
setSelectedPermissionIds([...selectedPermissionIds, permissionId]);
|
||||
} else {
|
||||
setSelectedPermissionIds(selectedPermissionIds.filter(id => id !== permissionId));
|
||||
}
|
||||
|
||||
// v3.7: 如果是通用权限,同步更新其他关联路由的显示状态
|
||||
// 注意:由于通用权限在数据库中只有一条记录,这里只需要更新 UI 显示
|
||||
// 实际的 selectedPermissionIds 只需要包含一次该权限ID
|
||||
if (isSharedPermission(permission) && permission.related_routes) {
|
||||
// 通用权限的 permissionId 是唯一的,所以这里不需要额外处理
|
||||
// 但需要触发 UI 更新,让其他路由下显示的同一权限也更新勾选状态
|
||||
// 由于 React 的状态更新机制,上面的 setSelectedPermissionIds 已经会触发重渲染
|
||||
console.log(`🔗 [handleTogglePermission] 通用权限 ${permission.display_name} 已${checked ? '勾选' : '取消'},关联路由: ${permission.related_routes.join(', ')}`);
|
||||
}
|
||||
};
|
||||
|
||||
// v3.0: 获取HTTP方法对应的标签样式
|
||||
@@ -1350,11 +1446,11 @@ export default function RolePermissions() {
|
||||
}
|
||||
};
|
||||
|
||||
// 渲染路由树 - v3.0: 支持展开显示API权限
|
||||
// v3.8: 渲染路由树 - 卡片式设计,支持展开显示API权限
|
||||
const renderRouteTree = (routeList: RouteInfo[], level = 0) => {
|
||||
return routeList.map(route => {
|
||||
const hasChildren = route.children && route.children.length > 0;
|
||||
// v3.0: 从 routePermissionsMap 获取该路由的 permissions,并过滤掉"所有权限"
|
||||
// 从 routePermissionsMap 获取该路由的 permissions,并过滤掉"所有权限"
|
||||
const rawPermissions = routePermissionsMap.get(route.id) || [];
|
||||
const permissions = filterPermissions(rawPermissions);
|
||||
const hasPermissions = permissions.length > 0;
|
||||
@@ -1373,15 +1469,189 @@ export default function RolePermissions() {
|
||||
selectedPermissionIds.includes(id)
|
||||
).length;
|
||||
|
||||
// 是否为一级路由(使用卡片样式)
|
||||
const isTopLevel = level === 0;
|
||||
|
||||
// 渲染权限展开按钮
|
||||
const renderPermissionButton = () => {
|
||||
if (!hasPermissions) return null;
|
||||
|
||||
const btnStyle: React.CSSProperties = {
|
||||
backgroundColor:
|
||||
selectedPermCount === permissions.length ? '#e6f7ed' :
|
||||
selectedPermCount > 0 ? '#fff7e6' : '#f5f5f5',
|
||||
color:
|
||||
selectedPermCount === permissions.length ? '#52c41a' :
|
||||
selectedPermCount > 0 ? '#fa8c16' : '#666',
|
||||
border:
|
||||
selectedPermCount === permissions.length ? '1px solid #b7eb8f' :
|
||||
selectedPermCount > 0 ? '1px solid #ffd591' : '1px solid #d9d9d9',
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="permission-expand-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleToggleRouteExpand(route.id);
|
||||
}}
|
||||
style={btnStyle}
|
||||
>
|
||||
<i className={`ri-${isExpanded ? 'arrow-up-s' : 'arrow-down-s'}-line`}></i>
|
||||
<span>API权限 ({selectedPermCount}/{permissions.length})</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染API权限列表
|
||||
const renderPermissionsList = () => {
|
||||
if (!hasPermissions || !isExpanded) return null;
|
||||
|
||||
return (
|
||||
<div className="permissions-list">
|
||||
{permissions.map(permission => {
|
||||
const isShared = isSharedPermission(permission);
|
||||
|
||||
// 获取通用权限关联的路由名称(排除当前路由)
|
||||
const relatedRouteNames = (() => {
|
||||
if (!isShared || !permission.related_routes) return [];
|
||||
return permission.related_routes
|
||||
.filter(rid => rid !== route.id)
|
||||
.map(rid => {
|
||||
const routeInfo = routeIdToInfoMap.get(rid);
|
||||
return routeInfo ? routeInfo.title : `路由${rid}`;
|
||||
});
|
||||
})();
|
||||
|
||||
return (
|
||||
<label
|
||||
key={permission.id}
|
||||
className={`permission-item ${isShared ? 'shared' : ''}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedPermissionIds.includes(permission.id)}
|
||||
onChange={(e) => handleTogglePermission(permission, e.target.checked)}
|
||||
style={{ margin: '3px 0 0 0', flexShrink: 0 }}
|
||||
disabled={!isProvincialAdmin}
|
||||
/>
|
||||
{isShared && (
|
||||
<span
|
||||
className="shared-badge"
|
||||
title={`此权限同时适用于 ${permission.related_routes?.length || 0} 个页面`}
|
||||
>
|
||||
通用
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={`method-tag ${(permission.api_method || '').toLowerCase()}`}
|
||||
>
|
||||
{permission.api_method || 'N/A'}
|
||||
</span>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<span style={{ color: isShared ? '#1890ff' : '#333', fontSize: '13px', fontWeight: 500 }}>
|
||||
{permission.display_name}
|
||||
</span>
|
||||
{isShared && relatedRouteNames.length > 0 && (
|
||||
<div className="related-routes">
|
||||
<i className="ri-link"></i>
|
||||
<span>同时关联:</span>
|
||||
{relatedRouteNames.map((name, idx) => (
|
||||
<span key={idx} className="related-route-tag">{name}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span style={{ color: '#999', fontSize: '11px', flexShrink: 0, fontFamily: 'Consolas, Monaco, monospace' }}>
|
||||
{permission.api_path}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// v3.9: 判断是否折叠
|
||||
const isCollapsed = collapsedRouteIds.includes(route.id);
|
||||
|
||||
// v3.9: 渲染折叠按钮
|
||||
const renderCollapseButton = () => {
|
||||
if (!hasChildren) return null;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`collapse-btn ${isCollapsed ? 'collapsed' : ''}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleToggleCollapse(route.id);
|
||||
}}
|
||||
title={isCollapsed ? '展开子路由' : '折叠子路由'}
|
||||
>
|
||||
<i className={`ri-arrow-${isCollapsed ? 'right' : 'down'}-s-line`}></i>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
// 一级路由使用卡片样式
|
||||
if (isTopLevel) {
|
||||
return (
|
||||
<div key={route.id} className={`route-card ${isChecked ? 'checked' : ''}`}>
|
||||
<div className="route-item-content">
|
||||
{renderCollapseButton()}
|
||||
<input
|
||||
type="checkbox"
|
||||
id={`route-${route.id}`}
|
||||
checked={isChecked}
|
||||
ref={el => {
|
||||
if (el) el.indeterminate = isIndeterminate ?? false;
|
||||
}}
|
||||
onChange={(e) => {
|
||||
if (hasChildren) {
|
||||
handleToggleParentRoute(route, e.target.checked);
|
||||
} else {
|
||||
handleToggleRoute(route.id, e.target.checked);
|
||||
}
|
||||
}}
|
||||
className="route-checkbox"
|
||||
disabled={!isProvincialAdmin}
|
||||
/>
|
||||
<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>
|
||||
{hasChildren && (
|
||||
<span className="children-count">
|
||||
{route.children!.length} 个子路由
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
{renderPermissionButton()}
|
||||
</div>
|
||||
|
||||
{renderPermissionsList()}
|
||||
|
||||
{hasChildren && (
|
||||
<div className={`route-children ${isCollapsed ? 'collapsed' : ''}`}>
|
||||
{renderRouteTree(route.children!, level + 1)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 子路由使用简洁样式
|
||||
return (
|
||||
<div key={route.id} className="route-item" style={{ paddingLeft: `${level * 20}px` }}>
|
||||
<div className="route-item-content">
|
||||
<div key={route.id} className="route-item">
|
||||
<div className={`route-item-content ${isChecked ? 'checked' : ''}`}>
|
||||
{renderCollapseButton()}
|
||||
<input
|
||||
type="checkbox"
|
||||
id={`route-${route.id}`}
|
||||
checked={isChecked}
|
||||
ref={el => {
|
||||
if (el) el.indeterminate = isIndeterminate;
|
||||
if (el) el.indeterminate = isIndeterminate ?? false;
|
||||
}}
|
||||
onChange={(e) => {
|
||||
if (hasChildren) {
|
||||
@@ -1397,104 +1667,19 @@ export default function RolePermissions() {
|
||||
{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>
|
||||
{hasChildren && (
|
||||
<span className="children-count">
|
||||
{route.children!.length} 个子路由
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
|
||||
{/* v3.0: 显示权限展开按钮 */}
|
||||
{hasPermissions && (
|
||||
<button
|
||||
type="button"
|
||||
className="permission-expand-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleToggleRouteExpand(route.id);
|
||||
}}
|
||||
style={{
|
||||
marginLeft: '8px',
|
||||
padding: '2px 8px',
|
||||
fontSize: '12px',
|
||||
backgroundColor:
|
||||
selectedPermCount === permissions.length ? '#e6f7ed' : // 全部选中:绿色
|
||||
selectedPermCount > 0 ? '#fff7e6' : // 部分选中:浅橙色
|
||||
'#f5f5f5', // 未选中:灰色
|
||||
color:
|
||||
selectedPermCount === permissions.length ? '#52c41a' : // 全部选中:绿色
|
||||
selectedPermCount > 0 ? '#fa8c16' : // 部分选中:橙色
|
||||
'#666', // 未选中:灰色
|
||||
border:
|
||||
selectedPermCount === permissions.length ? '1px solid #b7eb8f' : // 全部选中:绿色
|
||||
selectedPermCount > 0 ? '1px solid #ffd591' : // 部分选中:浅橙色
|
||||
'1px solid #d9d9d9', // 未选中:灰色
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px'
|
||||
}}
|
||||
>
|
||||
<i className={`ri-${isExpanded ? 'arrow-up-s' : 'arrow-down-s'}-line`}></i>
|
||||
<span>API权限 ({selectedPermCount}/{permissions.length})</span>
|
||||
</button>
|
||||
)}
|
||||
{renderPermissionButton()}
|
||||
</div>
|
||||
|
||||
{/* v3.0: 展开的API权限列表(过滤掉"所有权限"项) */}
|
||||
{hasPermissions && isExpanded && (
|
||||
<div
|
||||
className="permissions-list"
|
||||
style={{
|
||||
marginTop: '8px',
|
||||
marginLeft: '24px',
|
||||
padding: '12px',
|
||||
backgroundColor: '#fafafa',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid #e8e8e8'
|
||||
}}
|
||||
>
|
||||
{permissions.map(permission => (
|
||||
<label
|
||||
key={permission.id}
|
||||
className="permission-item"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '6px 0',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedPermissionIds.includes(permission.id)}
|
||||
onChange={(e) => handleTogglePermission(permission.id, e.target.checked)}
|
||||
style={{ margin: 0 }}
|
||||
disabled={!isProvincialAdmin}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
...getMethodTagStyle(permission.api_method),
|
||||
padding: '2px 6px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '11px',
|
||||
fontWeight: 600,
|
||||
minWidth: '50px',
|
||||
textAlign: 'center'
|
||||
}}
|
||||
>
|
||||
{permission.api_method}
|
||||
</span>
|
||||
<span style={{ color: '#333', fontSize: '13px' }}>
|
||||
{permission.display_name}
|
||||
</span>
|
||||
<span style={{ color: '#999', fontSize: '11px', marginLeft: 'auto' }}>
|
||||
{permission.api_path}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{renderPermissionsList()}
|
||||
|
||||
{hasChildren && (
|
||||
<div className="route-children">
|
||||
<div className={`route-children ${isCollapsed ? 'collapsed' : ''}`}>
|
||||
{renderRouteTree(route.children!, level + 1)}
|
||||
</div>
|
||||
)}
|
||||
@@ -1654,40 +1839,83 @@ export default function RolePermissions() {
|
||||
{/* 路由权限Tab */}
|
||||
{activeTab === 'permissions' && (
|
||||
<div className="permissions-tab">
|
||||
{/* v3.3: 权限提示(仅省级管理员可修改) */}
|
||||
{!isProvincialAdmin && (
|
||||
<div className="form-notice warning" style={{ marginBottom: '16px' }}>
|
||||
<i className="ri-information-line"></i>
|
||||
<span>您当前为只读模式,仅省级管理员可以修改角色路由权限</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="permissions-header">
|
||||
<h3>为角色 "{selectedRole.role_name}" 分配路由权限</h3>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={savingPermissions ? "ri-loader-4-line spin" : "ri-save-line"}
|
||||
onClick={handleSavePermissions}
|
||||
disabled={!isProvincialAdmin || savingPermissions}
|
||||
>
|
||||
{savingPermissions ? '保存中...' : '保存权限'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* v3.0: 始终使用 routes 渲染所有可用路由,permissions 从 routePermissionsMap 获取 */}
|
||||
<div className="routes-tree">
|
||||
{renderRouteTree(routes)}
|
||||
</div>
|
||||
|
||||
{/* v3.0: 更新权限统计,显示路由和API权限数量 */}
|
||||
<div className="permissions-summary">
|
||||
<i className="ri-information-line"></i>
|
||||
已选择 <strong>{selectedRouteIds.length}</strong> 个路由权限
|
||||
{selectedPermissionIds.length > 0 && (
|
||||
<>
|
||||
,<strong>{selectedPermissionIds.length}</strong> 个API权限
|
||||
</>
|
||||
{/* v3.8: 固定头部区域 */}
|
||||
<div className="permissions-tab-header">
|
||||
{/* v3.3: 权限提示(仅省级管理员可修改) */}
|
||||
{!isProvincialAdmin && (
|
||||
<div className="form-notice warning" style={{ marginBottom: '12px' }}>
|
||||
<i className="ri-information-line"></i>
|
||||
<span>您当前为只读模式,仅省级管理员可以修改角色路由权限</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="permissions-header">
|
||||
<h3>为角色 "{selectedRole.role_name}" 分配路由权限</h3>
|
||||
{loadingPermissions ? (<></>) : (
|
||||
<>
|
||||
{/* v3.9: 折叠控制栏 */}
|
||||
<div className="collapse-controls">
|
||||
<button
|
||||
type="button"
|
||||
className="collapse-control-btn"
|
||||
onClick={handleExpandAll}
|
||||
title="展开全部"
|
||||
>
|
||||
<i className="ri-expand-diagonal-line"></i>
|
||||
<span>展开全部</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="collapse-control-btn"
|
||||
onClick={handleCollapseAll}
|
||||
title="折叠全部"
|
||||
>
|
||||
<i className="ri-contract-left-right-line"></i>
|
||||
<span>折叠全部</span>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) }
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
icon={savingPermissions ? "ri-loader-4-line spin" : "ri-save-line"}
|
||||
onClick={handleSavePermissions}
|
||||
disabled={!isProvincialAdmin || savingPermissions}
|
||||
>
|
||||
{savingPermissions ? '保存中...' : '保存权限'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* v3.8: 加载状态显示 */}
|
||||
{loadingPermissions ? (
|
||||
<div className="loading-container" style={{ minHeight: '300px', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: '12px' }}>
|
||||
<i className="ri-loader-4-line spin" style={{ fontSize: '32px', color: '#00684a' }}></i>
|
||||
<span style={{ color: '#666' }}>正在加载权限配置...</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
|
||||
|
||||
{/* v3.8: 路由树容器 - 可滚动区域 */}
|
||||
<div className="routes-tree-container">
|
||||
<div className="routes-tree">
|
||||
{renderRouteTree(routes)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* v3.0: 更新权限统计,显示路由和API权限数量 */}
|
||||
{/* <div className="permissions-summary">
|
||||
<i className="ri-information-line"></i>
|
||||
已选择 <strong>{selectedRouteIds.length}</strong> 个路由权限
|
||||
{selectedPermissionIds.length > 0 && (
|
||||
<>
|
||||
,<strong>{selectedPermissionIds.length}</strong> 个API权限
|
||||
</>
|
||||
)}
|
||||
</div> */}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
+20
-36
@@ -66,6 +66,7 @@ interface ApiRule {
|
||||
priority: string;
|
||||
description: string;
|
||||
isActive: boolean;
|
||||
area?: string; // 地区
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -96,6 +97,7 @@ function mapApiRuleToModel(apiRule: ApiRule): Rule {
|
||||
checkMethod: 'automatic', // 默认值
|
||||
prompt: apiRule.description, // 使用描述作为默认prompt
|
||||
isActive: apiRule.isActive,
|
||||
area: apiRule.area || '', // 地区
|
||||
createdAt: apiRule.createdAt,
|
||||
updatedAt: apiRule.updatedAt
|
||||
};
|
||||
@@ -224,26 +226,6 @@ export default function RulesIndex() {
|
||||
// 使用 ref 跟踪是否正在加载数据,避免重复加载
|
||||
const isLoadingRef = useRef(false);
|
||||
|
||||
// 查询参数记忆 key 与保存/恢复
|
||||
const SEARCH_PARAMS_STORAGE_KEY = 'rules.searchParams';
|
||||
const persistSearchParams = (params: URLSearchParams) => {
|
||||
if (typeof window !== 'undefined') {
|
||||
sessionStorage.setItem(SEARCH_PARAMS_STORAGE_KEY, params.toString());
|
||||
}
|
||||
};
|
||||
|
||||
// 首次进入页且 URL 无参数时尝试恢复
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const hasAnyParam = Array.from(searchParams.keys()).length > 0;
|
||||
const stored = sessionStorage.getItem(SEARCH_PARAMS_STORAGE_KEY);
|
||||
if (!hasAnyParam && stored) {
|
||||
setSearchParams(new URLSearchParams(stored));
|
||||
}
|
||||
// 仅初始化检查一次
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// 获取当前的ruleType值
|
||||
const ruleTypeParam = searchParams.get('ruleType');
|
||||
|
||||
@@ -500,10 +482,9 @@ export default function RulesIndex() {
|
||||
|
||||
// 切换筛选条件时,重置到第一页
|
||||
newParams.set('page', '1');
|
||||
persistSearchParams(newParams);
|
||||
setSearchParams(newParams);
|
||||
};
|
||||
|
||||
|
||||
// 搜索评查点
|
||||
const handleSearch = (keyword: string) => {
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
@@ -512,10 +493,9 @@ export default function RulesIndex() {
|
||||
} else {
|
||||
newParams.delete('keyword');
|
||||
}
|
||||
|
||||
|
||||
// 搜索时,重置到第一页
|
||||
newParams.set('page', '1');
|
||||
persistSearchParams(newParams);
|
||||
setSearchParams(newParams);
|
||||
};
|
||||
|
||||
@@ -651,7 +631,6 @@ export default function RulesIndex() {
|
||||
const handlePageChange = (page: number) => {
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
newParams.set('page', page.toString());
|
||||
persistSearchParams(newParams);
|
||||
setSearchParams(newParams);
|
||||
};
|
||||
|
||||
@@ -660,10 +639,9 @@ export default function RulesIndex() {
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
newParams.set('pageSize', size.toString());
|
||||
newParams.set('page', '1'); // 更改每页条数时,重置到第一页
|
||||
persistSearchParams(newParams);
|
||||
setSearchParams(newParams);
|
||||
};
|
||||
|
||||
|
||||
// 处理重置筛选
|
||||
const handleReset = () => {
|
||||
const input = document.querySelector('input[placeholder="输入评查点名称或编码"]');
|
||||
@@ -672,9 +650,6 @@ export default function RulesIndex() {
|
||||
}
|
||||
|
||||
const newParams = new URLSearchParams();
|
||||
if (typeof window !== 'undefined') {
|
||||
sessionStorage.removeItem(SEARCH_PARAMS_STORAGE_KEY);
|
||||
}
|
||||
setSearchParams(newParams);
|
||||
};
|
||||
|
||||
@@ -707,7 +682,7 @@ export default function RulesIndex() {
|
||||
dataIndex: "code" as keyof Rule,
|
||||
key: "code",
|
||||
align: "left" as const,
|
||||
width: "20%",
|
||||
width: "15%",
|
||||
className: "whitespace-normal break-all",
|
||||
render: (value: string) => (
|
||||
<div className="whitespace-normal break-all overflow-visible">{value}</div>
|
||||
@@ -718,13 +693,13 @@ export default function RulesIndex() {
|
||||
dataIndex: "name" as keyof Rule,
|
||||
key: "name",
|
||||
align: "left" as const,
|
||||
width: "20%"
|
||||
width: "15%"
|
||||
},
|
||||
{
|
||||
title: "评查点类型",
|
||||
key: "ruleType",
|
||||
align: "left" as const,
|
||||
width: "12%",
|
||||
width: "10%",
|
||||
render: (_: unknown, record: Rule) => {
|
||||
const typeColor = RULE_TYPE_COLORS[record.ruleType] as TagColor;
|
||||
return (
|
||||
@@ -741,11 +716,19 @@ export default function RulesIndex() {
|
||||
align: "left" as const,
|
||||
width: "10%"
|
||||
},
|
||||
{
|
||||
title: "地区",
|
||||
dataIndex: "area" as keyof Rule,
|
||||
key: "area",
|
||||
align: "left" as const,
|
||||
width: "6%",
|
||||
render: (value: string) => value || '-'
|
||||
},
|
||||
{
|
||||
title: "优先级",
|
||||
key: "priority",
|
||||
align: "left" as const,
|
||||
width: "8%",
|
||||
width: "6%",
|
||||
render: (_: unknown, record: Rule) => {
|
||||
const priorityColor = RULE_PRIORITY_COLORS[record.priority] as TagColor;
|
||||
return (
|
||||
@@ -759,7 +742,7 @@ export default function RulesIndex() {
|
||||
title: "状态",
|
||||
key: "isActive",
|
||||
align: "left" as const,
|
||||
width: "8%",
|
||||
width: "6%",
|
||||
render: (_: unknown, record: Rule) => (
|
||||
<StatusDot status={record.isActive} text={record.isActive ? "启用" : "禁用"} />
|
||||
)
|
||||
@@ -775,7 +758,7 @@ export default function RulesIndex() {
|
||||
title: "操作",
|
||||
key: "operation",
|
||||
align: "left" as const,
|
||||
width: "10%",
|
||||
width: "150px",
|
||||
render: (_: unknown, record: Rule) => (
|
||||
<div className="operations-cell">
|
||||
{/* ✅ 查看/编辑和复制按钮 - 需要查看权限 */}
|
||||
@@ -943,6 +926,7 @@ export default function RulesIndex() {
|
||||
rowKey="id"
|
||||
// emptyText={loading ? "正在加载数据..." : "暂无评查点数据"}
|
||||
className="rules-table"
|
||||
scroll={{ y: 700 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1076,6 +1076,11 @@ export default function RuleNew() {
|
||||
loadedUrlRef.current = fullUrl;
|
||||
}, [location.search, location.pathname, fetchEvaluationPoint, fetchEvaluationPointGroups, fetchVlmFieldTypeOptions, resetFormData]);
|
||||
|
||||
// 处理返回按钮点击
|
||||
const handleBack = () => {
|
||||
navigate(-1);
|
||||
};
|
||||
|
||||
// 渲染页面内容
|
||||
return (
|
||||
<div className="container">
|
||||
@@ -1083,6 +1088,7 @@ export default function RuleNew() {
|
||||
<PageHeader
|
||||
title={isCopyMode ? "复制评查点" : (isEditMode ? (isReadOnly ? "查看评查点" : "编辑评查点") : "新增评查点")}
|
||||
onSave={handleSave}
|
||||
onBack={handleBack}
|
||||
showSaveButton={!isReadOnly}
|
||||
/>
|
||||
|
||||
|
||||
@@ -59,6 +59,43 @@
|
||||
@apply absolute inset-0 flex items-center justify-center bg-white bg-opacity-70;
|
||||
}
|
||||
|
||||
/* 固定表头滚动样式 */
|
||||
.ant-table-container {
|
||||
@apply w-full;
|
||||
}
|
||||
|
||||
.ant-table-header {
|
||||
@apply overflow-hidden;
|
||||
}
|
||||
|
||||
.ant-table-header .ant-table thead th {
|
||||
@apply bg-gray-50 font-medium py-3 px-4 text-left text-gray-700 border-b border-gray-200;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.ant-table-body {
|
||||
@apply overflow-auto;
|
||||
}
|
||||
|
||||
.ant-table-body::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.ant-table-body::-webkit-scrollbar-track {
|
||||
@apply bg-gray-100 rounded;
|
||||
}
|
||||
|
||||
.ant-table-body::-webkit-scrollbar-thumb {
|
||||
@apply bg-gray-300 rounded;
|
||||
}
|
||||
|
||||
.ant-table-body::-webkit-scrollbar-thumb:hover {
|
||||
@apply bg-gray-400;
|
||||
}
|
||||
|
||||
/* 表格行选中状态 */
|
||||
.ant-table tr.selected {
|
||||
@apply bg-[rgba(0,104,74,0.05)];
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
padding: 0;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.role-permissions-page .page-title {
|
||||
@@ -235,18 +236,23 @@
|
||||
.permissions-tab {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
height: 100%;
|
||||
justify-content: space-evenly;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* v3.8: 固定头部区域 */
|
||||
.permissions-tab-header {
|
||||
flex-shrink: 0;
|
||||
background: white;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid #e4e7ed;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.permissions-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-bottom: 6px;
|
||||
border-bottom: 1px solid #e4e7ed;
|
||||
}
|
||||
|
||||
.permissions-header h3 {
|
||||
@@ -256,79 +262,331 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 路由树 */
|
||||
.routes-tree {
|
||||
/* max-height: calc(100vh - 240px); */
|
||||
/* height: 400px; */
|
||||
/* v3.9: 折叠控制栏 */
|
||||
.collapse-controls {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.collapse-control-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
color: #606266;
|
||||
background: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.collapse-control-btn:hover {
|
||||
color: var(--color-primary);
|
||||
background: rgba(0, 104, 74, 0.04);
|
||||
}
|
||||
|
||||
.collapse-control-btn i {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 路由树容器 */
|
||||
.routes-tree-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
border: 1px solid #e4e7ed;
|
||||
border-radius: 6px;
|
||||
padding: 10px;
|
||||
background: #fafbfc;
|
||||
min-height: 0;
|
||||
padding: 4px;
|
||||
margin: -4px;
|
||||
}
|
||||
|
||||
.route-item {
|
||||
margin-bottom: 2px;
|
||||
/* 路由树 */
|
||||
.routes-tree {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* 一级路由卡片 - 使用柔和阴影效果 */
|
||||
.route-card {
|
||||
background: white;
|
||||
border: none;
|
||||
padding: 0 10px;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
transition: all 0.25s ease;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04), 0 1px 2px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.route-card:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 104, 74, 0.1), 0 2px 4px rgba(0, 0, 0, 0.04);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.route-card.checked {
|
||||
background: linear-gradient(to right, rgba(0, 104, 74, 0.03), rgba(255, 255, 255, 0.98));
|
||||
box-shadow: 0 2px 8px rgba(0, 104, 74, 0.12), 0 1px 3px rgba(0, 0, 0, 0.04);
|
||||
border-left: 2px solid var(--color-primary);
|
||||
}
|
||||
|
||||
/* 路由项内容 */
|
||||
.route-item-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
border-radius: 6px;
|
||||
padding: 14px 16px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.route-item-content:hover {
|
||||
background: #e6e8eb;
|
||||
background: #f5f7fa;
|
||||
}
|
||||
|
||||
.route-checkbox {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin-right: 10px;
|
||||
margin-right: 12px;
|
||||
cursor: pointer;
|
||||
accent-color: var(--color-primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.route-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.route-icon {
|
||||
font-size: 18px;
|
||||
font-size: 20px;
|
||||
color: var(--color-primary);
|
||||
width: 24px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.route-title {
|
||||
font-weight: 500;
|
||||
color: #303133;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.route-path {
|
||||
color: #909399;
|
||||
font-size: 13px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
background: #f5f7fa;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 权限展开按钮 */
|
||||
.permission-expand-btn {
|
||||
margin-left: auto;
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
transition: all 0.2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.permission-expand-btn:hover {
|
||||
filter: brightness(0.95);
|
||||
}
|
||||
|
||||
/* v3.9: 折叠按钮 */
|
||||
.collapse-btn {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
color: #909399;
|
||||
transition: all 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.collapse-btn:hover {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.collapse-btn i {
|
||||
font-size: 16px;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
/* 子路由数量标签 */
|
||||
.children-count {
|
||||
font-size: 11px;
|
||||
color: #909399;
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
/* 子路由容器 */
|
||||
.route-children {
|
||||
margin-top: 8px;
|
||||
margin-left: 0;
|
||||
padding: 3px 1px 12px 44px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
/* v3.9: 折叠动画 */
|
||||
max-height: 2000px;
|
||||
overflow: hidden;
|
||||
transition: max-height 0.3s ease-out, opacity 0.2s ease, padding 0.3s ease;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* v3.9: 折叠状态 */
|
||||
.route-children.collapsed {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
/* 子路由项 - 柔和的背景效果 */
|
||||
.route-children .route-item-content {
|
||||
padding: 10px 12px;
|
||||
border-radius: 6px;
|
||||
background: rgba(0, 0, 0, 0.015);
|
||||
border: none;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.route-children .route-item-content:hover {
|
||||
background: rgba(0, 0, 0, 0.035);
|
||||
}
|
||||
|
||||
.route-children .route-item-content.checked {
|
||||
background: rgba(0, 104, 74, 0.05);
|
||||
border-left: 2px solid var(--color-primary);
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
/* API权限列表 - 柔和背景 */
|
||||
.permissions-list {
|
||||
margin-top: 8px;
|
||||
margin-left: 44px;
|
||||
margin-right: 16px;
|
||||
margin-bottom: 12px;
|
||||
padding: 12px 16px;
|
||||
background: rgba(0, 0, 0, 0.02);
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.permission-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
margin: -2px -10px;
|
||||
}
|
||||
|
||||
.permission-item:hover {
|
||||
background: rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
.permission-item.shared {
|
||||
background: rgba(24, 144, 255, 0.06);
|
||||
border-left: 2px solid rgba(24, 144, 255, 0.5);
|
||||
margin-left: -12px;
|
||||
padding-left: 10px;
|
||||
border-radius: 0 6px 6px 0;
|
||||
}
|
||||
|
||||
.permission-item.shared:hover {
|
||||
background: rgba(24, 144, 255, 0.1);
|
||||
}
|
||||
|
||||
/* 权限方法标签 */
|
||||
.method-tag {
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
min-width: 52px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.method-tag.get { background: #e6f7ed; color: #52c41a; border: 1px solid #b7eb8f; }
|
||||
.method-tag.post { background: #e6f0ff; color: #1890ff; border: 1px solid #91caff; }
|
||||
.method-tag.put { background: #fff7e6; color: #faad14; border: 1px solid #ffd591; }
|
||||
.method-tag.delete { background: #fff1f0; color: #f5222d; border: 1px solid #ffa39e; }
|
||||
.method-tag.patch { background: #f0f5ff; color: #722ed1; border: 1px solid #d3adf7; }
|
||||
|
||||
/* 通用权限标签 */
|
||||
.shared-badge {
|
||||
display: inline-block;
|
||||
padding: 1px 6px;
|
||||
font-size: 11px;
|
||||
background: #e6f7ff;
|
||||
border: 1px solid #91d5ff;
|
||||
border-radius: 3px;
|
||||
color: #1890ff;
|
||||
font-weight: 500;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 关联路由显示 */
|
||||
.related-routes {
|
||||
margin-top: 4px;
|
||||
font-size: 11px;
|
||||
color: #8c8c8c;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.related-routes i {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.related-route-tag {
|
||||
padding: 2px 8px;
|
||||
background: #f0f0f0;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* 权限统计 - 固定底部 */
|
||||
.permissions-summary {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
background: #ecf5ff;
|
||||
margin-top: 16px;
|
||||
background: linear-gradient(to right, #ecf5ff, #f0f7ff);
|
||||
border: 1px solid #b3d8ff;
|
||||
border-radius: 6px;
|
||||
border-radius: 8px;
|
||||
color: #606266;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,499 @@
|
||||
# 文档更新接口详细文档
|
||||
|
||||
> 文件路径: `app/routes/postgrest.py` (PostgREST代理)
|
||||
>
|
||||
> 版本: v2.0 (RBAC-Enabled)
|
||||
|
||||
## 接口概述
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| **接口名称** | 更新文档信息 |
|
||||
| **请求方式** | `PATCH` |
|
||||
| **接口路径** | `/api/postgrest/proxy/documents` |
|
||||
| **认证方式** | JWT Bearer Token (完整签名验证) |
|
||||
| **权限要求** | 需要 `document:update` 权限 |
|
||||
|
||||
---
|
||||
|
||||
## 请求说明
|
||||
|
||||
### 请求头 (Headers)
|
||||
|
||||
| 参数名 | 类型 | 必填 | 说明 |
|
||||
|--------|------|------|------|
|
||||
| `Authorization` | string | 是 | Bearer Token,格式:`Bearer {jwt_token}` |
|
||||
| `Content-Type` | string | 是 | `application/json` |
|
||||
|
||||
### 查询参数 (Query Parameters)
|
||||
|
||||
| 参数名 | 类型 | 必填 | 说明 | 示例 |
|
||||
|--------|------|------|------|------|
|
||||
| `id` | string | 是 | 文档ID,PostgREST格式 | `eq.123` |
|
||||
|
||||
> **安全说明**: 系统会自动注入 `user_id` 过滤条件,确保用户只能更新自己的文档。
|
||||
|
||||
### 请求体 (Request Body)
|
||||
|
||||
```json
|
||||
{
|
||||
"document_number": "string", // 可选,文档编号
|
||||
"audit_status": 0, // 可选,审核状态
|
||||
"is_test_document": false, // 可选,是否测试文档
|
||||
"remark": "string" // 可选,备注信息
|
||||
}
|
||||
```
|
||||
|
||||
#### 可更新字段详情
|
||||
|
||||
| 字段名 | 类型 | 最大长度 | 说明 |
|
||||
|--------|------|----------|------|
|
||||
| `document_number` | varchar | 100 | 合同编号、许可证号等 |
|
||||
| `audit_status` | integer | - | 审核状态:0=待审核, 1=通过, 2=审核中, -1=不通过, -2=警告 |
|
||||
| `is_test_document` | boolean | - | 是否为测试文档,默认 false |
|
||||
| `remark` | text | 255 | 备注信息 |
|
||||
|
||||
---
|
||||
|
||||
## 响应说明
|
||||
|
||||
### 成功响应 (HTTP 200)
|
||||
|
||||
返回更新后的文档完整信息(数组格式):
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": 123,
|
||||
"user_id": 5,
|
||||
"type_id": 1,
|
||||
"name": "合同文档.pdf",
|
||||
"document_number": "HT-2024-001",
|
||||
"path": "documents/2024/01/abc123.pdf",
|
||||
"storage_type": "minio",
|
||||
"file_size": 1024000,
|
||||
"upload_time": "2024-01-15T10:30:00",
|
||||
"is_test_document": false,
|
||||
"evaluation_level": "普通",
|
||||
"status": "processed",
|
||||
"evaluations_status": 1,
|
||||
"audit_status": 1,
|
||||
"remark": "已审核通过",
|
||||
"created_at": "2024-01-15T10:30:00+08:00",
|
||||
"updated_at": "2024-01-16T14:20:00+08:00"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### 错误响应
|
||||
|
||||
#### 401 未授权
|
||||
|
||||
```json
|
||||
{
|
||||
"detail": "Token已过期"
|
||||
}
|
||||
```
|
||||
|
||||
或
|
||||
|
||||
```json
|
||||
{
|
||||
"detail": "无效的Token"
|
||||
}
|
||||
```
|
||||
|
||||
#### 403 权限不足
|
||||
|
||||
```json
|
||||
{
|
||||
"detail": "权限不足:需要 'document:update' 权限才能访问 'documents' 表"
|
||||
}
|
||||
```
|
||||
|
||||
#### 404 文档不存在或无权访问
|
||||
|
||||
```json
|
||||
[]
|
||||
```
|
||||
|
||||
> **注意**: 返回空数组表示没有匹配的记录(可能是文档不存在,或用户无权更新该文档)
|
||||
|
||||
---
|
||||
|
||||
## 数据库字段完整说明
|
||||
|
||||
### documents 表结构
|
||||
|
||||
| 字段名 | 类型 | 可空 | 默认值 | 说明 |
|
||||
|--------|------|------|--------|------|
|
||||
| `id` | integer | 否 | 自增 | 文档ID,主键 |
|
||||
| `user_id` | integer | 是 | - | 上传用户ID,外键→sso_users |
|
||||
| `type_id` | integer | 否 | - | 文档类型ID,外键→document_types |
|
||||
| `name` | varchar(255) | 否 | - | 原始文件名 |
|
||||
| `document_number` | varchar(100) | 是 | - | 合同编号/许可证号 |
|
||||
| `path` | varchar(255) | 否 | - | MinIO存储路径 |
|
||||
| `storage_type` | varchar(20) | 否 | 'minio' | 存储方式 |
|
||||
| `file_size` | integer | 是 | - | 文件大小(字节) |
|
||||
| `upload_time` | timestamp | 是 | CURRENT_TIMESTAMP | 上传时间 |
|
||||
| `is_test_document` | boolean | 是 | false | 是否测试文档 |
|
||||
| `evaluation_level` | varchar(255) | 是 | - | 评查级别 |
|
||||
| `status` | varchar(20) | 是 | 'waiting' | 处理状态 |
|
||||
| `ocr_result` | jsonb | 是 | - | OCR处理结果 |
|
||||
| `extracted_results` | jsonb | 是 | - | 内容抽取结果 |
|
||||
| `sumary` | text | 是 | - | 评查结果摘要 |
|
||||
| `evaluations_status` | integer | 是 | - | 评查状态 |
|
||||
| `audit_status` | integer | 是 | - | 审核状态 |
|
||||
| `error_massage` | varchar(500) | 是 | - | 错误信息 |
|
||||
| `remark` | text | 是 | - | 备注 |
|
||||
| `awareness_enabled` | boolean | 是 | false | 是否启用实体感知 |
|
||||
| `awareness_result` | jsonb | 是 | - | VLM实体感知结果 |
|
||||
| `awareness_execution_time` | double | 是 | - | 感知执行耗时(秒) |
|
||||
| `awareness_created_at` | timestamp | 是 | - | 感知执行时间 |
|
||||
| `original_word_path` | varchar(255) | 是 | - | 原始Word文件路径 |
|
||||
| `created_by` | integer | 是 | - | 创建者ID |
|
||||
| `created_at` | timestamptz | 是 | now() | 创建时间 |
|
||||
| `updated_at` | timestamptz | 是 | now() | 更新时间(自动触发器) |
|
||||
|
||||
---
|
||||
|
||||
## 权限与安全机制
|
||||
|
||||
### 1. JWT 验证流程
|
||||
|
||||
```
|
||||
请求 → JWT签名验证 → 有效期验证 → Audience验证 → 用户身份提取 → RBAC权限检查
|
||||
```
|
||||
|
||||
**验证内容**:
|
||||
- JWT签名验证:使用 `JWT_SECRET` 验证Token未被篡改
|
||||
- 有效期验证:检查 `exp` 字段确保Token未过期
|
||||
- Audience验证:检查 `aud` 字段匹配 `JWT_AUDIENCE`
|
||||
- 必填字段:验证 `user_id`, `username`, `user_role`, `exp` 存在
|
||||
|
||||
### 2. RBAC 权限检查
|
||||
|
||||
```python
|
||||
# 权限检查逻辑
|
||||
permission_key = PostgRESTRBACMapping.get_permission_key("documents", "PATCH")
|
||||
# → 返回 "document:update"
|
||||
|
||||
has_permission = await PermissionChecker.check_permission(
|
||||
user_id=user_id,
|
||||
permission_key="document:update"
|
||||
)
|
||||
```
|
||||
|
||||
### 3. 数据范围控制 (Data Scope)
|
||||
|
||||
| 角色 | 数据范围 | 说明 |
|
||||
|------|----------|------|
|
||||
| `provincial_admin` | ALL | 可访问所有文档,无过滤 |
|
||||
| `admin` | DEPT | 仅本地区文档 (area=用户地区) |
|
||||
| `user` | SELF | 仅本人文档 (user_id=当前用户ID) |
|
||||
|
||||
**系统自动注入过滤条件**:
|
||||
|
||||
```python
|
||||
# SELF 范围
|
||||
request_data.params["user_id"] = f"eq.{user_id}"
|
||||
|
||||
# DEPT 范围 (V2: 使用area而非ou_id)
|
||||
request_data.params["area"] = f"eq.{user_area}"
|
||||
```
|
||||
|
||||
### 4. 交叉评查权限
|
||||
|
||||
若用户参与交叉评查任务,可访问任务相关的跨地区文档:
|
||||
|
||||
```python
|
||||
# 获取用户可访问的交叉评查文档
|
||||
accessible_doc_ids = await CrossReviewPermission.get_user_accessible_documents(
|
||||
user_id=user_id,
|
||||
use_cache=True
|
||||
)
|
||||
|
||||
# PATCH请求:检查目标文档是否在交叉评查权限范围
|
||||
if target_doc_id in accessible_doc_ids:
|
||||
# 移除数据范围限制,允许更新
|
||||
del request_data.params["user_id"]
|
||||
```
|
||||
|
||||
### 5. 安全过滤
|
||||
|
||||
系统自动过滤危险参数,防止权限绕过:
|
||||
|
||||
```python
|
||||
DANGEROUS_PARAMS = ["select", "columns", "on_conflict", "resolution", ...]
|
||||
|
||||
# 自动移除危险参数
|
||||
for dangerous_param in DANGEROUS_PARAMS:
|
||||
if dangerous_param in request_data.params:
|
||||
del request_data.params[dangerous_param]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 调用示例
|
||||
|
||||
### cURL 示例
|
||||
|
||||
```bash
|
||||
curl -X PATCH \
|
||||
'https://api.example.com/api/postgrest/proxy/documents?id=eq.123' \
|
||||
-H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIs...' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"document_number": "HT-2024-001",
|
||||
"audit_status": 1,
|
||||
"remark": "审核通过"
|
||||
}'
|
||||
```
|
||||
|
||||
### TypeScript/JavaScript 示例
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* 更新文档信息
|
||||
* @param id 文档ID
|
||||
* @param document 部分文档数据
|
||||
* @param userId 用户ID
|
||||
* @param frontendJWT JWT Token
|
||||
* @returns 更新结果
|
||||
*/
|
||||
export async function updateDocument(
|
||||
id: string,
|
||||
document: Partial<DocumentUI> & { remark?: string },
|
||||
userId: string,
|
||||
frontendJWT?: string
|
||||
): Promise<{ data?: DocumentUI; error?: string; status?: number }> {
|
||||
try {
|
||||
if (!id) {
|
||||
return { error: '文档ID不能为空', status: 400 };
|
||||
}
|
||||
|
||||
if (!userId) {
|
||||
return { error: '用户身份验证失败', status: 401 };
|
||||
}
|
||||
|
||||
// 准备API数据 - 将UI数据转换为API格式
|
||||
const apiDocument: Partial<Document> = {};
|
||||
|
||||
if (document.documentNumber !== undefined) {
|
||||
apiDocument.document_number = document.documentNumber;
|
||||
}
|
||||
|
||||
if (document.auditStatus !== undefined) {
|
||||
apiDocument.audit_status = document.auditStatus;
|
||||
}
|
||||
|
||||
if (document.isTest !== undefined) {
|
||||
apiDocument.is_test_document = document.isTest;
|
||||
}
|
||||
|
||||
if (document.remark !== undefined) {
|
||||
apiDocument.remark = document.remark;
|
||||
}
|
||||
|
||||
// 发送PATCH请求
|
||||
const response = await postgrestPut<Document, Partial<Document>>(
|
||||
'/api/postgrest/proxy/documents',
|
||||
apiDocument,
|
||||
{
|
||||
id: parseInt(id),
|
||||
user_id: parseInt(userId) // 确保只能更新自己的文档
|
||||
},
|
||||
frontendJWT
|
||||
);
|
||||
|
||||
if (response.error) {
|
||||
console.error('更新文档API错误:', response.error);
|
||||
return { error: response.error, status: response.status };
|
||||
}
|
||||
|
||||
// 获取更新后的完整文档数据
|
||||
const updatedResponse = await getDocument(id, userId, frontendJWT);
|
||||
|
||||
return updatedResponse;
|
||||
} catch (error) {
|
||||
console.error('更新文档信息失败:', error);
|
||||
return {
|
||||
error: error instanceof Error ? error.message : '更新文档信息失败',
|
||||
status: 500
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Python 示例
|
||||
|
||||
```python
|
||||
import httpx
|
||||
|
||||
async def update_document(
|
||||
document_id: int,
|
||||
update_data: dict,
|
||||
jwt_token: str
|
||||
) -> dict:
|
||||
"""
|
||||
更新文档信息
|
||||
|
||||
Args:
|
||||
document_id: 文档ID
|
||||
update_data: 要更新的字段
|
||||
jwt_token: JWT Token
|
||||
|
||||
Returns:
|
||||
更新后的文档数据
|
||||
"""
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.patch(
|
||||
f"https://api.example.com/api/postgrest/proxy/documents",
|
||||
params={"id": f"eq.{document_id}"},
|
||||
json=update_data,
|
||||
headers={
|
||||
"Authorization": f"Bearer {jwt_token}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
return {"success": True, "data": response.json()}
|
||||
else:
|
||||
return {"success": False, "error": response.text}
|
||||
|
||||
# 使用示例
|
||||
result = await update_document(
|
||||
document_id=123,
|
||||
update_data={
|
||||
"document_number": "HT-2024-001",
|
||||
"audit_status": 1,
|
||||
"remark": "审核通过"
|
||||
},
|
||||
jwt_token="eyJhbGciOiJIUzI1NiIs..."
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 状态枚举值
|
||||
|
||||
### status (处理状态)
|
||||
|
||||
| 值 | 说明 | 触发条件 |
|
||||
|----|------|----------|
|
||||
| `waiting` | 上传后默认状态,等待处理 | 文档上传完成 |
|
||||
| `Cutting` | 切分+OCR处理中 | OCR任务开始 |
|
||||
| `extractioning` | 大模型抽取信息中 | 信息抽取任务开始 |
|
||||
| `evaluationing` | 评查中 | 评查任务开始 |
|
||||
| `processed` | 处理完成,等待审核 | 所有处理完成 |
|
||||
|
||||
### audit_status (审核状态)
|
||||
|
||||
| 值 | 说明 | 操作权限 |
|
||||
|----|------|----------|
|
||||
| 0 | 待审核 | 等待审核员处理 |
|
||||
| 1 | 通过 | 审核通过,流程结束 |
|
||||
| 2 | 审核中 | 审核员正在处理 |
|
||||
| -1 | 不通过 | 需要修改后重新提交 |
|
||||
| -2 | 警告 | 有问题但可继续流程 |
|
||||
|
||||
### evaluations_status (评查状态)
|
||||
|
||||
| 值 | 说明 | 后续操作 |
|
||||
|----|------|----------|
|
||||
| 1 | 通过 | 无需人工干预 |
|
||||
| 0 | 待人工确认 | 需要人工审核确认 |
|
||||
| -1 | 不通过 | 存在严重问题 |
|
||||
| -2 | 警告 | 存在轻微问题 |
|
||||
|
||||
---
|
||||
|
||||
## 后端处理流程
|
||||
|
||||
```
|
||||
1. 接收 PATCH 请求
|
||||
↓
|
||||
2. JWT 完整验证 (签名+有效期+Audience)
|
||||
↓
|
||||
3. 提取用户信息 (user_id, username, user_role, area)
|
||||
↓
|
||||
4. RBAC 权限检查 (document:update)
|
||||
↓
|
||||
5. 安全过滤 (移除危险参数)
|
||||
↓
|
||||
6. 数据范围注入 (根据角色添加过滤条件)
|
||||
↓
|
||||
7. 交叉评查权限检查 (如适用)
|
||||
↓
|
||||
8. 转发请求到 PostgREST
|
||||
↓
|
||||
9. 返回更新结果
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **自动更新时间**: `updated_at` 字段由数据库触发器 `update_documents_updated_at` 自动更新,无需手动传入
|
||||
|
||||
2. **字段长度限制**:
|
||||
- `document_number`: 最大100字符
|
||||
- `remark`: 前端限制为255字符
|
||||
- `error_massage`: 最大500字符
|
||||
|
||||
3. **只读字段**: 以下字段不可通过此接口修改
|
||||
- `id`, `user_id`, `type_id`
|
||||
- `name`, `path`, `storage_type`, `file_size`
|
||||
- `upload_time`, `created_at`
|
||||
- `ocr_result`, `extracted_results` (由系统处理任务更新)
|
||||
|
||||
4. **权限隔离**: 普通用户只能更新自己上传的文档,系统会强制校验 `user_id`
|
||||
|
||||
5. **PostgREST 格式**: 查询参数需使用 PostgREST 格式
|
||||
- 等于: `eq.值` (如 `id=eq.123`)
|
||||
- 不等于: `neq.值`
|
||||
- 大于: `gt.值`
|
||||
- 包含: `in.(值1,值2,值3)`
|
||||
|
||||
---
|
||||
|
||||
## 审计日志
|
||||
|
||||
所有更新操作都会记录审计日志:
|
||||
|
||||
```python
|
||||
await AuditLogger.log_permission_check(
|
||||
user_id=user_id,
|
||||
permission_key="document:update",
|
||||
is_success=True,
|
||||
ip_address=client_ip,
|
||||
user_agent=user_agent,
|
||||
context={
|
||||
'table': 'documents',
|
||||
'method': 'PATCH',
|
||||
'path': 'documents'
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 错误码说明
|
||||
|
||||
| HTTP状态码 | 错误类型 | 说明 | 解决方案 |
|
||||
|-----------|---------|------|---------|
|
||||
| 400 | Bad Request | 请求参数错误 | 检查请求体格式和字段类型 |
|
||||
| 401 | Unauthorized | Token无效或过期 | 重新登录获取新Token |
|
||||
| 403 | Forbidden | 权限不足 | 联系管理员分配权限 |
|
||||
| 404 | Not Found | 文档不存在 | 检查document_id是否正确 |
|
||||
| 500 | Internal Error | 服务器内部错误 | 查看服务器日志 |
|
||||
|
||||
---
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [PostgREST代理说明](../14_postgrest_proxy/README.md)
|
||||
- [RBAC权限系统](../../rbac/README.md)
|
||||
- [文档管理概述](./README.md)
|
||||
- [交叉评查权限](../../rbac/cross_review_permissions_design.md)
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Generated
-27609
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,4 @@
|
||||
ignoredBuiltDependencies:
|
||||
- canvas
|
||||
- esbuild
|
||||
- unrs-resolver
|
||||
@@ -71,14 +71,12 @@ export default defineConfig({
|
||||
// 预构建这些依赖,避免首次加载时出现重新构建
|
||||
include: [
|
||||
'react-pdf',
|
||||
'pdfjs-dist',
|
||||
'dayjs',
|
||||
'@remix-run/node',
|
||||
'react-dom',
|
||||
'axios',
|
||||
'dayjs/plugin/utc',
|
||||
'@remix-run/react',
|
||||
'react-router-dom',
|
||||
'jszip',
|
||||
'ahooks',
|
||||
'antd',
|
||||
|
||||
Reference in New Issue
Block a user