feat: 1. 完善全局路由的访问权限的验证。 2. 完善接口返回的树形路由结构 3.优化评查点列表的查询,改用表连接的方式,废弃使用数据库的rpc函数,同时进行地区隔离和权限隔离。
4. 删除冗余的评查文件列表。 5.完善上传文档 页面初始化查询数据的时候 查询文件类型(改成动态指定) 6. 添加获取入口模块的查询接口。 7.完善服务端中判断token的有效性,失效则跳转到登录页。 8. 重构layout和sidebar的页面,改成由动态权限路由来渲染对应的菜单栏。 9.重构入口页面,通过动态查询根据不同地区的人返回不同的入口。
This commit is contained in:
+71
-10
@@ -493,7 +493,7 @@ const FALLBACK_MENU_DATA: Record<string, MenuItem[]> = {
|
||||
*/
|
||||
export async function getUserRoutesByRole(roleKey: string, jwt?: string, includeHidden: boolean = false): Promise<{ success: boolean; data?: MenuItem[]; error?: string; shouldRedirectToHome?: boolean }> {
|
||||
try {
|
||||
console.log(`🔍 [User Routes] 获取用户路由,角色: ${roleKey}`);
|
||||
// console.log(`🔍 [User Routes] 获取用户路由,角色: ${roleKey}`);
|
||||
|
||||
if (!jwt) {
|
||||
console.error('❌ [User Routes] JWT token 未提供');
|
||||
@@ -554,13 +554,15 @@ export async function getUserRoutesByRole(roleKey: string, jwt?: string, include
|
||||
return { success: false, error: "用户没有分配任何路由权限", shouldRedirectToHome: true };
|
||||
}
|
||||
|
||||
// console.log('📋 [User Routes] 菜单数据:', routes);
|
||||
// console.log('🔍 [User Routes] 后端返回的原始路由数据:', JSON.stringify(routes, null, 2));
|
||||
// console.log('🔍 [User Routes] 检查第一个路由是否有children:', routes[0]?.children);
|
||||
|
||||
// 将后端路由格式转换为前端 MenuItem 格式
|
||||
const menuItems = convertBackendRoutesToMenuItems(routes, includeHidden);
|
||||
|
||||
// console.log(`✅ [User Routes] 成功获取 ${menuItems.length} 个路由 (includeHidden: ${includeHidden})`);
|
||||
// console.log('📋 [User Routes] 菜单数据:', menuItems);
|
||||
// console.log(`✅ [User Routes] 转换后得到 ${menuItems.length} 个菜单项 (includeHidden: ${includeHidden})`);
|
||||
// console.log('🔍 [User Routes] 转换后的菜单数据:', JSON.stringify(menuItems, null, 2));
|
||||
// console.log('🔍 [User Routes] 检查第一个菜单项是否有children:', menuItems[0]?.children);
|
||||
|
||||
return { success: true, data: menuItems };
|
||||
|
||||
@@ -608,12 +610,40 @@ function convertIcon(elementIcon: string | null): string {
|
||||
return ICON_MAPPING[elementIcon] || 'ri-file-line';
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归提取所有路由(包括嵌套的子路由)为平铺数组
|
||||
* @param routes 路由数组(可能包含嵌套的 children)
|
||||
* @returns 平铺的路由数组
|
||||
*/
|
||||
function flattenRoutes(routes: BackendRouteInfo[]): BackendRouteInfo[] {
|
||||
const flattened: BackendRouteInfo[] = [];
|
||||
|
||||
function traverse(routeList: BackendRouteInfo[]) {
|
||||
for (const route of routeList) {
|
||||
// 添加当前路由(不带 children,因为我们要重新构建)
|
||||
const { children, ...routeWithoutChildren } = route;
|
||||
flattened.push(routeWithoutChildren);
|
||||
|
||||
// 递归处理子路由
|
||||
if (children && children.length > 0) {
|
||||
traverse(children);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
traverse(routes);
|
||||
// console.log('🔄 [flattenRoutes] 平铺后的路由数量:', flattened.length);
|
||||
return flattened;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将平铺的路由数组构建为树形结构
|
||||
* @param routes 平铺的路由数组
|
||||
* @returns 树形结构的路由数组
|
||||
*/
|
||||
function buildRouteTree(routes: BackendRouteInfo[]): BackendRouteInfo[] {
|
||||
// console.log('🌲 [buildRouteTree] 开始构建树,接收到的路由数量:', routes.length);
|
||||
|
||||
// 创建路由映射
|
||||
const routeMap = new Map<number, BackendRouteInfo>();
|
||||
const rootRoutes: BackendRouteInfo[] = [];
|
||||
@@ -641,12 +671,13 @@ function buildRouteTree(routes: BackendRouteInfo[]): BackendRouteInfo[] {
|
||||
parentRoute.children.push(currentRoute);
|
||||
} else {
|
||||
// 如果找不到父路由,当作顶级路由处理
|
||||
// console.warn(`⚠️ [User Routes] 找不到父路由 (parent_id: ${route.parent_id}) for route: ${route.route_name}`);
|
||||
// console.warn(`⚠️ [buildRouteTree] 找不到父路由 (parent_id: ${route.parent_id}) for route: ${route.route_name}`);
|
||||
rootRoutes.push(currentRoute);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// console.log('🌲 [buildRouteTree] 构建完成,根路由数量:', rootRoutes.length);
|
||||
return rootRoutes;
|
||||
}
|
||||
|
||||
@@ -659,20 +690,43 @@ function buildRouteTree(routes: BackendRouteInfo[]): BackendRouteInfo[] {
|
||||
* @returns MenuItem 数组
|
||||
*/
|
||||
function convertBackendRoutesToMenuItems(backendRoutes: BackendRouteInfo[], includeHidden: boolean = false): MenuItem[] {
|
||||
// console.log('🔄 [convertBackendRoutesToMenuItems] 开始转换,接收到的路由数量:', backendRoutes.length);
|
||||
// console.log('🔄 [convertBackendRoutesToMenuItems] includeHidden:', includeHidden);
|
||||
// console.log('🔄 [convertBackendRoutesToMenuItems] 接收到的路由数据:', JSON.stringify(backendRoutes, null, 2));
|
||||
|
||||
// 检查是否需要构建树形结构
|
||||
// 如果存在 parent_id 不为 null/0 但没有对应的父路由在 children 中,说明是平铺数组
|
||||
// 如果存在 parent_id 不为 null/0 但没有对应的父路由在 children 中,说明需要重建树
|
||||
const needsBuildTree = backendRoutes.some(route =>
|
||||
route.parent_id !== null &&
|
||||
route.parent_id !== 0 &&
|
||||
!backendRoutes.some(r => r.children?.some(c => c.id === route.id))
|
||||
);
|
||||
|
||||
// 如果是平铺数组,先构建树形结构
|
||||
const treeRoutes = needsBuildTree ? buildRouteTree(backendRoutes) : backendRoutes;
|
||||
// console.log('🔄 [convertBackendRoutesToMenuItems] needsBuildTree:', needsBuildTree);
|
||||
|
||||
return treeRoutes
|
||||
.filter(route => includeHidden || !route.is_hidden) // 根据 includeHidden 决定是否过滤隐藏路由
|
||||
let treeRoutes: BackendRouteInfo[];
|
||||
if (needsBuildTree) {
|
||||
// 🔑 关键修复:先平铺所有路由(包括嵌套的 children),再重新构建树
|
||||
// console.log('🔄 [convertBackendRoutesToMenuItems] 检测到混合格式,先平铺再重建树...');
|
||||
const flattenedRoutes = flattenRoutes(backendRoutes);
|
||||
treeRoutes = buildRouteTree(flattenedRoutes);
|
||||
} else {
|
||||
// 后端已经返回正确的树形结构,直接使用
|
||||
// console.log('🔄 [convertBackendRoutesToMenuItems] 后端返回的是完整树形结构,直接使用');
|
||||
treeRoutes = backendRoutes;
|
||||
}
|
||||
|
||||
// console.log('🔄 [convertBackendRoutesToMenuItems] 构建树后的路由数据:', JSON.stringify(treeRoutes, null, 2));
|
||||
|
||||
const result = treeRoutes
|
||||
.filter(route => {
|
||||
const shouldInclude = includeHidden || !route.is_hidden;
|
||||
// console.log(`🔄 [convertBackendRoutesToMenuItems] 过滤路由 ${route.route_name}: is_hidden=${route.is_hidden}, includeHidden=${includeHidden}, 结果=${shouldInclude}`);
|
||||
return shouldInclude;
|
||||
})
|
||||
.map(route => {
|
||||
// console.log(`🔄 [convertBackendRoutesToMenuItems] 处理路由 ${route.route_name}, children数量: ${route.children?.length || 0}`);
|
||||
|
||||
const menuItem: MenuItem = {
|
||||
id: route.route_name || `route-${route.id}`,
|
||||
title: route.route_title,
|
||||
@@ -684,12 +738,19 @@ function convertBackendRoutesToMenuItems(backendRoutes: BackendRouteInfo[], incl
|
||||
|
||||
// 递归处理子路由,传递 includeHidden 参数
|
||||
if (route.children && route.children.length > 0) {
|
||||
// console.log(`🔄 [convertBackendRoutesToMenuItems] ${route.route_name} 有 ${route.children.length} 个子路由,递归处理...`);
|
||||
menuItem.children = convertBackendRoutesToMenuItems(route.children, includeHidden);
|
||||
// console.log(`🔄 [convertBackendRoutesToMenuItems] ${route.route_name} 转换后的children:`, menuItem.children);
|
||||
}
|
||||
|
||||
return menuItem;
|
||||
})
|
||||
.sort((a, b) => a.order - b.order); // 按 sort_order 排序
|
||||
|
||||
// console.log('🔄 [convertBackendRoutesToMenuItems] 转换完成,返回的菜单项数量:', result.length);
|
||||
// console.log('🔄 [convertBackendRoutesToMenuItems] 返回的菜单数据:', JSON.stringify(result, null, 2));
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
|
||||
+16
-2
@@ -106,6 +106,13 @@ axiosInstance.interceptors.response.use(
|
||||
if (isAxiosError(error) && error.response?.status === 401) {
|
||||
// Token 过期或无效
|
||||
console.warn('⚠️ Token 已过期或无效,请重新登录');
|
||||
console.warn('⚠️ 401 错误详情:', {
|
||||
url: error.config?.url,
|
||||
status: error.response?.status,
|
||||
statusText: error.response?.statusText,
|
||||
data: error.response?.data,
|
||||
headers: error.response?.headers
|
||||
});
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
// 🌐 客户端环境:清除 localStorage 并跳转
|
||||
@@ -328,13 +335,20 @@ export async function apiRequest<T>(
|
||||
// 确保使用默认超时时间
|
||||
timeout: options.timeout || DEFAULT_TIMEOUT
|
||||
};
|
||||
|
||||
// 🔍 调试:打印 Authorization 头
|
||||
if (headers['Authorization']) {
|
||||
// console.log('🔑 [apiRequest] 请求包含 Authorization 头:', headers['Authorization'].substring(0, 20) + '...');
|
||||
} else {
|
||||
console.warn('⚠️ [apiRequest] 请求缺少 Authorization 头!headers:', Object.keys(headers));
|
||||
}
|
||||
// console.log(`📦 axios-client.ts->请求配置: \n${JSON.stringify(config)}`);
|
||||
|
||||
|
||||
// 删除body属性,避免axios警告
|
||||
if ('body' in config) {
|
||||
delete (config as ExtendedAxiosRequestConfig).body;
|
||||
}
|
||||
|
||||
|
||||
// 使用带重试功能的请求方法
|
||||
const response: AxiosResponse = await axiosRetry(config);
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { postgrestPut, postgrestPost } from '../postgrest-client';
|
||||
import { formatDate } from '../../utils';
|
||||
import { postgrestPut } from '../postgrest-client';
|
||||
|
||||
// 文档数据库表接口
|
||||
export interface Document {
|
||||
@@ -62,32 +61,6 @@ export interface ReviewFileUI {
|
||||
manualCount: number;
|
||||
}
|
||||
|
||||
// 数据库函数返回的评查文件结构
|
||||
interface ReviewFileFromSQL {
|
||||
id: number;
|
||||
status: string;
|
||||
path: string;
|
||||
file_name: string;
|
||||
file_code: string;
|
||||
file_type_name: string;
|
||||
file_type_id: number;
|
||||
file_size: number;
|
||||
upload_time: string;
|
||||
created_at: string;
|
||||
evaluations_status: number;
|
||||
audit_status: number | null;
|
||||
created_by_user_id: number | null;
|
||||
issue_count: number;
|
||||
total_score: number;
|
||||
pass_count: number;
|
||||
warning_count: number;
|
||||
fail_count: number;
|
||||
manual_count: number;
|
||||
issues: Array<{
|
||||
severity: 'info' | 'warning' | 'error' | 'critical';
|
||||
message: string;
|
||||
}> | null;
|
||||
}
|
||||
|
||||
// 文件列表搜索参数
|
||||
export interface DocumentSearchParams {
|
||||
@@ -133,183 +106,7 @@ export function mapUIToReviewStatus(status: string): number {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件扩展名
|
||||
* @param fileName 文件名
|
||||
* @returns 文件扩展名
|
||||
*/
|
||||
export function getFileExtension(fileName: string): string {
|
||||
return fileName.split('.').pop()?.toLowerCase() || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取评查文件列表
|
||||
* @param searchParams 搜索参数
|
||||
* @param documentIds 文档ID数组(可选)
|
||||
* @param userId 用户ID
|
||||
* @returns 评查文件列表和总数
|
||||
*/
|
||||
export async function getReviewFiles(searchParams: DocumentSearchParams = {}, documentIds: number[] | null = null, userId?: string): Promise<{
|
||||
data?: { files: ReviewFileUI[], total: number };
|
||||
error?: string;
|
||||
status?: number;
|
||||
}> {
|
||||
try {
|
||||
// 确保userId必须存在,如果不存在则抛出错误
|
||||
if (!userId) {
|
||||
return { error: '用户身份验证失败,无法获取评查文件列表', status: 401 };
|
||||
}
|
||||
|
||||
const {
|
||||
page = 1,
|
||||
pageSize = 10,
|
||||
keyword,
|
||||
fileType, // sessionStorage.getItem('reviewType')
|
||||
reviewStatus,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
sortOrder = 'upload_time_desc'
|
||||
} = searchParams;
|
||||
|
||||
let p_typeid: number[] | null = null;
|
||||
if (fileType) {
|
||||
if (fileType === 'record') {
|
||||
p_typeid = [2, 3, 155];
|
||||
} else if (fileType === 'contract') {
|
||||
p_typeid = [1];
|
||||
} else {
|
||||
const typeId = parseInt(fileType, 10);
|
||||
if (!isNaN(typeId)) {
|
||||
p_typeid = [typeId];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const rpcParams = {
|
||||
p_keyword: keyword || null,
|
||||
p_typeid: p_typeid,
|
||||
p_evaluations_status: reviewStatus ? mapUIToReviewStatus(reviewStatus) : null,
|
||||
p_date_from: dateFrom || null,
|
||||
p_date_to: dateTo || null,
|
||||
p_document_ids: documentIds || null,
|
||||
p_user_id: parseInt(userId, 10), // 强制要求传递用户ID
|
||||
};
|
||||
|
||||
const listParams = {
|
||||
...rpcParams,
|
||||
p_page: page,
|
||||
p_page_size: pageSize,
|
||||
p_sort_order: sortOrder
|
||||
};
|
||||
|
||||
// 并行执行获取数据和获取总数的请求
|
||||
const [filesResponse, countResponse] = await Promise.all([
|
||||
postgrestPost<ReviewFileFromSQL[]>('rpc/get_review_files_with_details', listParams),
|
||||
postgrestPost<number>('rpc/count_review_files', rpcParams)
|
||||
]);
|
||||
|
||||
// 处理获取文档列表的错误
|
||||
if (filesResponse.error || !filesResponse.data) {
|
||||
return { error: filesResponse.error || '获取文档数据失败', status: filesResponse.status || 500 };
|
||||
}
|
||||
|
||||
// 处理获取总数的错误
|
||||
if (countResponse.error || typeof countResponse.data !== 'number') {
|
||||
console.error('获取文档总数失败:', countResponse.error);
|
||||
}
|
||||
|
||||
const totalCount = typeof countResponse.data === 'number' ? countResponse.data : 0;
|
||||
|
||||
// 将SQL返回的数据转换为UI格式
|
||||
const reviewFiles: ReviewFileUI[] = filesResponse.data.map((file: ReviewFileFromSQL) => ({
|
||||
id: file.id.toString(),
|
||||
status: file.status,
|
||||
path: file.path,
|
||||
fileName: file.file_name,
|
||||
fileCode: file.file_code,
|
||||
fileType: file.file_type_name,
|
||||
fileTypeId: file.file_type_id,
|
||||
fileSize: file.file_size,
|
||||
uploadTime: formatDate(file.created_at),
|
||||
reviewStatus: mapReviewStatusToUI(file.evaluations_status),
|
||||
reviewStatusCode: file.evaluations_status,
|
||||
issueCount: file.issue_count,
|
||||
score: file.total_score,
|
||||
auditStatus: file.audit_status,
|
||||
issues: file.issues || [],
|
||||
createdBy: file.created_by_user_id?.toString() || '系统',
|
||||
passCount: file.pass_count,
|
||||
warningCount: file.warning_count,
|
||||
failCount: file.fail_count,
|
||||
manualCount: file.manual_count,
|
||||
}));
|
||||
|
||||
return {
|
||||
data: {
|
||||
files: reviewFiles,
|
||||
total: totalCount
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('获取评查文件列表失败:', error);
|
||||
return {
|
||||
error: error instanceof Error ? error.message : '获取评查文件列表失败',
|
||||
status: 500
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新文件的评查状态
|
||||
* @param id 文件ID
|
||||
* @param status 评查状态
|
||||
* @returns 更新后的文件信息
|
||||
*/
|
||||
// export async function updateReviewStatus(id: string, status: string): Promise<{
|
||||
// data?: ReviewFileUI;
|
||||
// error?: string;
|
||||
// status?: number;
|
||||
// }> {
|
||||
// try {
|
||||
// if (!id) {
|
||||
// return { error: '文件ID不能为空', status: 400 };
|
||||
// }
|
||||
|
||||
// const statusValue = mapUIToReviewStatus(status);
|
||||
|
||||
// const response = await postgrestPut<Document, Partial<Document>>(
|
||||
// 'documents',
|
||||
// { evaluations_status: statusValue },
|
||||
// { id: parseInt(id) }
|
||||
// );
|
||||
|
||||
// if (response.error) {
|
||||
// return { error: response.error, status: response.status };
|
||||
// }
|
||||
|
||||
// const extractedData = extractApiData<Document>(response.data);
|
||||
|
||||
// if (!extractedData) {
|
||||
// return { error: '更新评查状态失败', status: 500 };
|
||||
// }
|
||||
|
||||
// // 获取文档类型,用于查找文档类型名称
|
||||
// const documentTypesResponse = await getDocumentTypes({pageSize: 500});
|
||||
// const documentTypes = documentTypesResponse.data?.types || [];
|
||||
|
||||
// // 查找文档类型名称
|
||||
// const docType = documentTypes.find((type: DocumentTypeUI) => type.id === extractedData.type_id);
|
||||
// const typeName = docType ? docType.name : '未知类型';
|
||||
|
||||
// return { data: convertToReviewFileUI(extractedData, typeName) };
|
||||
// } catch (error) {
|
||||
// console.error('更新评查状态失败:', error);
|
||||
// return {
|
||||
// error: error instanceof Error ? error.message : '更新评查状态失败',
|
||||
// status: 500
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
/**
|
||||
* 更新文件的审核状态
|
||||
|
||||
+212
-201
@@ -32,9 +32,10 @@ export interface RulesQueryParams {
|
||||
groupId?: string; // 规则组ID
|
||||
isActive?: boolean;
|
||||
keyword?: string;
|
||||
area?: string; // 地区过滤
|
||||
orderBy?: string;
|
||||
orderDirection?: 'asc' | 'desc';
|
||||
reviewType?: string; // 添加 reviewType 参数,值为 contract 或 record
|
||||
userRole?: string; // 用户角色
|
||||
token?: string; // JWT token
|
||||
}
|
||||
|
||||
@@ -53,6 +54,7 @@ export interface ApiRule {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
area?: string; // 地区
|
||||
evaluation_point_groups_id: number | null;
|
||||
risk: string;
|
||||
description: string;
|
||||
@@ -61,6 +63,15 @@ export interface ApiRule {
|
||||
first_name: string;
|
||||
second_name: string;
|
||||
};
|
||||
// 🆕 PostgREST 双连接查询返回的字段
|
||||
child_group?: {
|
||||
id: number;
|
||||
name: string;
|
||||
} | null;
|
||||
parent_group?: {
|
||||
id: number;
|
||||
name: string;
|
||||
} | null;
|
||||
references_laws: Record<string, unknown>;
|
||||
extraction_config: {
|
||||
type: string;
|
||||
@@ -118,27 +129,42 @@ function mapApiRuleToFrontendModel(apiRule: ApiRule): Rule {
|
||||
'低': 'low'
|
||||
};
|
||||
|
||||
//评查点类型映射
|
||||
// 🆕 优先使用 PostgREST 双连接查询返回的数据
|
||||
let ruleType = '';
|
||||
let groupName = '';
|
||||
let groupId = '';
|
||||
|
||||
// 规则类型映射(这里根据实际业务逻辑设置一个默认值)
|
||||
// const ruleType = 'essential'; // 实际应用中可能需要从其他字段推断
|
||||
// console.log("apiRule.evaluation_point_groups",apiRule);
|
||||
|
||||
// 如果evaluation_point_groups_id为null或空,则不显示ruleType和groupName
|
||||
const isGroupIdEmpty = !apiRule.evaluation_point_groups_id;
|
||||
|
||||
// 规则类型只在有分组ID时才显示
|
||||
const ruleType = isGroupIdEmpty ? '' : (apiRule.evaluation_point_groups?.first_name || '');
|
||||
if (apiRule.child_group || apiRule.parent_group) {
|
||||
// 有 PostgREST 双连接查询数据(外键约束方式)
|
||||
groupId = apiRule.child_group?.id.toString() || '';
|
||||
groupName = apiRule.child_group?.name || '';
|
||||
ruleType = apiRule.parent_group?.name || '';
|
||||
} else if (apiRule.evaluation_point_groups) {
|
||||
// 兼容旧的查询方式(RPC 函数或手动拼接)
|
||||
const isGroupIdEmpty = !apiRule.evaluation_point_groups_id;
|
||||
ruleType = isGroupIdEmpty ? '' : (apiRule.evaluation_point_groups.first_name || '');
|
||||
groupName = isGroupIdEmpty ? '' : (apiRule.evaluation_point_groups.second_name || `${apiRule.evaluation_point_groups_id}`);
|
||||
groupId = isGroupIdEmpty ? '' : apiRule.evaluation_point_groups_id?.toString() || '';
|
||||
} else {
|
||||
// 没有任何分组信息
|
||||
const isGroupIdEmpty = !apiRule.evaluation_point_groups_id;
|
||||
groupId = isGroupIdEmpty ? '' : apiRule.evaluation_point_groups_id?.toString() || '';
|
||||
}
|
||||
|
||||
// 🔑 清洗评查点编码:移除最后一个 '--' 及其后面的字符
|
||||
// 例如:'code-mis--mz' --> 'code-mis', 'code-mbs--alsi--gz' --> 'code-mbs--alsi'
|
||||
let cleanedCode = apiRule.code || '';
|
||||
const lastDoubleHyphenIndex = cleanedCode.lastIndexOf('--');
|
||||
if (lastDoubleHyphenIndex !== -1) {
|
||||
cleanedCode = cleanedCode.substring(0, lastDoubleHyphenIndex);
|
||||
}
|
||||
|
||||
// 规则组名称只在有分组ID时才显示
|
||||
const groupName = isGroupIdEmpty ? '' : (apiRule.evaluation_point_groups?.second_name || `${apiRule.evaluation_point_groups_id}`);
|
||||
|
||||
return {
|
||||
id: apiRule.id ? apiRule.id.toString() : '', // 添加空值检查
|
||||
code: apiRule.code || '',
|
||||
id: apiRule.id ? apiRule.id.toString() : '',
|
||||
code: cleanedCode,
|
||||
name: apiRule.name || '',
|
||||
ruleType: ruleType,
|
||||
groupId: isGroupIdEmpty ? '' : apiRule.evaluation_point_groups_id?.toString() || '',
|
||||
groupId: groupId,
|
||||
groupName: groupName,
|
||||
priority: priorityMap[apiRule.risk] || 'medium',
|
||||
description: apiRule.description || '',
|
||||
@@ -156,40 +182,63 @@ function mapApiRuleToFrontendModel(apiRule: ApiRule): Rule {
|
||||
export async function getRulesList(params: RulesQueryParams): Promise<{data: RulesListResponse; error?: never} | {data?: never; error: string; status?: number}> {
|
||||
try {
|
||||
// 解构并设置默认值
|
||||
const {
|
||||
page = 1,
|
||||
pageSize = 10,
|
||||
ruleType,
|
||||
groupId,
|
||||
isActive,
|
||||
const {
|
||||
page = 1,
|
||||
pageSize = 10,
|
||||
ruleType,
|
||||
groupId,
|
||||
isActive,
|
||||
keyword,
|
||||
area,
|
||||
orderBy = 'created_at',
|
||||
orderDirection = 'desc',
|
||||
reviewType,
|
||||
userRole,
|
||||
token
|
||||
} = params;
|
||||
|
||||
// 🔑 如果没有传递 userRole,尝试从 localStorage 中获取
|
||||
let user_role = ''
|
||||
if (!userRole && typeof window !== 'undefined' && window.localStorage) {
|
||||
try {
|
||||
const userInfoStr = localStorage.getItem('user_info');
|
||||
if (userInfoStr) {
|
||||
const userInfo = JSON.parse(userInfoStr);
|
||||
user_role = userInfo.user_role || userInfo.userRole;
|
||||
// console.log('📋 [getRulesList] 从 localStorage 获取用户角色:', userRole);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ [getRulesList] 解析 localStorage 用户信息失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 构建PostgrestParams参数
|
||||
const postgrestParams: PostgrestParams = {
|
||||
// 修改select语句,不使用嵌入查询语法
|
||||
// 🆕 使用 PostgREST 双连接查询(直接连接父子分组)
|
||||
// child_group: 通过 evaluation_point_groups_id 获取子分组(所属规则组)
|
||||
// parent_group: 通过 evaluation_point_groups_pid 直接获取父分组(评查点类型)
|
||||
// ⚠️ 重要:使用 .replace() 移除换行符,PostgREST 的 select 参数不支持多行字符串
|
||||
select: `
|
||||
id,
|
||||
code,
|
||||
name,
|
||||
area,
|
||||
evaluation_point_groups_id,
|
||||
evaluation_point_groups_pid,
|
||||
risk,
|
||||
description,
|
||||
is_enabled,
|
||||
created_at,
|
||||
updated_at
|
||||
`,
|
||||
updated_at,
|
||||
child_group:evaluation_point_groups!fk_evaluation_points_group(id,name),
|
||||
parent_group:evaluation_point_groups!fk_evaluation_points_parent_group(id,name)
|
||||
`.replace(/\s+/g, ' ').trim(),
|
||||
// 设置分页
|
||||
limit: pageSize,
|
||||
offset: (page - 1) * pageSize,
|
||||
|
||||
|
||||
// 设置排序
|
||||
order: `${orderBy}.${orderDirection}`,
|
||||
|
||||
|
||||
// 构建过滤条件
|
||||
filter: {},
|
||||
|
||||
@@ -204,62 +253,30 @@ export async function getRulesList(params: RulesQueryParams): Promise<{data: Rul
|
||||
if (groupId) {
|
||||
postgrestParams.filter!['evaluation_point_groups_id'] = `eq.${groupId}`;
|
||||
}
|
||||
|
||||
|
||||
if (isActive !== undefined) {
|
||||
postgrestParams.filter!['is_enabled'] = `eq.${isActive}`;
|
||||
}
|
||||
|
||||
// 根据reviewType添加过滤条件
|
||||
if (reviewType) {
|
||||
try {
|
||||
// 先获取所有评查点组数据,用于找到对应的pid
|
||||
const groupsAllResponse = await postgrestGet<{code: number; msg: string; data: Array<{id: number; pid: number; m_type: number}>}>('evaluation_point_groups', {
|
||||
select: 'id,pid,m_type',
|
||||
token
|
||||
});
|
||||
|
||||
let groups: Array<{id: number; pid: number; m_type: number}> = [];
|
||||
|
||||
if (!groupsAllResponse.error) {
|
||||
if (groupsAllResponse.data && 'code' in groupsAllResponse.data && groupsAllResponse.data.data) {
|
||||
groups = groupsAllResponse.data.data;
|
||||
} else if (Array.isArray(groupsAllResponse.data)) {
|
||||
groups = groupsAllResponse.data;
|
||||
}
|
||||
}
|
||||
|
||||
// 根据reviewType过滤pid
|
||||
let pidList: number[] = [];
|
||||
|
||||
if (reviewType === 'contract') {
|
||||
// 合同类型,找到所有pid=3的评查点组 2025/10/29: 修改为通过m_type = 0 去查找评查点组
|
||||
// const contractGroups = groups.filter(g => g.pid === 3).map(g => g.id);
|
||||
const contractGroups = groups.filter(g => g.m_type === 0).map(g => g.id);
|
||||
pidList = contractGroups;
|
||||
} else if (reviewType === 'record') {
|
||||
// 卷宗类型,找到所有pid=1或pid=2的评查点组 2025/10/29: 修改为通过m_type = 1 去查找评查点组
|
||||
// const recordGroups = groups.filter(g => g.pid === 1 || g.pid === 2).map(g => g.id);
|
||||
const recordGroups = groups.filter(g => g.m_type === 1).map(g => g.id);
|
||||
pidList = recordGroups;
|
||||
}
|
||||
|
||||
// 如果有过滤的组id,则添加到查询条件中
|
||||
if (pidList.length > 0) {
|
||||
postgrestParams.filter!['evaluation_point_groups_id'] = `in.(${pidList.join(',')})`;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取评查点组数据出错:', error);
|
||||
}
|
||||
|
||||
// 🔑 添加地区过滤
|
||||
if (user_role == 'provincial_admin') {
|
||||
postgrestParams.filter!['area'] = `eq.省级`;
|
||||
}else{
|
||||
postgrestParams.filter!['area'] = `eq.${area}`;
|
||||
}
|
||||
|
||||
|
||||
// 如果指定了评查点类型ID,需要先查询该类型下的所有规则组ID
|
||||
if (ruleType) {
|
||||
try {
|
||||
// 先获取该类型下的所有规则组
|
||||
// 🔑 检查是否为多个类型(逗号分隔)
|
||||
const isMultipleTypes = ruleType.includes(',');
|
||||
|
||||
// 先获取该类型(或多个类型)下的所有规则组
|
||||
const groupsParams: PostgrestParams = {
|
||||
select: 'id',
|
||||
filter: {
|
||||
'pid': `eq.${ruleType}`
|
||||
// 如果是多个类型,使用 in.(type1,type2),否则使用 eq.type
|
||||
'pid': isMultipleTypes ? `in.(${ruleType})` : `eq.${ruleType}`
|
||||
},
|
||||
token
|
||||
};
|
||||
@@ -311,7 +328,7 @@ export async function getRulesList(params: RulesQueryParams): Promise<{data: Rul
|
||||
if (response.error) {
|
||||
return { error: response.error, status: response.status };
|
||||
}
|
||||
|
||||
|
||||
// 处理不同的API响应格式
|
||||
let apiRules: ApiRule[] = [];
|
||||
let totalCount = 0;
|
||||
@@ -352,87 +369,18 @@ export async function getRulesList(params: RulesQueryParams): Promise<{data: Rul
|
||||
totalCount = apiRules.length;
|
||||
}
|
||||
|
||||
// 打印第一个数据项来查看结构(仅用于调试)
|
||||
// if (apiRules.length > 0) {
|
||||
// console.log('评查点数据示例:', JSON.stringify(apiRules[0], null, 2));
|
||||
// }
|
||||
|
||||
// 🆕 使用嵌套查询后,不再需要手动查询分组信息
|
||||
// PostgREST 的嵌套 select 已经自动关联了分组数据
|
||||
console.log('📋 [getRulesList] 使用 PostgREST 嵌套查询获取评查点数据');
|
||||
|
||||
// 收集所有规则分组ID(实际上是二级分组) (多进行一步查找表的操作)
|
||||
const groupIds = [...new Set(apiRules.map(rule => rule.evaluation_point_groups_id))];
|
||||
// 如果有分组ID,查询分组信息 - 更新类型定义
|
||||
const groupsMap: Record<string, {first_name: string; second_name: string}> = {};
|
||||
if (groupIds.length > 0) {
|
||||
try {
|
||||
// 过滤null和空值
|
||||
const validGroupIds = groupIds.filter(id => id != null && id.toString().trim() !== '');
|
||||
|
||||
if (validGroupIds.length > 0) {
|
||||
// 使用Promise.all并行查询所有分组信息 - 使用正确的函数名
|
||||
const groupPromises = validGroupIds.map(id =>
|
||||
postgrestGet<{code: number; msg: string; data: {id: number; pid: number; name: string; first_name: string; second_name: string}[]}>(
|
||||
`rpc/get_evaluation_point_group_with_pid?input_id=${id}`,
|
||||
{ token }
|
||||
)
|
||||
);
|
||||
|
||||
const groupResponses = await Promise.all(groupPromises);
|
||||
// console.log("groupResponsesdddddddddddddddddd",groupResponses);
|
||||
// 处理响应,填充groupsMap
|
||||
groupResponses.forEach((response) => {
|
||||
if (!response.error && response.data) {
|
||||
// 处理不同API响应格式
|
||||
if (Array.isArray(response.data.data) && response.data.data.length > 0) {
|
||||
const groupid = response.data.data[1]?.id || "";
|
||||
groupsMap[groupid] = {
|
||||
first_name: response.data.data[0]?.name || "",
|
||||
second_name: response.data.data[1]?.name || ""
|
||||
};
|
||||
}else if(Array.isArray(response.data) && response.data.length > 0){
|
||||
const groupid = response.data[1]?.id || "";
|
||||
groupsMap[groupid] = {
|
||||
first_name: response.data[0]?.name || "",
|
||||
second_name: response.data[1]?.name || ""
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取分组数据失败:', error);
|
||||
// 失败不阻止主流程,使用默认分组名
|
||||
}
|
||||
}
|
||||
|
||||
// 将API返回的数据映射到前端模型
|
||||
const mappedRules = apiRules.map(apiRule => {
|
||||
const rule = mapApiRuleToFrontendModel(apiRule);
|
||||
|
||||
// 应用本地过滤 - 只在API不支持的情况下使用
|
||||
const filteredRules = [...apiRules];
|
||||
// 不再需要本地过滤,因为已经在API层面添加了评查点类型过滤
|
||||
// 如果进行了本地过滤,则需要调整总记录数
|
||||
// if (ruleType) {
|
||||
// totalCount = filteredRules.length;
|
||||
// }
|
||||
|
||||
|
||||
// 将API返回的数据映射到前端模型,并附加分组名称
|
||||
// console.log("groupsMap",groupsMap);
|
||||
const mappedRules = filteredRules.map(apiRule => {
|
||||
// 创建修改版的apiRule,添加从分组映射获取的名称
|
||||
const enhancedApiRule = {
|
||||
...apiRule,
|
||||
// 从映射中获取分组名称,如果不存在则使用默认值
|
||||
evaluation_point_groups: {
|
||||
first_name: groupsMap[apiRule.evaluation_point_groups_id?.toString() || '']?.first_name || `${apiRule.evaluation_point_groups_id}`,
|
||||
second_name: groupsMap[apiRule.evaluation_point_groups_id?.toString() || '']?.second_name || `${apiRule.evaluation_point_groups_id}`
|
||||
}
|
||||
};
|
||||
|
||||
const rule = mapApiRuleToFrontendModel(enhancedApiRule);
|
||||
|
||||
// 格式化日期字段
|
||||
rule.createdAt = formatDate(rule.createdAt);
|
||||
rule.updatedAt = formatDate(rule.updatedAt);
|
||||
|
||||
|
||||
return rule;
|
||||
});
|
||||
|
||||
@@ -848,72 +796,134 @@ export interface RuleGroup {
|
||||
|
||||
/**
|
||||
* 获取评查点类型列表
|
||||
* @param reviewType 评查类型,contract表示合同,record表示卷宗
|
||||
* @param documentTypeIds 文档类型 ID 列表
|
||||
* @param token JWT token (可选)
|
||||
* @returns 评查点类型列表
|
||||
*/
|
||||
export async function getRuleTypes(reviewType?: string, token?: string): Promise<{data: RuleType[]; error?: never} | {data?: never; error: string; status?: number}> {
|
||||
export async function getRuleTypes(documentTypeIds?: number[], token?: string): Promise<{data: RuleType[]; error?: never} | {data?: never; error: string; status?: number}> {
|
||||
try {
|
||||
// 构建PostgrestParams参数
|
||||
const postgrestParams: PostgrestParams = {
|
||||
// 如果没有传入 documentTypeIds,返回空数组
|
||||
if (!documentTypeIds || documentTypeIds.length === 0) {
|
||||
console.warn('getRuleTypes: 未提供 documentTypeIds');
|
||||
return { data: [] };
|
||||
}
|
||||
|
||||
// 1️⃣ 根据 documentTypeIds 查询 document_types 表
|
||||
const typeIdsStr = documentTypeIds.join(',');
|
||||
const documentTypesParams: PostgrestParams = {
|
||||
select: 'id, name, evaluation_point_groups_ids',
|
||||
filter: {
|
||||
'id': `in.(${typeIdsStr})`
|
||||
},
|
||||
token
|
||||
};
|
||||
|
||||
const documentTypesResponse = await postgrestGet<{
|
||||
code: number;
|
||||
msg: string;
|
||||
data: Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
evaluation_point_groups_ids: number[];
|
||||
}>
|
||||
}>('document_types', documentTypesParams);
|
||||
|
||||
if (documentTypesResponse.error) {
|
||||
return { error: documentTypesResponse.error, status: documentTypesResponse.status };
|
||||
}
|
||||
|
||||
// 提取 document_types 数据
|
||||
let documentTypesData: Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
evaluation_point_groups_ids: number[];
|
||||
}> = [];
|
||||
|
||||
if (documentTypesResponse.data && 'code' in documentTypesResponse.data && documentTypesResponse.data.data) {
|
||||
if (Array.isArray(documentTypesResponse.data.data)) {
|
||||
documentTypesData = documentTypesResponse.data.data;
|
||||
}
|
||||
} else if (Array.isArray(documentTypesResponse.data)) {
|
||||
documentTypesData = documentTypesResponse.data as Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
evaluation_point_groups_ids: number[];
|
||||
}>;
|
||||
}
|
||||
|
||||
if (documentTypesData.length === 0) {
|
||||
console.warn('getRuleTypes: 未找到对应的文档类型数据');
|
||||
return { data: [] };
|
||||
}
|
||||
|
||||
// 2️⃣ 提取并组合所有的 evaluation_point_groups_ids
|
||||
const allGroupIds = new Set<number>();
|
||||
documentTypesData.forEach(docType => {
|
||||
if (Array.isArray(docType.evaluation_point_groups_ids)) {
|
||||
docType.evaluation_point_groups_ids.forEach(id => allGroupIds.add(id));
|
||||
}
|
||||
});
|
||||
|
||||
if (allGroupIds.size === 0) {
|
||||
console.warn('getRuleTypes: 未找到评查点组 ID');
|
||||
return { data: [] };
|
||||
}
|
||||
|
||||
// console.log('📋 [getRuleTypes] 提取的评查点组 IDs:', Array.from(allGroupIds));
|
||||
|
||||
// 3️⃣ 根据组合后的 ID 查询 evaluation_point_groups 表
|
||||
const groupIdsStr = Array.from(allGroupIds).join(',');
|
||||
const groupsParams: PostgrestParams = {
|
||||
select: `
|
||||
id,
|
||||
pid,
|
||||
code,
|
||||
name,
|
||||
description,
|
||||
is_enabled,
|
||||
m_type
|
||||
is_enabled
|
||||
`,
|
||||
// 查询父ID为0的类型(顶级类型)
|
||||
filter: {
|
||||
'pid': 'eq.0'
|
||||
'id': `in.(${groupIdsStr})`,
|
||||
'pid': 'eq.0'
|
||||
},
|
||||
token
|
||||
};
|
||||
|
||||
// 根据 reviewType 添加过滤条件
|
||||
if (reviewType === 'contract') {
|
||||
// 如果是合同类型,只加载id=3的评查点类型 2025/10/29: 修改为通过m_type = 0 去查找评查点组
|
||||
postgrestParams.filter!['m_type'] = 'eq.0';
|
||||
} else if (reviewType === 'record') {
|
||||
// 如果是卷宗类型,只加载id=1和id=2的评查点类型 2025/10/29: 修改为通过m_type = 1 去查找评查点组
|
||||
postgrestParams.filter!['m_type'] = 'eq.1';
|
||||
}
|
||||
|
||||
// 发送请求获取评查点类型列表
|
||||
const response = await postgrestGet<{code: number; msg: string; data: Array<{
|
||||
id: number;
|
||||
pid: number;
|
||||
code: string;
|
||||
name: string;
|
||||
description: string;
|
||||
is_enabled: boolean;
|
||||
}>;
|
||||
}>('evaluation_point_groups', postgrestParams);
|
||||
|
||||
|
||||
const response = await postgrestGet<{
|
||||
code: number;
|
||||
msg: string;
|
||||
data: Array<{
|
||||
id: number;
|
||||
pid: number;
|
||||
code: string;
|
||||
name: string;
|
||||
description: string;
|
||||
is_enabled: boolean;
|
||||
}>
|
||||
}>('evaluation_point_groups', groupsParams);
|
||||
|
||||
// 检查是否有错误响应
|
||||
if (response.error) {
|
||||
return { error: response.error, status: response.status };
|
||||
}
|
||||
|
||||
if(response.data && 'code' in response.data && response.data.data){
|
||||
if(Array.isArray(response.data.data) && response.data.data.length > 0){
|
||||
// 将API返回的数据映射到前端模型
|
||||
const ruleTypes = response.data.data.map(item => ({
|
||||
id: item.id.toString(),
|
||||
pid: item.pid.toString(),
|
||||
code: item.code,
|
||||
name: item.name,
|
||||
description: item.description,
|
||||
isEnabled: item.is_enabled
|
||||
}));
|
||||
return { data: ruleTypes };
|
||||
}else{
|
||||
return { error: '9000接口返回数据格式不正确', status: 500 };
|
||||
|
||||
// 处理响应数据
|
||||
if (response.data && 'code' in response.data && response.data.data) {
|
||||
if (Array.isArray(response.data.data)) {
|
||||
const ruleTypes = response.data.data.map(item => ({
|
||||
id: item.id.toString(),
|
||||
pid: item.pid.toString(),
|
||||
code: item.code,
|
||||
name: item.name,
|
||||
description: item.description,
|
||||
isEnabled: item.is_enabled
|
||||
}));
|
||||
// console.log('📋 [getRuleTypes] 返回评查点类型:', ruleTypes);
|
||||
return { data: ruleTypes };
|
||||
} else {
|
||||
return { data: [] };
|
||||
}
|
||||
}else if(Array.isArray(response.data) && response.data.length > 0){
|
||||
// console.log("评查点类型列表",response.data);
|
||||
} else if (Array.isArray(response.data)) {
|
||||
const ruleTypes = response.data.map(item => ({
|
||||
id: item.id.toString(),
|
||||
pid: item.pid.toString(),
|
||||
@@ -922,13 +932,14 @@ export async function getRuleTypes(reviewType?: string, token?: string): Promise
|
||||
description: item.description,
|
||||
isEnabled: item.is_enabled
|
||||
}));
|
||||
// console.log('📋 [getRuleTypes] 返回评查点类型:', ruleTypes);
|
||||
return { data: ruleTypes };
|
||||
}else{
|
||||
return { error: '3000接口返回数据格式不正确', status: 500 };
|
||||
} else {
|
||||
return { data: [] };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取评查点类型出错:', error);
|
||||
return {
|
||||
return {
|
||||
error: error instanceof Error ? error.message : '获取评查点类型失败',
|
||||
status: 500
|
||||
};
|
||||
|
||||
+70
-154
@@ -12,9 +12,9 @@ function extractApiData<T>(responseData: unknown): T | null {
|
||||
if (!responseData) return null;
|
||||
|
||||
// 格式1: { code: number, msg: string, data: T }
|
||||
if (typeof responseData === 'object' && responseData !== null &&
|
||||
'code' in responseData &&
|
||||
'data' in responseData &&
|
||||
if (typeof responseData === 'object' && responseData !== null &&
|
||||
'code' in responseData &&
|
||||
'data' in responseData &&
|
||||
(responseData as { data: unknown }).data) {
|
||||
return (responseData as { data: T }).data;
|
||||
}
|
||||
@@ -23,6 +23,34 @@ function extractApiData<T>(responseData: unknown): T | null {
|
||||
return responseData as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 sessionStorage 获取文档类型 ID 列表(客户端专用)
|
||||
* @returns 文档类型 ID 数组,如果不存在则返回 null
|
||||
*/
|
||||
function getDocumentTypeIdsFromSession(): number[] | null {
|
||||
if (typeof window === 'undefined') {
|
||||
return null; // 服务端环境返回 null
|
||||
}
|
||||
|
||||
try {
|
||||
const typeIdsStr = sessionStorage.getItem('documentTypeIds');
|
||||
if (!typeIdsStr) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const typeIds = JSON.parse(typeIdsStr);
|
||||
if (Array.isArray(typeIds) && typeIds.every(id => typeof id === 'number')) {
|
||||
return typeIds;
|
||||
}
|
||||
|
||||
console.warn('⚠️ [getDocumentTypeIds] documentTypeIds 格式不正确:', typeIds);
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('❌ [getDocumentTypeIds] 解析 documentTypeIds 失败:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 文档状态枚举
|
||||
export enum DocumentStatus {
|
||||
waiting = 'waiting',
|
||||
@@ -226,6 +254,7 @@ export async function uploadContractTemplate(
|
||||
* @param mergeMode 合并模式:'overwrite'(覆盖原文档)或 'new'(新建文档记录)
|
||||
* @param isReprocess 是否触发重新处理
|
||||
* @param remark 备注
|
||||
* @param token JWT token(可选)
|
||||
* @returns 上传结果
|
||||
*/
|
||||
export async function appendContractAttachments(
|
||||
@@ -233,7 +262,8 @@ export async function appendContractAttachments(
|
||||
files: File[],
|
||||
mergeMode: 'overwrite' | 'new' = 'overwrite',
|
||||
isReprocess: boolean = true,
|
||||
remark?: string
|
||||
remark?: string,
|
||||
token?: string
|
||||
): Promise<{data: FileUploadResponse; error?: never} | {data?: never; error: string; status?: number}> {
|
||||
try {
|
||||
console.log('【合同附件追加】开始追加附件:', { documentId, fileCount: files.length, mergeMode });
|
||||
@@ -256,20 +286,18 @@ export async function appendContractAttachments(
|
||||
// 构建请求URL
|
||||
const uploadUrl = `${UPLOAD_URL}/contracts/${documentId}/append_attachments`;
|
||||
console.log('【合同附件追加】准备发送请求到服务器:', uploadUrl);
|
||||
|
||||
|
||||
// 设置请求头
|
||||
const headers: HeadersInit = {
|
||||
'Accept': 'application/json'
|
||||
};
|
||||
|
||||
// 从 localStorage 获取 token
|
||||
if (typeof window !== 'undefined') {
|
||||
const token = localStorage.getItem('access_token');
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
// 使用传入的 token 或从 localStorage 获取
|
||||
const authToken = token || (typeof window !== 'undefined' ? localStorage.getItem('access_token') : null);
|
||||
if (authToken) {
|
||||
headers['Authorization'] = `Bearer ${authToken}`;
|
||||
}
|
||||
|
||||
|
||||
// 发送请求
|
||||
const response = await fetch(uploadUrl, {
|
||||
method: 'POST',
|
||||
@@ -440,10 +468,15 @@ export async function uploadDocumentToServer(
|
||||
/**
|
||||
* 获取当天的文档列表
|
||||
* @param userInfo 用户信息(必需)
|
||||
* @param reviewType 审核类型(可选)
|
||||
* @param token JWT token
|
||||
* @param documentTypeIds 文档类型 ID 列表(可选)
|
||||
* @returns 文档列表
|
||||
*/
|
||||
export async function getTodayDocuments(userInfo?: { user_id?: number; [key: string]: unknown }, reviewType?: string, token?: string): Promise<{data: Document[]; error?: never} | {data?: never; error: string; status?: number}> {
|
||||
export async function getTodayDocuments(
|
||||
userInfo?: { user_id?: number; [key: string]: unknown },
|
||||
token?: string,
|
||||
documentTypeIds?: number[]
|
||||
): Promise<{data: Document[]; error?: never} | {data?: never; error: string; status?: number}> {
|
||||
try {
|
||||
// 检查用户信息是否存在
|
||||
if (!userInfo?.user_id) {
|
||||
@@ -455,129 +488,11 @@ export async function getTodayDocuments(userInfo?: { user_id?: number; [key: str
|
||||
|
||||
const today = dayjs().startOf('day').format('YYYY-MM-DD');
|
||||
// console.log('查询当天文档,日期范围:', today);
|
||||
|
||||
// 如果是合同类型,需要合并查询documents表和contract_structure_comparison表
|
||||
if (reviewType === 'contract') {
|
||||
try {
|
||||
// 查询documents表中的合同数据
|
||||
const documentsParams: PostgrestParams = {
|
||||
select: `
|
||||
id,
|
||||
name,
|
||||
type_id,
|
||||
file_size,
|
||||
status,
|
||||
created_at,
|
||||
document_number,
|
||||
path,
|
||||
storage_type,
|
||||
is_test_document,
|
||||
evaluation_level,
|
||||
ocr_result,
|
||||
extracted_results,
|
||||
sumary,
|
||||
remark,
|
||||
audit_status
|
||||
`,
|
||||
order: 'created_at.desc',
|
||||
filter: {
|
||||
'created_at': `gte.${today}`,
|
||||
'type_id': 'eq.1',
|
||||
'user_id': `eq.${userInfo.user_id}`
|
||||
}
|
||||
};
|
||||
|
||||
// 🔑 优先使用传入的 documentTypeIds,否则从 sessionStorage 读取(客户端)
|
||||
const typeIds = documentTypeIds || getDocumentTypeIdsFromSession();
|
||||
// console.log('📋 [getTodayDocuments] 文档类型 IDs:', typeIds, '来源:', documentTypeIds ? 'URL参数' : 'sessionStorage');
|
||||
|
||||
// 查询contract_structure_comparison表中的数据
|
||||
// const comparisonParams: PostgrestParams = {
|
||||
// select: `
|
||||
// id,
|
||||
// template_contract_name,
|
||||
// file_size,
|
||||
// status,
|
||||
// created_at,
|
||||
// document_id,
|
||||
// template_contract_path,
|
||||
// ocr_results,
|
||||
// comparison_results
|
||||
// `,
|
||||
// order: 'created_at.desc',
|
||||
// filter: {
|
||||
// 'created_at': `gte.${today}`
|
||||
// }
|
||||
// };
|
||||
|
||||
// 并行查询两个表
|
||||
// const [documentsResponse, comparisonResponse] = await Promise.all([
|
||||
// postgrestGet<Document[]>('documents', documentsParams),
|
||||
// postgrestGet<ContractStructureComparison[]>('contract_structure_comparison', comparisonParams)
|
||||
// ]);
|
||||
|
||||
const documentsResponse = await postgrestGet<Document[]>('documents', { ...documentsParams, token });
|
||||
|
||||
// console.log('documents表响应:', documentsResponse);
|
||||
// console.log('contract_structure_comparison表响应:', comparisonResponse);
|
||||
|
||||
// if (documentsResponse.error && comparisonResponse.error) {
|
||||
// console.error('两个表查询都失败:', documentsResponse.error, comparisonResponse.error);
|
||||
// return { error: documentsResponse.error || comparisonResponse.error, status: documentsResponse.status || comparisonResponse.status };
|
||||
// }
|
||||
if (documentsResponse.error) {
|
||||
console.error('documents表查询失败:', documentsResponse.error);
|
||||
return { error: documentsResponse.error, status: documentsResponse.status };
|
||||
}
|
||||
|
||||
// 提取documents表数据
|
||||
let documentsData: Document[] = [];
|
||||
if (!documentsResponse.error && documentsResponse.data) {
|
||||
const extractedDocuments = extractApiData<Document[]>(documentsResponse.data);
|
||||
if (extractedDocuments) {
|
||||
documentsData = extractedDocuments;
|
||||
}
|
||||
}
|
||||
|
||||
// 提取contract_structure_comparison表数据并转换为Document格式
|
||||
// let comparisonData: Document[] = [];
|
||||
// if (!comparisonResponse.error && comparisonResponse.data) {
|
||||
// const extractedComparison = extractApiData<ContractStructureComparison[]>(comparisonResponse.data);
|
||||
// if (extractedComparison) {
|
||||
// // 将ContractStructureComparison转换为Document格式
|
||||
// console.log('extractedComparison:', extractedComparison);
|
||||
// comparisonData = extractedComparison.map(item => ({
|
||||
// id: item.id,
|
||||
// name: item.template_contract_name || `合同结构比较记录_${item.id}`,
|
||||
// type_id: 1, // 合同结构比较默认为合同类型
|
||||
// file_size: item.file_size || 0,
|
||||
// status: item.status,
|
||||
// created_at: item.created_at,
|
||||
// document_id: item.document_id,
|
||||
// template_contract_path: item.template_contract_path,
|
||||
// ocr_results: item.ocr_results,
|
||||
// comparison_results: item.comparison_results
|
||||
// }));
|
||||
// }
|
||||
// }
|
||||
|
||||
// 合并两个数据源
|
||||
// const allData = [...documentsData, ...comparisonData];
|
||||
const allData = [...documentsData];
|
||||
|
||||
// 按created_at降序排序
|
||||
allData.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
|
||||
|
||||
// console.log('合并后的数据:', allData);
|
||||
return { data: allData };
|
||||
|
||||
} catch (contractError) {
|
||||
console.error('合同类型查询失败:', contractError);
|
||||
return {
|
||||
error: contractError instanceof Error ? contractError.message : '合同类型查询失败',
|
||||
status: 500
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 非合同类型的原有逻辑
|
||||
const params: PostgrestParams = {
|
||||
select: `
|
||||
id,
|
||||
@@ -604,14 +519,16 @@ export async function getTodayDocuments(userInfo?: { user_id?: number; [key: str
|
||||
}
|
||||
};
|
||||
|
||||
// 根据reviewType添加过滤条件
|
||||
if (reviewType === 'record') {
|
||||
// 如果是卷宗类型,只显示type_id=2或type_id=3的文档
|
||||
// 🔑 根据 documentTypeIds 添加过滤条件
|
||||
if (typeIds && typeIds.length > 0) {
|
||||
// 使用动态的文档类型 ID 列表
|
||||
const typeIdsStr = typeIds.join(',');
|
||||
if (params.filter) {
|
||||
params.filter['type_id'] = 'in.(2,3,155)';
|
||||
params.filter['type_id'] = `in.(${typeIdsStr})`;
|
||||
} else {
|
||||
params.filter = { 'type_id': 'in.(2,3,155)' };
|
||||
params.filter = { 'type_id': `in.(${typeIdsStr})` };
|
||||
}
|
||||
console.log('📋 [getTodayDocuments] 使用文档类型 IDs 查询:', typeIdsStr);
|
||||
}
|
||||
|
||||
// console.log('发送请求参数:', params);
|
||||
@@ -643,33 +560,32 @@ export async function getTodayDocuments(userInfo?: { user_id?: number; [key: str
|
||||
|
||||
/**
|
||||
* 获取文档类型列表
|
||||
* @param reviewType 审核类型(可选)
|
||||
* @param token JWT token (可选)
|
||||
* @returns 文档类型列表
|
||||
*/
|
||||
export async function getDocumentTypes(reviewType?: string, token?: string): Promise<{data: DocumentType[]; error?: never} | {data?: never; error: string; status?: number}> {
|
||||
export async function getDocumentTypes(token?: string): Promise<{data: DocumentType[]; error?: never} | {data?: never; error: string; status?: number}> {
|
||||
try {
|
||||
const params: PostgrestParams = {
|
||||
select: 'id, name',
|
||||
filter: {} // 初始化为空对象
|
||||
};
|
||||
|
||||
// 根据reviewType添加过滤条件
|
||||
if (reviewType === 'contract') {
|
||||
// 如果是合同类型,只显示id=1的文档类型
|
||||
// 🔑 从 sessionStorage 获取文档类型 ID 列表(动态方式)
|
||||
const documentTypeIds = getDocumentTypeIdsFromSession();
|
||||
// console.log('📋 [getDocumentTypes] 文档类型 IDs:', documentTypeIds);
|
||||
|
||||
// 根据 documentTypeIds 添加过滤条件
|
||||
if (documentTypeIds && documentTypeIds.length > 0) {
|
||||
// 使用动态的文档类型 ID 列表
|
||||
const typeIdsStr = documentTypeIds.join(',');
|
||||
if (params.filter) {
|
||||
params.filter['id'] = 'eq.1';
|
||||
params.filter['id'] = `in.(${typeIdsStr})`;
|
||||
} else {
|
||||
params.filter = { 'id': 'eq.1' };
|
||||
}
|
||||
} else if (reviewType === 'record') {
|
||||
// 如果是卷宗类型,只显示id=2或id=3的文档类型
|
||||
if (params.filter) {
|
||||
params.filter['id'] = 'in.(2,3,155)';
|
||||
} else {
|
||||
params.filter = { 'id': 'in.(2,3,155)' };
|
||||
params.filter = { 'id': `in.(${typeIdsStr})` };
|
||||
}
|
||||
console.log('📋 [getDocumentTypes] 使用动态类型 IDs 查询:', typeIdsStr);
|
||||
}
|
||||
// 如果没有 documentTypeIds,返回所有文档类型(不添加过滤条件)
|
||||
|
||||
const response = await postgrestGet<DocumentType[]>('document_types', { ...params, token });
|
||||
|
||||
|
||||
@@ -485,3 +485,125 @@ export async function getHomeData(reviewType?: string | null,userId?: string | n
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 入口模块类型定义
|
||||
*/
|
||||
export interface EntryModule {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
path: string | null;
|
||||
areas: string[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
document_types?: Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
code: string | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户可访问的入口模块
|
||||
* @param userArea 用户所属地区
|
||||
* @param token JWT token
|
||||
* @returns 入口模块列表
|
||||
*/
|
||||
export async function getEntryModules(userRole: string | null | undefined, userArea: string | null | undefined, token?: string): Promise<EntryModule[]> {
|
||||
try {
|
||||
if (!userRole || !userArea) {
|
||||
console.warn('⚠️ [getEntryModules] 用户角色或地区为空,返回空模块列表');
|
||||
return [];
|
||||
}
|
||||
|
||||
// console.log('🔍 [getEntryModules] 查询地区:', userArea);
|
||||
|
||||
// 查询 entry_modules 表,筛选 areas 数组中包含用户地区的模块
|
||||
// 使用 PostgreSQL JSONB 操作符 @> 检查数组是否包含值
|
||||
const params: PostgrestParams = {
|
||||
select: 'id,name,description,path,areas,created_at,updated_at',
|
||||
filter: {
|
||||
// areas 数组中包含用户的 area
|
||||
// areas: `cs.["${userArea}"]` // cs = contains (PostgreSQL @> 操作符)
|
||||
}
|
||||
};
|
||||
if (userRole != 'provincial_admin'){
|
||||
params.filter = {
|
||||
areas: `cs.["${userArea}"]`
|
||||
}
|
||||
}
|
||||
|
||||
const modulesResponse = await postgrestGet('entry_modules', { ...params, token });
|
||||
|
||||
if (modulesResponse.error) {
|
||||
console.error('❌ [getEntryModules] 查询入口模块失败:', modulesResponse.error);
|
||||
return [];
|
||||
}
|
||||
|
||||
const modules = extractApiData<EntryModule[]>(modulesResponse.data);
|
||||
if (!modules || modules.length === 0) {
|
||||
console.warn('⚠️ [getEntryModules] 未找到匹配的入口模块');
|
||||
return [];
|
||||
}
|
||||
|
||||
console.log(`✅ [getEntryModules] 找到 ${modules.length} 个入口模块`);
|
||||
|
||||
// 为每个模块查询关联的 document_types
|
||||
const modulesWithTypes = await Promise.all(
|
||||
modules.map(async (module) => {
|
||||
try {
|
||||
const typesParams: PostgrestParams = {
|
||||
select: 'id,name,code',
|
||||
filter: {
|
||||
entry_module_id: `eq.${module.id}`
|
||||
}
|
||||
};
|
||||
|
||||
const typesResponse = await postgrestGet('document_types', { ...typesParams, token });
|
||||
|
||||
if (typesResponse.error) {
|
||||
console.error(`❌ [getEntryModules] 查询模块 ${module.id} 的文档类型失败:`, typesResponse.error);
|
||||
return { ...module, document_types: [] };
|
||||
}
|
||||
|
||||
const documentTypes = extractApiData<Array<{ id: number; name: string; code: string | null }>>(typesResponse.data);
|
||||
|
||||
return {
|
||||
...module,
|
||||
document_types: documentTypes || []
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`❌ [getEntryModules] 处理模块 ${module.id} 时出错:`, error);
|
||||
return { ...module, document_types: [] };
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
console.log('✅ [getEntryModules] 入口模块数据(含文档类型):', JSON.stringify(modulesWithTypes));
|
||||
|
||||
// 默认会多加一个 智慧法务大模型 入口 默认所有人都可以用,看到
|
||||
modulesWithTypes.push({
|
||||
"id": 0,
|
||||
"name": "智慧法务大模型",
|
||||
"description": "智慧法务大模型",
|
||||
"path": "entryModule/assistant",
|
||||
"areas": [],
|
||||
"created_at": "2025-11-18T21:33:33.857417+08:00",
|
||||
"updated_at": "2025-11-18T22:28:51.819722+08:00",
|
||||
"document_types": [
|
||||
{
|
||||
"id": 0,
|
||||
"name": "空",
|
||||
"code": "空"
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
|
||||
return modulesWithTypes;
|
||||
} catch (error) {
|
||||
console.error('❌ [getEntryModules] 获取入口模块失败:', error instanceof Error ? error.message : String(error));
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ import { OAUTH_CONFIG, API_BASE_URL } from "~/config/api-config";
|
||||
* @property {'common'} common - 普通用户,有基本的系统访问权限
|
||||
* @property {'developer'} developer - 开发者/管理员,有完整的系统管理权限
|
||||
*/
|
||||
export type UserRole = 'common' | 'admin' | 'deptLeader' | 'groupLeader' | string;
|
||||
export type UserRole = string;
|
||||
|
||||
/**
|
||||
* 用户信息接口,对应 sso_users 表结构
|
||||
@@ -144,22 +144,22 @@ export async function getSession(request: Request) {
|
||||
* @param expiresIn OAuth token过期时间(秒)
|
||||
* @returns JWT字符串
|
||||
*/
|
||||
async function generateFrontendJWT(userInfo: UserInfo, savedUserData: SsoUser, userRole: UserRole, expiresIn: number): Promise<string> {
|
||||
const jwtUserInfo: UserInfoForJWT = {
|
||||
sub: userInfo.sub,
|
||||
user_id: savedUserData.id!,
|
||||
username: savedUserData.username,
|
||||
nick_name: savedUserData.nick_name,
|
||||
email: savedUserData.email,
|
||||
phone_number: savedUserData.phone_number,
|
||||
ou_id: savedUserData.ou_id,
|
||||
ou_name: savedUserData.ou_name,
|
||||
is_leader: savedUserData.is_leader,
|
||||
user_role: userRole
|
||||
};
|
||||
// async function generateFrontendJWT(userInfo: UserInfo, savedUserData: SsoUser, userRole: UserRole, expiresIn: number): Promise<string> {
|
||||
// const jwtUserInfo: UserInfoForJWT = {
|
||||
// sub: userInfo.sub,
|
||||
// user_id: savedUserData.id!,
|
||||
// username: savedUserData.username,
|
||||
// nick_name: savedUserData.nick_name,
|
||||
// email: savedUserData.email,
|
||||
// phone_number: savedUserData.phone_number,
|
||||
// ou_id: savedUserData.ou_id,
|
||||
// ou_name: savedUserData.ou_name,
|
||||
// is_leader: savedUserData.is_leader,
|
||||
// user_role: userRole
|
||||
// };
|
||||
|
||||
return JWTUtils.generateJWT(jwtUserInfo, expiresIn);
|
||||
}
|
||||
// return JWTUtils.generateJWT(jwtUserInfo, expiresIn);
|
||||
// }
|
||||
|
||||
/**
|
||||
* 创建包含JWT的用户信息对象
|
||||
@@ -169,38 +169,44 @@ async function generateFrontendJWT(userInfo: UserInfo, savedUserData: SsoUser, u
|
||||
* @param frontendJWT 前端JWT
|
||||
* @returns 完整的用户信息对象
|
||||
*/
|
||||
function createUserInfoWithJWT(userInfo: UserInfo, savedUserData: SsoUser, userRole: UserRole, frontendJWT: string) {
|
||||
return {
|
||||
// 保持与callback.tsx中enhancedUserInfo相同的数据结构
|
||||
sub: userInfo.sub,
|
||||
username: savedUserData.username,
|
||||
nick_name: savedUserData.nick_name,
|
||||
phone_number: savedUserData.phone_number,
|
||||
email: savedUserData.email,
|
||||
ou_id: savedUserData.ou_id,
|
||||
ou_name: savedUserData.ou_name,
|
||||
status: savedUserData.status,
|
||||
is_leader: savedUserData.is_leader,
|
||||
// 增强字段,与OAuth登录保持一致
|
||||
user_id: savedUserData.id,
|
||||
user_role: userRole,
|
||||
frontend_jwt: frontendJWT
|
||||
};
|
||||
}
|
||||
// function createUserInfoWithJWT(userInfo: UserInfo, savedUserData: SsoUser, userRole: UserRole, frontendJWT: string) {
|
||||
// return {
|
||||
// // 保持与callback.tsx中enhancedUserInfo相同的数据结构
|
||||
// sub: userInfo.sub,
|
||||
// username: savedUserData.username,
|
||||
// nick_name: savedUserData.nick_name,
|
||||
// phone_number: savedUserData.phone_number,
|
||||
// email: savedUserData.email,
|
||||
// ou_id: savedUserData.ou_id,
|
||||
// ou_name: savedUserData.ou_name,
|
||||
// status: savedUserData.status,
|
||||
// is_leader: savedUserData.is_leader,
|
||||
// // 增强字段,与OAuth登录保持一致
|
||||
// user_id: savedUserData.id,
|
||||
// user_role: userRole,
|
||||
// frontend_jwt: frontendJWT
|
||||
// };
|
||||
// }
|
||||
|
||||
export async function getUserSession(request: Request) {
|
||||
const session = await getSession(request);
|
||||
const isAuthenticated = session.get("isAuthenticated") === true;
|
||||
const userRole = session.get("userRole") as UserRole;
|
||||
let accessToken = session.get("accessToken");
|
||||
const accessToken = session.get("accessToken");
|
||||
const refreshToken = session.get("refreshToken");
|
||||
let tokenIssuedAt = session.get("tokenIssuedAt");
|
||||
const tokenIssuedAt = session.get("tokenIssuedAt");
|
||||
let tokenExpiresIn = session.get("tokenExpiresIn");
|
||||
const userInfo = session.get("userInfo");
|
||||
let frontendJWT = session.get("frontendJWT");
|
||||
const frontendJWT = session.get("frontendJWT");
|
||||
|
||||
// 🔑 检查是否是公共路径(不需要认证的路径)
|
||||
const url = new URL(request.url);
|
||||
const pathname = url.pathname;
|
||||
const publicPaths = ['/login', '/favicon.ico', '/callback'];
|
||||
const isPublicPath = publicPaths.some(path => pathname.startsWith(path));
|
||||
|
||||
let refreshedSession = null;
|
||||
let shouldRegenerateJWT = false;
|
||||
// let shouldRegenerateJWT = false;
|
||||
|
||||
// 🔑 新的统一过期检查逻辑
|
||||
// 不区分 admin 和 OAuth 用户,所有用户都使用同样的过期检查
|
||||
@@ -251,20 +257,44 @@ export async function getUserSession(request: Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// 🚨 如果 session 无效(包括 token 过期),自动重定向到登录页
|
||||
if (!finalIsAuthenticated && isAuthenticated) {
|
||||
console.error("❌ [getUserSession] Session 已失效,清除 session 并重定向到登录页");
|
||||
// 🚨 统一的认证检查和重定向逻辑
|
||||
if (!finalIsAuthenticated) {
|
||||
// 如果是公共路径,不重定向,直接返回未认证状态
|
||||
if (isPublicPath) {
|
||||
// console.log("ℹ️ [getUserSession] 公共路径,允许未认证访问:", pathname);
|
||||
return {
|
||||
isAuthenticated: false,
|
||||
userRole,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
userInfo,
|
||||
refreshedSession,
|
||||
frontendJWT
|
||||
};
|
||||
}
|
||||
|
||||
// 销毁服务端 session
|
||||
const { redirect } = await import("@remix-run/node");
|
||||
const destroyedSession = await sessionStorage.destroySession(session);
|
||||
// 非公共路径且未认证,重定向到登录页
|
||||
if (isAuthenticated) {
|
||||
// Session 存在但已失效(token 过期或数据不完整)
|
||||
console.error("❌ [getUserSession] Session 已失效,清除 session 并重定向到登录页");
|
||||
const { redirect } = await import("@remix-run/node");
|
||||
const destroyedSession = await sessionStorage.destroySession(session);
|
||||
|
||||
// 重定向到登录页,添加 expired=true 参数标识是因为过期重定向
|
||||
throw redirect("/login?expired=true", {
|
||||
headers: {
|
||||
"Set-Cookie": destroyedSession
|
||||
}
|
||||
});
|
||||
// 重定向到登录页,添加 expired=true 参数
|
||||
throw redirect("/login?expired=true", {
|
||||
headers: {
|
||||
"Set-Cookie": destroyedSession
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Session 不存在(首次访问或已登出)
|
||||
console.warn("⚠️ [getUserSession] 未登录,重定向到登录页");
|
||||
const { redirect } = await import("@remix-run/node");
|
||||
|
||||
// 保存当前路径,登录后可以跳转回来
|
||||
const redirectTo = pathname !== '/login' ? pathname : '/';
|
||||
throw redirect(`/login?redirect=${encodeURIComponent(redirectTo)}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -45,6 +45,7 @@ export interface LoginResponse {
|
||||
is_leader: boolean;
|
||||
user_role: string;
|
||||
sub: string;
|
||||
area?: string; // 🔑 用户所属地区
|
||||
};
|
||||
};
|
||||
error?: string;
|
||||
|
||||
@@ -96,24 +96,29 @@ function mergeAuthHeaders(
|
||||
explicitToken?: string
|
||||
): Record<string, string> {
|
||||
const headers = { ...existingHeaders };
|
||||
|
||||
|
||||
// 如果已经有 Authorization 头部(不区分大小写),不覆盖
|
||||
const hasAuth = Object.keys(headers).some(
|
||||
key => key.toLowerCase() === 'authorization'
|
||||
);
|
||||
|
||||
|
||||
if (hasAuth) {
|
||||
console.log('🔑 [mergeAuthHeaders] 已存在 Authorization 头,不覆盖');
|
||||
return headers;
|
||||
}
|
||||
|
||||
|
||||
// 优先使用显式传入的 token,否则尝试从客户端 localStorage 获取
|
||||
const token = explicitToken || (typeof window !== 'undefined' ? localStorage.getItem('access_token') : undefined);
|
||||
|
||||
// 如果有有效的 token(显式传入或从客户端获取),添加到 Authorization 头部
|
||||
if (token && token !== 'undefined') {
|
||||
// console.log('🔑 [mergeAuthHeaders] 添加 Authorization 头,token 来源:', explicitToken ? 'explicitToken' : 'localStorage');
|
||||
// console.log('🔑 [mergeAuthHeaders] Token 前10位:', token.substring(0, 10));
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
} else {
|
||||
console.warn('⚠️ [mergeAuthHeaders] 没有可用的 token!explicitToken:', explicitToken, 'window:', typeof window);
|
||||
}
|
||||
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
|
||||
@@ -96,9 +96,11 @@ export function Breadcrumb({ items = [], className = '' }: BreadcrumbProps) {
|
||||
{index === breadcrumbs.length - 1 ? (
|
||||
<span className="text-gray-900 font-medium">{item.title}</span>
|
||||
) : (
|
||||
<Link
|
||||
to={item.to || '#'}
|
||||
<Link
|
||||
to={item.to || '#'}
|
||||
className="hover:text-primary-600"
|
||||
prefetch="intent"
|
||||
preventScrollReset={false}
|
||||
>
|
||||
{item.title}
|
||||
</Link>
|
||||
|
||||
@@ -5,16 +5,6 @@ import { Breadcrumb } from './Breadcrumb';
|
||||
import { useMatches, useLocation } from '@remix-run/react';
|
||||
import type { UserRole } from '~/root';
|
||||
|
||||
// 定义应用模块类型
|
||||
type AppModule = 'contract' | 'record' | 'model' | '';
|
||||
|
||||
// 应用模块与reviewType的映射
|
||||
const REVIEW_TYPE_TO_APP: Record<string, AppModule> = {
|
||||
'contract': 'contract', // 合同管理
|
||||
'record': 'record', // 案卷智能评查
|
||||
'model': 'model' // 智慧法务大模型
|
||||
};
|
||||
|
||||
interface LayoutProps {
|
||||
children: React.ReactNode;
|
||||
userRole?: UserRole;
|
||||
@@ -35,7 +25,6 @@ interface Match {
|
||||
|
||||
export function Layout({ children, userRole = 'developer' as UserRole, frontendJWT = '' }: LayoutProps) {
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
const [selectedApp, setSelectedApp] = useState<AppModule>('');
|
||||
const [effectiveUserRole, setEffectiveUserRole] = useState<UserRole>(userRole);
|
||||
const [effectiveFrontendJWT, setEffectiveFrontendJWT] = useState<string>(frontendJWT);
|
||||
const matches = useMatches() as Match[];
|
||||
@@ -83,7 +72,7 @@ export function Layout({ children, userRole = 'developer' as UserRole, frontendJ
|
||||
}
|
||||
}, [userRole, frontendJWT]);
|
||||
|
||||
// 从sessionStorage中获取侧边栏状态和reviewType
|
||||
// 从localStorage中获取侧边栏状态
|
||||
useEffect(() => {
|
||||
// 检查是否为移动端
|
||||
const isMobile = window.innerWidth <= 768;
|
||||
@@ -97,35 +86,8 @@ export function Layout({ children, userRole = 'developer' as UserRole, frontendJ
|
||||
} else if (savedState) {
|
||||
setSidebarCollapsed(savedState === 'true');
|
||||
}
|
||||
|
||||
// 从sessionStorage获取reviewType并设置对应的应用模块
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
const reviewType = sessionStorage.getItem('reviewType');
|
||||
if (reviewType && REVIEW_TYPE_TO_APP[reviewType]) {
|
||||
setSelectedApp(REVIEW_TYPE_TO_APP[reviewType]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取reviewType失败:', error);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 路由变化时,检查并更新应用模块
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
const reviewType = sessionStorage.getItem('reviewType');
|
||||
// console.log('Layout 路由变化, reviewType:', reviewType, '路径:', location.pathname);
|
||||
if (reviewType && REVIEW_TYPE_TO_APP[reviewType]) {
|
||||
setSelectedApp(REVIEW_TYPE_TO_APP[reviewType]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('路由变化时获取reviewType失败:', error);
|
||||
}
|
||||
}
|
||||
}, [location.pathname]);
|
||||
|
||||
const toggleSidebar = () => {
|
||||
const newState = !sidebarCollapsed;
|
||||
setSidebarCollapsed(newState);
|
||||
@@ -150,7 +112,6 @@ export function Layout({ children, userRole = 'developer' as UserRole, frontendJ
|
||||
collapsed={sidebarCollapsed}
|
||||
onToggle={toggleSidebar}
|
||||
userRole={effectiveUserRole}
|
||||
selectedApp={selectedApp}
|
||||
frontendJWT={effectiveFrontendJWT}
|
||||
/>
|
||||
|
||||
|
||||
@@ -8,36 +8,15 @@ interface SidebarProps {
|
||||
collapsed: boolean;
|
||||
userRole: UserRole;
|
||||
frontendJWT?: string;
|
||||
selectedApp?: string; // 添加所选应用模块参数
|
||||
}
|
||||
|
||||
// 已移除 APP_MENU_MAP:路由的显示/隐藏由后端 is_hidden 字段控制
|
||||
// 只保留特殊规则:
|
||||
// - /chat-with-llm 只在 model 模块中显示
|
||||
// - /contract-template 只在 contract 模块中显示
|
||||
|
||||
// 应用模块名称映射
|
||||
const APP_NAME_MAP: Record<string, string> = {
|
||||
'contract': '合同管理',
|
||||
'record': '案卷智能评查',
|
||||
'model': '智慧法务大模型'
|
||||
};
|
||||
|
||||
// 应用模块图标映射
|
||||
const APP_ICON_MAP: Record<string, string> = {
|
||||
'contract': '/images/icon_hetong.png',
|
||||
'record': '/images/icon_anjuan.png',
|
||||
'model': '/images/icon_assistant.png'
|
||||
};
|
||||
|
||||
export function Sidebar({ onToggle, collapsed, userRole, frontendJWT = '', selectedApp = '' }: SidebarProps) {
|
||||
export function Sidebar({ onToggle, collapsed, userRole, frontendJWT = '' }: SidebarProps) {
|
||||
const location = useLocation();
|
||||
const [expandedMenus, setExpandedMenus] = useState<Record<string, boolean>>({});
|
||||
const [currentApp, setCurrentApp] = useState<string>(''); // 初始设置为空字符串而不是selectedApp
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true); // 添加加载状态
|
||||
const [menuItems, setMenuItems] = useState<MenuItem[]>([]); // 动态菜单项
|
||||
const [isLoadingRoutes, setIsLoadingRoutes] = useState<boolean>(true); // 路由加载状态
|
||||
const [isMobile, setIsMobile] = useState<boolean>(false); // 移动端检测
|
||||
const [selectedModuleName, setSelectedModuleName] = useState<string>(''); // 当前选中的模块名称
|
||||
const navigate = useNavigate();
|
||||
|
||||
// 移动端检测
|
||||
@@ -57,6 +36,8 @@ export function Sidebar({ onToggle, collapsed, userRole, frontendJWT = '', selec
|
||||
|
||||
// 获取用户路由权限
|
||||
useEffect(() => {
|
||||
console.log('🔍 [Sidebar] useEffect 触发,开始获取路由权限');
|
||||
|
||||
const fetchUserRoutes = async () => {
|
||||
setIsLoadingRoutes(true);
|
||||
try {
|
||||
@@ -69,19 +50,19 @@ export function Sidebar({ onToggle, collapsed, userRole, frontendJWT = '', selec
|
||||
}
|
||||
|
||||
if (!jwt) {
|
||||
console.error('❌ [Sidebar] JWT token 未找到');
|
||||
console.error('❌ [Sidebar] JWT token 未找到,props.frontendJWT:', frontendJWT, 'localStorage.access_token:', localStorage.getItem('access_token'));
|
||||
setMenuItems([]);
|
||||
setIsLoadingRoutes(false);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('🔍 [Sidebar] 当前用户角色:', userRole);
|
||||
const roleKey = mapUserRoleToRoleKey(userRole);
|
||||
const result = await getUserRoutesByRole(roleKey, jwt);
|
||||
// console.log('🔍 [Sidebar] 当前用户角色:', userRole, 'JWT前20字符:', jwt.substring(0, 20));
|
||||
// console.log('🔍 [Sidebar] 映射后的角色key:', roleKey);
|
||||
const result = await getUserRoutesByRole(userRole, jwt);
|
||||
|
||||
if (result.success && result.data) {
|
||||
setMenuItems(result.data);
|
||||
console.log('✅ [Sidebar] 用户路由权限加载成功:', result.data);
|
||||
// console.log('✅ [Sidebar] 用户路由权限加载成功:', result.data);
|
||||
} else {
|
||||
console.error('❌ [Sidebar] 获取用户路由权限失败:', result.error);
|
||||
|
||||
@@ -108,93 +89,20 @@ export function Sidebar({ onToggle, collapsed, userRole, frontendJWT = '', selec
|
||||
fetchUserRoutes();
|
||||
}, [userRole, frontendJWT, navigate]);
|
||||
|
||||
// 组件挂载后从 sessionStorage 读取初始 reviewType
|
||||
// 从 sessionStorage 读取当前选中的模块名称
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
setIsLoading(true); // 设置加载状态
|
||||
|
||||
try {
|
||||
const reviewType = sessionStorage.getItem('reviewType');
|
||||
// console.log('初始 reviewType:', reviewType);
|
||||
if (reviewType && mounted) {
|
||||
setCurrentApp(reviewType);
|
||||
} else if (selectedApp && mounted) {
|
||||
// 如果没有reviewType,但有selectedApp,使用selectedApp
|
||||
setCurrentApp(selectedApp);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('读取 reviewType 失败:', error);
|
||||
} finally {
|
||||
// 使用setTimeout确保状态在DOM更新后再变化,避免闪烁
|
||||
setTimeout(() => {
|
||||
if (mounted) {
|
||||
setIsLoading(false); // 数据加载完成
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
const moduleName = sessionStorage.getItem('selectedModuleName');
|
||||
if (moduleName) {
|
||||
setSelectedModuleName(moduleName);
|
||||
console.log('📌 [Sidebar] 当前选中模块:', moduleName);
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [selectedApp]);
|
||||
|
||||
// 从 sessionStorage 获取 reviewType 并设置当前应用模块
|
||||
useEffect(() => {
|
||||
// 监听 sessionStorage 变化(主要用于多标签页情况)
|
||||
const handleStorageChange = (e: StorageEvent) => {
|
||||
if (e.key === 'reviewType' && e.newValue) {
|
||||
setCurrentApp(e.newValue);
|
||||
}
|
||||
};
|
||||
|
||||
// 添加事件监听器
|
||||
window.addEventListener('storage', handleStorageChange);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('storage', handleStorageChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 监听路由变化,重新检查 reviewType
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
try {
|
||||
const reviewType = sessionStorage.getItem('reviewType');
|
||||
// 只有当reviewType变化时才设置加载状态和更新currentApp
|
||||
if (reviewType && reviewType !== currentApp && mounted) {
|
||||
setIsLoading(true); // 路由变化时设置加载状态
|
||||
setCurrentApp(reviewType);
|
||||
// 使用setTimeout确保状态在DOM更新后再变化,避免闪烁
|
||||
setTimeout(() => {
|
||||
if (mounted) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('路由变化时读取 reviewType 失败:', error);
|
||||
if (mounted) {
|
||||
setIsLoading(false);
|
||||
} catch (error) {
|
||||
console.error('❌ [Sidebar] 读取 selectedModuleName 失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [location.pathname, currentApp]);
|
||||
|
||||
// 监听 selectedApp 属性变化
|
||||
useEffect(() => {
|
||||
if (selectedApp && selectedApp !== currentApp) {
|
||||
setIsLoading(true); // 设置加载状态
|
||||
setCurrentApp(selectedApp);
|
||||
// 使用setTimeout确保状态在DOM更新后再变化,避免闪烁
|
||||
setTimeout(() => {
|
||||
setIsLoading(false); // 数据加载完成
|
||||
}, 0);
|
||||
}
|
||||
}, [selectedApp, currentApp]);
|
||||
}, [location.pathname]); // 路由变化时重新读取
|
||||
|
||||
// 初始化展开状态,默认全部展开
|
||||
useEffect(() => {
|
||||
@@ -238,73 +146,72 @@ export function Sidebar({ onToggle, collapsed, userRole, frontendJWT = '', selec
|
||||
// console.log('子菜单点击:', child.title, '路径:', child.path);
|
||||
};
|
||||
|
||||
// 检查是否通过51707端口访问(省局)
|
||||
const isPort51707 = typeof window !== 'undefined' && window.location.port === '51707';
|
||||
const isPort51707 = typeof window !== 'undefined' && window.location.port === '51707'
|
||||
|
||||
// 根据当前应用模式过滤菜单项
|
||||
const filteredMenuItems = menuItems
|
||||
.filter(item => {
|
||||
// 如果是51707端口,只显示交叉评查相关菜单
|
||||
if (isPort51707) {
|
||||
// 如果当前应用是智慧法务大模型,只显示AI对话菜单
|
||||
if (currentApp === 'model') {
|
||||
return item.path && item.path.startsWith('/chat-with-llm');
|
||||
} else {
|
||||
return item.path && item.path.startsWith('/cross-checking');
|
||||
}
|
||||
// 处理菜单项:清理子菜单结构
|
||||
const processedMenuItems: MenuItem[] = menuItems.filter(item =>{
|
||||
// 如果是省局访问
|
||||
if(isPort51707){
|
||||
if (selectedModuleName === '智慧法务大模型'){
|
||||
return item.path && item.path.startsWith('/chat-with-llm')
|
||||
}
|
||||
return item.path && item.path.startsWith('/cross-checking')
|
||||
}
|
||||
|
||||
// 特殊规则1:/chat-with-llm 只在 model 模块中显示
|
||||
// 🔑 如果选择了"智慧法务大模型",只显示 /chat-with-llm 相关菜单
|
||||
if (selectedModuleName === '智慧法务大模型') {
|
||||
return item.path === '/chat-with-llm' || item.path?.startsWith('/chat-with-llm/');
|
||||
}
|
||||
|
||||
// 🔑 如果选择了包含"合同"的模块
|
||||
if (selectedModuleName.includes('合同')) {
|
||||
// 排除智慧法务大模型专属菜单
|
||||
if (item.path === '/chat-with-llm' || item.path?.startsWith('/chat-with-llm/')) {
|
||||
return currentApp === 'model';
|
||||
return false;
|
||||
}
|
||||
|
||||
// 特殊规则2:/contract-template 只在 contract 模块中显示
|
||||
if (item.path === '/contract-template' || item.path?.startsWith('/contract-template/')) {
|
||||
return currentApp === 'contract';
|
||||
}
|
||||
|
||||
// 其他路由:后端已通过 is_hidden 控制显示/隐藏,这里全部保留
|
||||
// 保留其他所有菜单(包括 /contract-template)
|
||||
return true;
|
||||
})
|
||||
.map(item => {
|
||||
// 处理子菜单:过滤隐藏的子菜单
|
||||
if (item.children && item.children.length > 0) {
|
||||
// 过滤掉 hideBreadcrumb=true 的子菜单(这些通常是隐藏菜单)
|
||||
const visibleChildren = item.children.filter(child => !child.hideBreadcrumb);
|
||||
}
|
||||
|
||||
// 如果过滤后没有可见的子菜单,返回不带子菜单的父级(变成可点击的单级菜单)
|
||||
if (visibleChildren.length === 0) {
|
||||
const { children, ...itemWithoutChildren } = item;
|
||||
return itemWithoutChildren;
|
||||
}
|
||||
// 🔑 其他模块:排除特殊菜单
|
||||
// 排除智慧法务大模型专属菜单
|
||||
if (item.path === '/chat-with-llm' || item.path?.startsWith('/chat-with-llm/')) {
|
||||
return false;
|
||||
}
|
||||
// 排除合同专属菜单
|
||||
if (item.path === '/contract-template' || item.path?.startsWith('/contract-template/')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 如果还有可见的子菜单,返回带过滤后子菜单的项
|
||||
return { ...item, children: visibleChildren };
|
||||
}
|
||||
// 保留其他菜单
|
||||
return true;
|
||||
|
||||
// 处理空 children 数组或 undefined 的情况
|
||||
if (item.children !== undefined) {
|
||||
// 如果 children 存在但为空数组,移除它(让父级变成可点击的单级菜单)
|
||||
}).map((item): MenuItem => {
|
||||
// 处理子菜单:过滤隐藏的子菜单
|
||||
if (item.children && item.children.length > 0) {
|
||||
// 过滤掉 hideBreadcrumb=true 的子菜单(这些通常是隐藏菜单)
|
||||
const visibleChildren = item.children.filter(child => !child.hideBreadcrumb);
|
||||
|
||||
// 如果过滤后没有可见的子菜单,返回不带子菜单的父级(变成可点击的单级菜单)
|
||||
if (visibleChildren.length === 0) {
|
||||
const { children, ...itemWithoutChildren } = item;
|
||||
return itemWithoutChildren;
|
||||
return itemWithoutChildren as MenuItem;
|
||||
}
|
||||
|
||||
// 没有子菜单的项直接返回
|
||||
return item;
|
||||
});
|
||||
// 如果还有可见的子菜单,返回带过滤后子菜单的项
|
||||
return { ...item, children: visibleChildren };
|
||||
}
|
||||
|
||||
// filteredMenuItems = filteredMenuItems.map(item => {
|
||||
// if(item.children && item.children.length > 0){
|
||||
// const children = item.children.filter(child => {
|
||||
// const isUploadByPath = child.path === '/files/upload' || child.path?.startsWith('/files/upload')
|
||||
// const isUploadByTitle = child.title === '文件上传'
|
||||
// return !(isUploadByPath || isUploadByTitle)
|
||||
// })
|
||||
// return { ...item, children}
|
||||
// }
|
||||
// return item
|
||||
// })
|
||||
// 处理空 children 数组或 undefined 的情况
|
||||
if (item.children !== undefined) {
|
||||
// 如果 children 存在但为空数组,移除它(让父级变成可点击的单级菜单)
|
||||
const { children, ...itemWithoutChildren } = item;
|
||||
return itemWithoutChildren as MenuItem;
|
||||
}
|
||||
|
||||
// 没有子菜单的项直接返回
|
||||
return item;
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -353,27 +260,8 @@ export function Sidebar({ onToggle, collapsed, userRole, frontendJWT = '', selec
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!collapsed && (
|
||||
<div className="px-4 py-3 border-b border-gray-100">
|
||||
<div className="flex items-center text-green-700">
|
||||
{isLoading ? (
|
||||
// 加载中状态,只显示加载图标,保留布局
|
||||
<div className="flex items-center">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-green-700 mr-2"></div>
|
||||
<span className="font-medium text-gray-500">加载中...</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<img src={APP_ICON_MAP[currentApp] || ''} alt={APP_NAME_MAP[currentApp] || ''} className="w-6 h-6 mr-2" />
|
||||
<span className="font-medium">{APP_NAME_MAP[currentApp] || ''}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="py-4 px-[10px] flex-1 overflow-y-auto sidebar-scroll-area">
|
||||
{isLoading || isLoadingRoutes ? (
|
||||
{isLoadingRoutes ? (
|
||||
// 加载中状态显示,保留菜单布局结构
|
||||
<div className="py-2">
|
||||
{Array(5).fill(0).map((_, index) => (
|
||||
@@ -387,7 +275,7 @@ export function Sidebar({ onToggle, collapsed, userRole, frontendJWT = '', selec
|
||||
</div>
|
||||
) : (
|
||||
// 数据加载完成后显示菜单
|
||||
filteredMenuItems.map((item) => (
|
||||
processedMenuItems.map((item) => (
|
||||
<div key={item.id} className={`${collapsed ? 'px-0' : ''}`}>
|
||||
{!item.children ? (
|
||||
<Link
|
||||
|
||||
@@ -24,11 +24,11 @@ export function BasicInfo({ onChange, initialData, evaluationPointGroups = [], r
|
||||
// 找到当前评查点类型对应的code
|
||||
const getCheckpointTypeCode = () => {
|
||||
if (!formData.evaluation_point_groups_pid) return "";
|
||||
|
||||
|
||||
const typeGroup = evaluationPointGroups.find(
|
||||
group => group.id === formData.evaluation_point_groups_pid && group.pid === 0
|
||||
group => group.id === formData.evaluation_point_groups_pid && (!group.pid || group.pid === 0) // 🆕 NULL或0都表示顶级分组
|
||||
);
|
||||
|
||||
|
||||
return typeGroup?.code || "";
|
||||
};
|
||||
|
||||
@@ -45,7 +45,7 @@ export function BasicInfo({ onChange, initialData, evaluationPointGroups = [], r
|
||||
group.is_enabled
|
||||
);
|
||||
|
||||
// 获取评查点类型选项(pid=0的数据)
|
||||
// 🆕 获取评查点类型选项(pid为NULL或0的数据)
|
||||
const getCheckpointTypeOptions = () => {
|
||||
if (!evaluationPointGroups || evaluationPointGroups.length === 0) {
|
||||
return (
|
||||
@@ -54,8 +54,8 @@ export function BasicInfo({ onChange, initialData, evaluationPointGroups = [], r
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const typeGroups = evaluationPointGroups.filter(group => group.pid === 0 && group.is_enabled);
|
||||
|
||||
const typeGroups = evaluationPointGroups.filter(group => (!group.pid || group.pid === 0) && group.is_enabled);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -113,8 +113,8 @@ export function BasicInfo({ onChange, initialData, evaluationPointGroups = [], r
|
||||
case 'checkpoint-type':
|
||||
// 处理评查点类型选择
|
||||
if (value) {
|
||||
// 找到选中的类型组
|
||||
const selectedType = evaluationPointGroups.find(group => group.code === value && group.pid === 0);
|
||||
// 🆕 找到选中的类型组(pid为NULL或0表示顶级分组)
|
||||
const selectedType = evaluationPointGroups.find(group => group.code === value && (!group.pid || group.pid === 0));
|
||||
if (selectedType) {
|
||||
newData.evaluation_point_groups_pid = selectedType.id;
|
||||
}
|
||||
|
||||
@@ -1029,51 +1029,55 @@ export function ExtractionSettings({
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<label
|
||||
className="form-label mb-1"
|
||||
htmlFor="regex-template-container"
|
||||
>
|
||||
常用正则模板
|
||||
</label>
|
||||
<div
|
||||
className="flex flex-wrap gap-1 mt-1"
|
||||
id="regex-template-container"
|
||||
>
|
||||
{[
|
||||
{
|
||||
label: "日期格式:yyyy-mm-dd",
|
||||
regex:
|
||||
"\\d{4}[-/年](0?[1-9]|1[0-2])[-/月](0?[1-9]|[12][0-9]|3[01])[日]?",
|
||||
},
|
||||
{ label: "合同编号格式", regex: "[A-Z]{2,5}-\\d{4,10}" },
|
||||
{
|
||||
label: "金额格式",
|
||||
regex:
|
||||
"(人民币|RMB)?\\s?(\\d{1,3}(,\\d{3})*(\\.\\d{2})?)\\s?[万元]?",
|
||||
},
|
||||
{
|
||||
label: "座机号码格式",
|
||||
regex: "\\d{3}-\\d{8}|\\d{4}-\\d{7,8}",
|
||||
},
|
||||
{ label: "手机号码格式", regex: "1[3-9]\\d{9}" },
|
||||
].map(({ label, regex }) => (
|
||||
<div
|
||||
key={label}
|
||||
className="chip cursor-pointer regex-template"
|
||||
onClick={() => applyRegexTemplate(regex)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ")
|
||||
applyRegexTemplate(regex);
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 🔑 只有在添加字段后或本来就有字段时才显示常用正则模板 */}
|
||||
{regexFields.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<label
|
||||
className="form-label mb-1"
|
||||
htmlFor="regex-template-container"
|
||||
>
|
||||
常用正则模板
|
||||
</label>
|
||||
<div
|
||||
className="flex flex-wrap gap-1 mt-1"
|
||||
id="regex-template-container"
|
||||
>
|
||||
{[
|
||||
{
|
||||
label: "日期格式:yyyy-mm-dd",
|
||||
regex:
|
||||
"\\d{4}[-/年](0?[1-9]|1[0-2])[-/月](0?[1-9]|[12][0-9]|3[01])[日]?",
|
||||
},
|
||||
{ label: "合同编号格式", regex: "[A-Z]{2,5}-\\d{4,10}" },
|
||||
{
|
||||
label: "金额格式",
|
||||
regex:
|
||||
"(人民币|RMB)?\\s?(\\d{1,3}(,\\d{3})*(\\.\\d{2})?)\\s?[万元]?",
|
||||
},
|
||||
{
|
||||
label: "座机号码格式",
|
||||
regex: "\\d{3}-\\d{8}|\\d{4}-\\d{7,8}",
|
||||
},
|
||||
{ label: "手机号码格式", regex: "1[3-9]\\d{9}" },
|
||||
].map(({ label, regex }) => (
|
||||
<div
|
||||
key={label}
|
||||
className="chip cursor-pointer regex-template"
|
||||
onClick={() => applyRegexTemplate(regex)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ")
|
||||
applyRegexTemplate(regex);
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+36
-26
@@ -126,33 +126,47 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
||||
try {
|
||||
const { getUserSession } = await import("~/api/login/auth.server");
|
||||
const session = await getUserSession(request);
|
||||
userRole = session.userRole || 'common';
|
||||
userRole = session.userRole;
|
||||
frontendJWT = session.frontendJWT || null;
|
||||
// console.log("🔑 [Root Loader] 用户角色:", userRole);
|
||||
|
||||
// 🔑 检查用户角色和JWT是否为空
|
||||
if (!userRole || userRole === '') {
|
||||
console.error("❌ [Root Loader] 用户角色为空,session数据异常");
|
||||
// 保存当前路径,登录后可以跳转回来
|
||||
const redirectTo = pathname !== '/login' ? pathname : '/';
|
||||
return redirect(`/login?redirect=${encodeURIComponent(redirectTo)}&error=no_role`);
|
||||
}
|
||||
|
||||
if (!frontendJWT) {
|
||||
console.error("❌ [Root Loader] JWT token为空,session数据异常");
|
||||
// 保存当前路径,登录后可以跳转回来
|
||||
const redirectTo = pathname !== '/login' ? pathname : '/';
|
||||
return redirect(`/login?redirect=${encodeURIComponent(redirectTo)}&error=no_token`);
|
||||
}
|
||||
|
||||
// console.log("🔑 [Root Loader] 用户角色:", userRole, "JWT前20字符:", frontendJWT.substring(0, 20));
|
||||
|
||||
// 🔒 RBAC 路由权限检查
|
||||
if (frontendJWT) {
|
||||
const { getUserRoutesByRole } = await import("~/api/auth/user-routes");
|
||||
// 权限校验需要包含隐藏路由,确保用户可以访问隐藏的功能页面
|
||||
const routesResult = await getUserRoutesByRole(userRole, frontendJWT, true);
|
||||
const { getUserRoutesByRole } = await import("~/api/auth/user-routes");
|
||||
// 权限校验需要包含隐藏路由,确保用户可以访问隐藏的功能页面
|
||||
const routesResult = await getUserRoutesByRole(userRole, frontendJWT, true);
|
||||
|
||||
if (routesResult.success && routesResult.data) {
|
||||
// 从菜单数据中提取所有允许的路径
|
||||
allowedPaths = extractAllPaths(routesResult.data);
|
||||
// console.log("🔑 [Root Loader] 用户允许的路由:", allowedPaths);
|
||||
if (routesResult.success && routesResult.data) {
|
||||
// 从菜单数据中提取所有允许的路径
|
||||
allowedPaths = extractAllPaths(routesResult.data);
|
||||
// console.log("🔑 [Root Loader] 用户允许的路由:", allowedPaths);
|
||||
|
||||
// 检查当前路径是否在允许列表中
|
||||
const isAllowedPath = isPathAllowed(pathname, allowedPaths);
|
||||
// 检查当前路径是否在允许列表中
|
||||
const isAllowedPath = isPathAllowed(pathname, allowedPaths);
|
||||
|
||||
if (!isAllowedPath) {
|
||||
console.warn(`⚠️ [Root Loader] 用户尝试访问未授权路由: ${pathname}`);
|
||||
// 返回 403 错误,而不是 redirect(避免循环)
|
||||
throw new Response("无权访问此页面", { status: 403 });
|
||||
}
|
||||
} else {
|
||||
// 获取路由权限失败,只记录警告,不阻止访问(避免影响正常使用)
|
||||
console.warn("⚠️ [Root Loader] 获取用户路由权限失败,跳过权限检查");
|
||||
if (!isAllowedPath) {
|
||||
console.warn(`⚠️ [Root Loader] 用户尝试访问未授权路由: ${pathname}`);
|
||||
// 返回 403 错误,而不是 redirect(避免循环)
|
||||
throw new Response("无权访问此页面", { status: 403 });
|
||||
}
|
||||
} else {
|
||||
// 获取路由权限失败,只记录警告,不阻止访问(避免影响正常使用)
|
||||
console.warn("⚠️ [Root Loader] 获取用户路由权限失败,跳过权限检查");
|
||||
}
|
||||
} catch (error) {
|
||||
// 如果是 Response 对象(403 错误),直接抛出
|
||||
@@ -171,13 +185,9 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
||||
console.warn("⚠️ [Root Loader] 获取用户会话失败:", error);
|
||||
// 保持默认值 'common'
|
||||
}
|
||||
}
|
||||
|
||||
// ⚠️ 重要:现在使用客户端 localStorage 存储 token,服务端不再检查 session
|
||||
// 认证检查改为在客户端进行(通过 ClientAuthGuard 组件)
|
||||
|
||||
if (!frontendJWT) {
|
||||
frontendJWT = getAccessToken();
|
||||
// 注意:认证检查和重定向已在 getUserSession() 中统一处理
|
||||
// 如果执行到这里,说明已通过认证或是公共路径
|
||||
}
|
||||
|
||||
// 检查51707端口访问控制
|
||||
|
||||
+102
-51
@@ -4,6 +4,7 @@ import { type MetaFunction, type ActionFunctionArgs, LoaderFunctionArgs, redirec
|
||||
import styles from "~/styles/pages/home.css?url";
|
||||
import dayjs from 'dayjs';
|
||||
import { getUserSession, logout } from "~/api/login/auth.server";
|
||||
import { toastService } from '~/components/ui';
|
||||
|
||||
export const links = () => [
|
||||
{ rel: "stylesheet", href: styles }
|
||||
@@ -28,15 +29,28 @@ export async function action({ request }: ActionFunctionArgs) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 获取用户信息(不再检查服务端认证)
|
||||
// 获取用户信息
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
// ⚠️ 不再检查服务端 session 认证
|
||||
// 认证检查由 ClientAuthGuard 在客户端进行
|
||||
// 🔒 认证检查已在 getUserSession() 中统一处理
|
||||
// 如果未认证,会自动重定向到登录页,不会执行到这里
|
||||
const { userRole, userInfo, frontendJWT } = await getUserSession(request);
|
||||
|
||||
const { userRole, userInfo } = await getUserSession(request);
|
||||
// 🔑 获取用户地区并查询入口模块
|
||||
const userArea = userInfo?.area || null;
|
||||
// console.log('🔍 [Index Loader] 用户地区:', userArea);
|
||||
// console.log('🔍 [Index Loader] 用户角色:', userRole);
|
||||
|
||||
// 返回用户信息给客户端(可能为空)
|
||||
return Response.json({ userRole, userInfo });
|
||||
let entryModules = [];
|
||||
if (userRole && frontendJWT) {
|
||||
const { getEntryModules } = await import('~/api/home/home');
|
||||
entryModules = await getEntryModules(userRole,userArea, frontendJWT);
|
||||
console.log(`📦 [Index Loader] 获取到 ${entryModules.length} 个入口模块`);
|
||||
} else {
|
||||
console.warn('⚠️ [Index Loader] 用户角色为空,返回空模块列表');
|
||||
}
|
||||
|
||||
// 返回用户信息和入口模块给客户端
|
||||
return Response.json({ userRole, userInfo, entryModules });
|
||||
}
|
||||
|
||||
export default function Index() {
|
||||
@@ -102,21 +116,73 @@ export default function Index() {
|
||||
}, []);
|
||||
|
||||
// 处理模块点击
|
||||
const handleModuleClick = (path: string, reviewType: string) => {
|
||||
// 将reviewType存入sessionStorage
|
||||
if (typeof window !== 'undefined') {
|
||||
sessionStorage.setItem('reviewType', reviewType);
|
||||
const handleModuleClick = (module: typeof loaderData.entryModules[0]) => {
|
||||
// 提取文档类型 IDs
|
||||
const typeIds = module.document_types?.map(dt => dt.id) || [];
|
||||
|
||||
// 🔑 验证文档类型(智慧法务大模型除外)
|
||||
if (module.name !== '智慧法务大模型' && typeIds.length === 0) {
|
||||
toastService.error('该入口尚未关联文档类型,无法进入');
|
||||
console.warn('⚠️ [Index] 模块未关联文档类型:', module.name);
|
||||
return; // 阻止进入
|
||||
}
|
||||
navigate(path);
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
// 🔑 存储到 sessionStorage(用于客户端请求)
|
||||
if (typeIds.length > 0) {
|
||||
sessionStorage.setItem('documentTypeIds', JSON.stringify(typeIds));
|
||||
|
||||
// console.log('📝 [Index] 存储到客户端 sessionStorage:', typeIds);
|
||||
} else {
|
||||
// 清空文档类型数据
|
||||
sessionStorage.removeItem('documentTypeIds');
|
||||
}
|
||||
|
||||
// 存储模块信息
|
||||
sessionStorage.setItem('selectedModuleId', String(module.id));
|
||||
sessionStorage.setItem('selectedModuleName', module.name);
|
||||
sessionStorage.setItem('selectedModulePicPath', module.path)
|
||||
}
|
||||
|
||||
// 🔑 根据模块名称决定跳转路径
|
||||
let targetPath = '/home'; // 默认跳转到首页
|
||||
|
||||
if (module.name.includes('合同')) {
|
||||
// 合同相关模块 → 跳转到合同模板搜索
|
||||
targetPath = '/contract-template/search';
|
||||
// console.log('📌 [Index] 合同模块,跳转到:', targetPath);
|
||||
} else if (module.name === '智慧法务大模型') {
|
||||
// 智慧法务大模型 → 跳转到 AI 对话
|
||||
targetPath = '/chat-with-llm';
|
||||
// console.log('📌 [Index] 智慧法务大模型,跳转到:', targetPath);
|
||||
} else {
|
||||
// console.log('📌 [Index] 其他模块,跳转到:', targetPath);
|
||||
}
|
||||
|
||||
navigate(targetPath);
|
||||
};
|
||||
|
||||
// 处理键盘事件
|
||||
const handleKeyDown = (path: string, reviewType: string, e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
const handleKeyDown = (module: typeof loaderData.entryModules[0], e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
handleModuleClick(path, reviewType);
|
||||
handleModuleClick(module);
|
||||
}
|
||||
};
|
||||
|
||||
// 获取模块图标(根据模块 path 或 id)
|
||||
const getModuleIcon = (module: typeof loaderData.entryModules[0]) => {
|
||||
// 根据 path 判断图标
|
||||
if (module.path?.includes('ht')) {
|
||||
return '/images/icon_hetong.png';
|
||||
} else if (module.path?.includes('aj')) {
|
||||
return '/images/icon_anjuan.png';
|
||||
} else if (module.path?.includes('nw')) {
|
||||
return '/images/icon_assistant.png';
|
||||
}
|
||||
// 默认图标
|
||||
return '/images/icon_assistant.png';
|
||||
};
|
||||
|
||||
// 处理登出
|
||||
const handleLogout = () => {
|
||||
// 清除sessionStorage中的所有数据
|
||||
@@ -182,46 +248,31 @@ export default function Index() {
|
||||
<h1 className="welcome-text">- 欢迎来到智慧法务平台 -</h1>
|
||||
|
||||
<div className="modules-container">
|
||||
{/* 合同管理模块 - 51708端口时隐藏 */}
|
||||
{!isPort51707 && (
|
||||
<div
|
||||
className="module-card"
|
||||
onClick={() => handleModuleClick('/contract-template/search', 'contract')}
|
||||
onKeyDown={(e) => handleKeyDown('/contract-template/search', 'contract', e)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label="合同管理"
|
||||
>
|
||||
<img src="/images/icon_hetong.png" alt="合同管理" className="w-12 h-12 mx-1" />
|
||||
<span className="module-name">合同管理</span>
|
||||
{/* 动态渲染入口模块 */}
|
||||
{loaderData.entryModules && loaderData.entryModules.length > 0 ? (
|
||||
loaderData.entryModules.map((module) => (
|
||||
<div
|
||||
key={module.id}
|
||||
className="module-card"
|
||||
onClick={() => handleModuleClick(module)}
|
||||
onKeyDown={(e) => handleKeyDown(module, e)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={module.name}
|
||||
>
|
||||
<img
|
||||
src={getModuleIcon(module)}
|
||||
alt={module.name}
|
||||
className="w-12 h-12 mx-1"
|
||||
/>
|
||||
<span className="module-name">{module.name}</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center text-gray-500 py-8">
|
||||
暂无可用模块
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 案卷智能评查模块 */}
|
||||
<div
|
||||
className="module-card"
|
||||
onClick={() => handleModuleClick('/home', 'record')}
|
||||
onKeyDown={(e) => handleKeyDown('/home', 'record', e)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label="案卷智能评查"
|
||||
>
|
||||
<img src="/images/icon_anjuan.png" alt="案卷智能评查" className="w-12 h-12" />
|
||||
<span className="module-name">案卷智能评查</span>
|
||||
</div>
|
||||
|
||||
{/* 智慧法务大模型模块 */}
|
||||
<div
|
||||
className="module-card"
|
||||
onClick={() => handleModuleClick('/chat-with-llm', 'model')}
|
||||
onKeyDown={(e) => handleKeyDown('/chat-with-llm', 'model', e)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label="智慧法务大模型"
|
||||
>
|
||||
<img src="/images/icon_assistant.png" alt="智慧法务大模型" className="w-12 h-12" />
|
||||
<span className="module-name">智慧法务大模型</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -205,6 +205,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
||||
is_leader: savedUserInfo.is_leader,
|
||||
user_id: savedUserInfo.user_id,
|
||||
user_role: savedUserInfo.user_role, // 使用后端返回的角色
|
||||
area: savedUserInfo.area, // 🔑 用户所属地区
|
||||
frontend_jwt: frontendJWT
|
||||
};
|
||||
|
||||
@@ -222,6 +223,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
||||
ou_name: savedUserInfo.ou_name,
|
||||
is_leader: savedUserInfo.is_leader,
|
||||
user_role: savedUserInfo.user_role,
|
||||
area: savedUserInfo.area, // 🔑 用户所属地区
|
||||
sub: userInfo.data.sub
|
||||
})));
|
||||
callbackUrl.searchParams.set('redirectTo', redirectTo);
|
||||
|
||||
@@ -56,7 +56,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
label: type.name
|
||||
}));
|
||||
|
||||
// 初始返回空数据,将在客户端根据 sessionStorage 中的 reviewType 加载实际数据
|
||||
// 初始返回空数据,将在客户端根据 sessionStorage 中的 documentTypeIds 加载实际数据
|
||||
return Response.json({
|
||||
documents: [],
|
||||
total: 0,
|
||||
@@ -64,6 +64,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
pageSize,
|
||||
documentTypeOptions,
|
||||
userInfo, // 传递用户信息到客户端
|
||||
frontendJWT, // 传递 JWT 到客户端
|
||||
initialLoad: true // 标记这是初始加载
|
||||
});
|
||||
};
|
||||
@@ -295,16 +296,22 @@ export default function DocumentsIndex() {
|
||||
throw new Error(documentsResponse.error);
|
||||
}
|
||||
|
||||
// 🔑 从 sessionStorage 读取文档类型 IDs
|
||||
const typeIdsStr = sessionStorage.getItem('documentTypeIds');
|
||||
const documentTypeIds = typeIdsStr ? JSON.parse(typeIdsStr) : undefined;
|
||||
|
||||
// 获取经过过滤的文档类型列表(token 由 axios 拦截器自动获取)
|
||||
const filteredTypesResponse = await getDocumentTypes({
|
||||
pageSize: 500,
|
||||
reviewType: storedReviewType || undefined
|
||||
documentTypeIds: documentTypeIds // 使用动态的文档类型 IDs
|
||||
});
|
||||
const filteredDocumentTypes = filteredTypesResponse.data?.types || [];
|
||||
const filteredOptions = filteredDocumentTypes.map(type => ({
|
||||
value: type.id,
|
||||
label: type.name
|
||||
}));
|
||||
|
||||
// console.log('文档列表',documentsResponse)
|
||||
|
||||
// 更新状态
|
||||
setDocuments(documentsResponse.data?.documents || []);
|
||||
@@ -824,7 +831,8 @@ export default function DocumentsIndex() {
|
||||
attachmentFiles,
|
||||
attachmentMergeMode,
|
||||
true, // isReprocess
|
||||
attachmentRemark || undefined
|
||||
attachmentRemark || undefined,
|
||||
loaderData.frontendJWT
|
||||
);
|
||||
|
||||
if (result.error) {
|
||||
@@ -1153,7 +1161,7 @@ export default function DocumentsIndex() {
|
||||
{record.historyCount !== undefined && record.historyCount > 0 ?
|
||||
<span className="version-badge">
|
||||
<i className="ri-history-line"></i>
|
||||
v{record.historyCount + 1} {record.historyCount !== undefined && `(共${record.historyCount}个历史版本)`}
|
||||
v{record.historyCount + 1} {record.historyCount !== undefined && `(有${record.historyCount}个历史版本)`}
|
||||
</span> : ""
|
||||
}
|
||||
</div>
|
||||
|
||||
+66
-72
@@ -259,11 +259,11 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
||||
const url = new URL(request.url);
|
||||
const mode = url.searchParams.get("mode") || "create";
|
||||
|
||||
// 我们不能在服务器端访问 sessionStorage,所以在客户端组件中处理 reviewType 过滤
|
||||
// 我们不能在服务器端访问 sessionStorage,所以在客户端组件中处理 documentTypeIds 过滤
|
||||
// 并行加载文档和文档类型
|
||||
const [documentsResponse, typesResponse] = await Promise.all([
|
||||
getTodayDocuments(userInfo, undefined, frontendJWT),
|
||||
getDocumentTypes(undefined, frontendJWT)
|
||||
getTodayDocuments(userInfo, frontendJWT),
|
||||
getDocumentTypes(frontendJWT)
|
||||
]);
|
||||
|
||||
// console.log('loader: 文档加载结果:', documentsResponse);
|
||||
@@ -308,9 +308,9 @@ export default function FilesUpload() {
|
||||
const [isNavigating, setIsNavigating] = useState(false)
|
||||
const revalidator = useRevalidator()
|
||||
|
||||
// 获取 sessionStorage 中的 reviewType 值
|
||||
// 获取 sessionStorage 中的 documentTypeIds 值
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const [reviewType, setReviewType] = useState<string | null>(null);
|
||||
const [documentTypeIds, setDocumentTypeIds] = useState<number[] | null>(null);
|
||||
|
||||
// 使用 useLoaderData 获取初始数据
|
||||
const loaderData = useLoaderData<LoaderData>();
|
||||
@@ -361,19 +361,20 @@ export default function FilesUpload() {
|
||||
const [queueFiles, setQueueFiles] = useState<Document[]>([]);
|
||||
const [documentTypesState, setDocumentTypesState] = useState<DocumentType[]>([]);
|
||||
|
||||
// 在组件挂载时从 sessionStorage 获取 reviewType
|
||||
// 在组件挂载时从 sessionStorage 获取 documentTypeIds
|
||||
useEffect(() => {
|
||||
try {
|
||||
// 在客户端环境中执行
|
||||
if (typeof window !== 'undefined') {
|
||||
const storedReviewType = sessionStorage.getItem('reviewType');
|
||||
setReviewType(storedReviewType);
|
||||
// 根据 reviewType 过滤文档类型和文档列表
|
||||
filterDocumentTypes(storedReviewType, loaderData.documentTypes);
|
||||
filterDocuments(storedReviewType);
|
||||
|
||||
// 如果reviewType是contract,自动选择合同文档类型
|
||||
if (storedReviewType === 'contract') {
|
||||
const typeIdsStr = sessionStorage.getItem('documentTypeIds');
|
||||
const typeIds = typeIdsStr ? JSON.parse(typeIdsStr) : null;
|
||||
setDocumentTypeIds(typeIds);
|
||||
// 根据 documentTypeIds 过滤文档类型和文档列表
|
||||
filterDocumentTypes(typeIds, loaderData.documentTypes);
|
||||
filterDocuments(typeIds);
|
||||
|
||||
// 如果包含合同类型(ID=1),自动选择合同文档类型
|
||||
if (typeIds && typeIds.includes(1)) {
|
||||
setIsContractType(true);
|
||||
// 查找ID为1的合同文档类型
|
||||
const contractType = loaderData.documentTypes.find(type => type.id === 1);
|
||||
@@ -385,49 +386,39 @@ export default function FilesUpload() {
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取 sessionStorage 中的 reviewType 失败:', error);
|
||||
console.error('获取 sessionStorage 中的 documentTypeIds 失败:', error);
|
||||
}
|
||||
}, [loaderData]);
|
||||
|
||||
// 过滤文档类型列表
|
||||
const filterDocumentTypes = (reviewType: string | null, types: DocumentType[]) => {
|
||||
if (!reviewType) {
|
||||
// 如果没有特定的 reviewType,使用原始数据
|
||||
const filterDocumentTypes = (documentTypeIds: number[] | null, types: DocumentType[]) => {
|
||||
if (!documentTypeIds || documentTypeIds.length === 0) {
|
||||
// 如果没有特定的 documentTypeIds,使用原始数据
|
||||
setDocumentTypesState(types);
|
||||
return;
|
||||
}
|
||||
|
||||
let filteredTypes: DocumentType[] = [];
|
||||
|
||||
if (reviewType === 'contract') {
|
||||
// 只保留 id=1 的选项
|
||||
filteredTypes = types.filter(type => type.id === 1);
|
||||
} else if (reviewType === 'record') {
|
||||
// 只保留 id=2 和 id=3 的选项
|
||||
filteredTypes = types.filter(type => type.id === 2 || type.id === 3 || type.id === 155);
|
||||
} else {
|
||||
// 如果reviewType不匹配任何条件,使用原始数据
|
||||
filteredTypes = types;
|
||||
}
|
||||
|
||||
|
||||
// 根据 documentTypeIds 过滤文档类型
|
||||
const filteredTypes = types.filter(type => documentTypeIds.includes(type.id));
|
||||
|
||||
setDocumentTypesState(filteredTypes);
|
||||
};
|
||||
|
||||
// 过滤文档列表
|
||||
const filterDocuments = async (reviewType: string | null) => {
|
||||
if (!reviewType) {
|
||||
// 如果没有特定的 reviewType,使用原始数据
|
||||
const filterDocuments = async (documentTypeIds: number[] | null) => {
|
||||
if (!documentTypeIds || documentTypeIds.length === 0) {
|
||||
// 如果没有特定的 documentTypeIds,使用原始数据
|
||||
const documents = loaderData.documents;
|
||||
setQueueFiles(documents);
|
||||
|
||||
|
||||
// 启动状态检查定时器
|
||||
startStatusChecker(documents);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
// 使用 reviewType 获取过滤后的文档列表
|
||||
const response = await getTodayDocuments(loaderData.userInfo || undefined, reviewType, loaderData.frontendJWT || undefined);
|
||||
// 使用 documentTypeIds 获取过滤后的文档列表
|
||||
const response = await getTodayDocuments(loaderData.userInfo || undefined, loaderData.frontendJWT || undefined, documentTypeIds);
|
||||
|
||||
if (response.error) {
|
||||
console.error('过滤文档列表失败:', response.error);
|
||||
@@ -559,45 +550,48 @@ export default function FilesUpload() {
|
||||
const checkQueueStatusWithFiles = async (files: Document[]) => {
|
||||
try {
|
||||
// console.log('开始检查队列状态,当前队列文件:', files);
|
||||
|
||||
// 直接从sessionStorage读取reviewType,避免异步状态更新问题
|
||||
const currentReviewType = typeof window !== 'undefined' ? sessionStorage.getItem('reviewType') : null;
|
||||
// console.log('从sessionStorage读取的reviewType:', currentReviewType);
|
||||
|
||||
|
||||
// 直接从sessionStorage读取documentTypeIds,避免异步状态更新问题
|
||||
const typeIdsStr = typeof window !== 'undefined' ? sessionStorage.getItem('documentTypeIds') : null;
|
||||
const currentDocumentTypeIds = typeIdsStr ? JSON.parse(typeIdsStr) : null;
|
||||
// console.log('从sessionStorage读取的documentTypeIds:', currentDocumentTypeIds);
|
||||
|
||||
// 获取所有未完成的文档
|
||||
const incompleteFiles = files.filter(file =>
|
||||
const incompleteFiles = files.filter(file =>
|
||||
file.status !== DocumentStatus.PROCESSED && file.id
|
||||
);
|
||||
|
||||
|
||||
if (incompleteFiles.length === 0) {
|
||||
console.log('没有未完成的文档,跳过状态检查');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
let statusResponse;
|
||||
|
||||
// 如果是合同类型,需要分类处理
|
||||
console.log('当前reviewType:', currentReviewType);
|
||||
if (currentReviewType === 'contract') {
|
||||
// 分类文档ID
|
||||
const mainDocumentIds: number[] = [];
|
||||
const attachmentIds: number[] = [];
|
||||
|
||||
// 如果是合同类型(ID=1),需要分类处理
|
||||
// console.log('当前documentTypeIds:', currentDocumentTypeIds);
|
||||
// if (currentDocumentTypeIds && currentDocumentTypeIds.includes(1)) {
|
||||
// // 分类文档ID
|
||||
// const mainDocumentIds: number[] = [];
|
||||
// const attachmentIds: number[] = [];
|
||||
|
||||
incompleteFiles.forEach(file => {
|
||||
// 检查是否存在template_contract_path属性来判断是否为合同附件
|
||||
if ('template_contract_path' in file && file.template_contract_path) {
|
||||
attachmentIds.push(file.id);
|
||||
} else {
|
||||
mainDocumentIds.push(file.id);
|
||||
}
|
||||
});
|
||||
// incompleteFiles.forEach(file => {
|
||||
// // 检查是否存在template_contract_path属性来判断是否为合同附件
|
||||
// if ('template_contract_path' in file && file.template_contract_path) {
|
||||
// attachmentIds.push(file.id);
|
||||
// } else {
|
||||
// mainDocumentIds.push(file.id);
|
||||
// }
|
||||
// });
|
||||
|
||||
// console.log('合同主文件ID:', mainDocumentIds);
|
||||
// console.log('合同附件ID:', attachmentIds);
|
||||
// // console.log('合同主文件ID:', mainDocumentIds);
|
||||
// // console.log('合同附件ID:', attachmentIds);
|
||||
|
||||
// 分别查询状态
|
||||
statusResponse = await getDocumentsStatus(mainDocumentIds, attachmentIds, loaderData.frontendJWT || undefined);
|
||||
} else {
|
||||
// // 分别查询状态
|
||||
// statusResponse = await getDocumentsStatus(mainDocumentIds, attachmentIds, loaderData.frontendJWT || undefined);
|
||||
// }
|
||||
// else
|
||||
{
|
||||
// 非合同类型,使用原有逻辑
|
||||
const incompleteIds = incompleteFiles.map(file => file.id);
|
||||
// console.log('未完成的文档ID:', incompleteIds);
|
||||
@@ -959,9 +953,9 @@ export default function FilesUpload() {
|
||||
setAttachmentRemark("");
|
||||
setShowAttachmentUpload(false);
|
||||
setSelectedDocumentId(null);
|
||||
|
||||
|
||||
// 刷新文档列表
|
||||
await filterDocuments(reviewType);
|
||||
await filterDocuments(documentTypeIds);
|
||||
|
||||
} catch (error) {
|
||||
console.error('【附件追加】上传失败:', error);
|
||||
@@ -1028,9 +1022,9 @@ export default function FilesUpload() {
|
||||
setTemplateFile(null);
|
||||
setShowTemplateUpload(false);
|
||||
setSelectedDocumentId(null);
|
||||
|
||||
|
||||
// 刷新文档列表
|
||||
await filterDocuments(reviewType);
|
||||
await filterDocuments(documentTypeIds);
|
||||
|
||||
} catch (error) {
|
||||
console.error('【合同模板上传】上传失败:', error);
|
||||
@@ -1163,7 +1157,7 @@ export default function FilesUpload() {
|
||||
setCompletedFiles(uploadedFiles);
|
||||
startProcessing(uploadedFiles);
|
||||
// 刷新队列
|
||||
await filterDocuments(reviewType);
|
||||
await filterDocuments(documentTypeIds);
|
||||
} catch (error) {
|
||||
console.error('合同首传上传失败:', error);
|
||||
messageService.error(`合同上传失败:${error instanceof Error ? error.message : '未知错误'}`);
|
||||
|
||||
+43
-6
@@ -24,21 +24,41 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
||||
// ⚠️ 不再检查服务端 session 认证
|
||||
// 认证检查改为在客户端通过 localStorage 进行
|
||||
|
||||
// 获取重定向URL
|
||||
// 获取重定向URL和错误参数
|
||||
const url = new URL(request.url);
|
||||
const redirectTo = url.searchParams.get("redirect") || "/";
|
||||
const urlError = url.searchParams.get("error");
|
||||
|
||||
const session = await getSession(request);
|
||||
|
||||
// 读取 flash 消息(来自 callback 的错误)
|
||||
const loginError = session.get("loginError");
|
||||
|
||||
// 将URL错误参数转换为友好的错误消息
|
||||
let urlErrorMessage: string | null = null;
|
||||
if (urlError) {
|
||||
switch (urlError) {
|
||||
case 'no_role':
|
||||
urlErrorMessage = '用户角色信息缺失,请重新登录';
|
||||
break;
|
||||
case 'no_token':
|
||||
urlErrorMessage = '认证令牌缺失,请重新登录';
|
||||
break;
|
||||
case 'session_expired':
|
||||
urlErrorMessage = '会话已过期,请重新登录';
|
||||
break;
|
||||
default:
|
||||
urlErrorMessage = '登录状态异常,请重新登录';
|
||||
}
|
||||
}
|
||||
|
||||
// 提交 session 以清除 flash 消息
|
||||
if (loginError) {
|
||||
const { sessionStorage } = await import("~/api/login/auth.server");
|
||||
return Response.json({
|
||||
redirectTo,
|
||||
flashError: loginError
|
||||
flashError: loginError,
|
||||
urlError: urlErrorMessage
|
||||
}, {
|
||||
headers: {
|
||||
"Set-Cookie": await sessionStorage.commitSession(session)
|
||||
@@ -48,7 +68,8 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
||||
|
||||
return Response.json({
|
||||
redirectTo,
|
||||
flashError: null
|
||||
flashError: null,
|
||||
urlError: urlErrorMessage
|
||||
});
|
||||
}
|
||||
|
||||
@@ -107,6 +128,11 @@ export async function action({ request }: ActionFunctionArgs) {
|
||||
}, { status: 500 });
|
||||
}
|
||||
|
||||
// 测试,管理员账密返回的时候默认给没有area信息
|
||||
// if(!user_info.area){
|
||||
// user_info.area = '梅州'
|
||||
// }
|
||||
|
||||
// 🔑 将后端返回的 issued_time 转换为时间戳(毫秒)
|
||||
let tokenIssuedAt = Date.now(); // 默认使用当前时间
|
||||
if (issued_time) {
|
||||
@@ -141,6 +167,7 @@ export async function action({ request }: ActionFunctionArgs) {
|
||||
ou_name: user_info.ou_name,
|
||||
is_leader: user_info.is_leader,
|
||||
user_role: user_info.user_role,
|
||||
area: user_info.area, // 🔑 用户所属地区
|
||||
sub: user_info.sub
|
||||
})));
|
||||
callbackUrl.searchParams.set('redirectTo', redirectTo);
|
||||
@@ -163,6 +190,7 @@ export async function action({ request }: ActionFunctionArgs) {
|
||||
ou_name: user_info.ou_name,
|
||||
is_leader: user_info.is_leader,
|
||||
user_role: user_info.user_role,
|
||||
area: user_info.area, // 🔑 用户所属地区
|
||||
sub: user_info.sub
|
||||
}
|
||||
});
|
||||
@@ -185,11 +213,12 @@ export default function Login() {
|
||||
const [password, setPassword] = useState("");
|
||||
const [passwordLoginError, setPasswordLoginError] = useState<string | null>(null);
|
||||
|
||||
// 从 loaderData 中获取 OAuth 回调的错误信息
|
||||
// 从 loaderData 中获取错误信息
|
||||
const oauthError = loaderData?.flashError;
|
||||
const urlError = loaderData?.urlError;
|
||||
|
||||
// 显示的错误信息:密码登录错误优先,其次是 OAuth 错误
|
||||
const error = passwordLoginError || oauthError;
|
||||
// 显示的错误信息:密码登录错误优先,其次是 URL 错误,最后是 OAuth 错误
|
||||
const error = passwordLoginError || urlError || oauthError;
|
||||
const isLocked = false; // 可以从后端响应中获取
|
||||
const retryCount = 0;
|
||||
const remainingAttempts = 5;
|
||||
@@ -283,6 +312,14 @@ export default function Login() {
|
||||
}
|
||||
}, [fetcher.data]);
|
||||
|
||||
// 显示URL错误参数的Toast提示
|
||||
useEffect(() => {
|
||||
if (urlError) {
|
||||
console.warn("⚠️ [Login] 检测到URL错误参数:", urlError);
|
||||
toastService.error(urlError);
|
||||
}
|
||||
}, [urlError]);
|
||||
|
||||
useEffect(() => {
|
||||
// 🔑 只在 token 过期时清理客户端存储
|
||||
// 检查 URL 参数中是否有 expired=true 标识
|
||||
|
||||
@@ -1,985 +0,0 @@
|
||||
import { type MetaFunction, type LoaderFunctionArgs } from "@remix-run/node";
|
||||
import { useLoaderData, useSearchParams, useNavigate } from "@remix-run/react";
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { Button } from "~/components/ui/Button";
|
||||
import { Card } from "~/components/ui/Card";
|
||||
import { FileIcon } from "~/components/ui/FileIcon";
|
||||
import { FilterPanel, FilterSelect, SearchFilter, DateRangeFilter } from "~/components/ui/FilterPanel";
|
||||
import { Pagination } from "~/components/ui/Pagination";
|
||||
import { Table } from "~/components/ui/Table";
|
||||
import { StatusBadge } from "~/components/ui/StatusBadge";
|
||||
import { FileTypeTag, links as fileTypeTagLinks } from "~/components/ui/FileTypeTag";
|
||||
import { NumberSkeleton, TableRowSkeleton, LoadingIndicator } from "~/components/ui/SkeletonScreen";
|
||||
import rulesFilesStyles from "~/styles/pages/rules-files.css?url";
|
||||
import {
|
||||
getReviewFiles,
|
||||
type ReviewFileUI,
|
||||
updateDocumentAuditStatus,
|
||||
type DocumentSearchParams
|
||||
} from "~/api/evaluation_points/rules-files";
|
||||
import { getDocumentTypes } from "~/api/document-types/document-types";
|
||||
import { toastService } from "~/components/ui/Toast";
|
||||
// 导入axios下载文件方法
|
||||
import { downloadFile } from "~/api/axios-client";
|
||||
import { appendContractAttachments } from "~/api/files/files-upload";
|
||||
import { messageService } from "~/components/ui/MessageModal";
|
||||
|
||||
export const links = () => [
|
||||
{ rel: "stylesheet", href: rulesFilesStyles },
|
||||
...fileTypeTagLinks()
|
||||
];
|
||||
|
||||
export const handle = {
|
||||
breadcrumb: "评查文件列表"
|
||||
};
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
return [
|
||||
{ title: "评查文件列表 - 中国烟草AI合同及卷宗审核系统" },
|
||||
{ name: "description", content: "管理系统中所有上传的评查文件,支持按文件类型、评查状态进行筛选" },
|
||||
{ name: "keywords", content: "评查文件,合同审核,中国烟草,文件管理" }
|
||||
];
|
||||
};
|
||||
|
||||
// 日期范围枚举
|
||||
export enum DateRange {
|
||||
ALL = 'all',
|
||||
TODAY = 'today',
|
||||
WEEK = 'week',
|
||||
MONTH = 'month',
|
||||
CUSTOM = 'custom'
|
||||
}
|
||||
|
||||
// 评查状态标签映射
|
||||
export const REVIEW_STATUS_LABELS: Record<string, string> = {
|
||||
'pass': '通过',
|
||||
'warning': '警告',
|
||||
'fail': '不通过',
|
||||
'pending': '待人工确认'
|
||||
};
|
||||
|
||||
// 加载评查文件列表
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
// 获取用户会话信息
|
||||
const { getUserSession } = await import("~/api/login/auth.server");
|
||||
const { userInfo, frontendJWT } = await getUserSession(request);
|
||||
|
||||
// 获取分页参数
|
||||
const url = new URL(request.url);
|
||||
const currentPage = parseInt(url.searchParams.get("page") || "1", 10);
|
||||
const pageSize = parseInt(url.searchParams.get("pageSize") || "10", 10);
|
||||
|
||||
try {
|
||||
// 获取文档类型列表(服务端需要显式传递 token,客户端依赖 axios 拦截器)
|
||||
const typesResponse = await getDocumentTypes({pageSize:500}, frontendJWT);
|
||||
const documentTypes = typesResponse.data?.types || [];
|
||||
|
||||
// 返回初始空数据,客户端将根据 sessionStorage 中的 reviewType 加载实际数据
|
||||
return Response.json({
|
||||
files: [],
|
||||
documentTypes,
|
||||
totalCount: 0,
|
||||
currentPage,
|
||||
pageSize,
|
||||
userInfo, // 传递用户信息到客户端
|
||||
initialLoad: true
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('加载评查文件列表失败:', error);
|
||||
return Response.json({ result: false, message: error instanceof Error ? error.message : '加载评查文件列表失败' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export default function RulesFiles() {
|
||||
const navigate = useNavigate();
|
||||
const { files: initialFiles, documentTypes: allDocumentTypes, totalCount: initialTotal, currentPage, pageSize, userInfo, result, message } = useLoaderData<typeof loader>();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const dateFrom = searchParams.get('dateFrom') || '';
|
||||
const dateTo = searchParams.get('dateTo') || '';
|
||||
|
||||
// 添加状态管理
|
||||
const [files, setFiles] = useState<ReviewFileUI[]>(initialFiles);
|
||||
const [documentTypes, setDocumentTypes] = useState(allDocumentTypes);
|
||||
const [totalCount, setTotalCount] = useState(initialTotal);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [reviewType, setReviewType] = useState<string | null>(null);
|
||||
|
||||
// 附件追加相关状态
|
||||
const [showAttachmentUpload, setShowAttachmentUpload] = useState<boolean>(false);
|
||||
const [selectedDocumentId, setSelectedDocumentId] = useState<string | null>(null);
|
||||
const [attachmentFiles, setAttachmentFiles] = useState<File[]>([]);
|
||||
const [attachmentMergeMode, setAttachmentMergeMode] = useState<'overwrite' | 'new'>('overwrite');
|
||||
const [attachmentRemark, setAttachmentRemark] = useState<string>("");
|
||||
const [attachmentUploading, setAttachmentUploading] = useState<boolean>(false);
|
||||
|
||||
// 保存/恢复 查询参数 的 sessionStorage key
|
||||
const SEARCH_PARAMS_STORAGE_KEY = 'rulesFiles.searchParams';
|
||||
|
||||
const persistSearchParams = useCallback((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
|
||||
}, []);
|
||||
|
||||
// 处理初始加载数据loader的错误
|
||||
useEffect(() => {
|
||||
if(result === false && message) {
|
||||
toastService.error(message);
|
||||
}
|
||||
}, [result, message]);
|
||||
|
||||
// 辅助函数:从 localStorage 获取用户ID
|
||||
const getUserId = useCallback((): string | undefined => {
|
||||
if (typeof window === 'undefined') return undefined;
|
||||
|
||||
const userInfoStr = localStorage.getItem('user_info');
|
||||
if (!userInfoStr) return undefined;
|
||||
|
||||
try {
|
||||
const userInfoData = JSON.parse(userInfoStr);
|
||||
return userInfoData.user_id?.toString();
|
||||
} catch (error) {
|
||||
console.error('解析 localStorage 用户信息失败:', error);
|
||||
return undefined;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 客户端数据请求
|
||||
const fetchData = useCallback(async (params: Record<string, string>) => {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
// 构建搜索参数(token 由 axios 拦截器自动从 localStorage 获取)
|
||||
const searchParams: DocumentSearchParams = {
|
||||
fileType: params.fileType || undefined,
|
||||
reviewStatus: params.reviewStatus || undefined,
|
||||
dateFrom: params.dateFrom || undefined,
|
||||
dateTo: params.dateTo || undefined,
|
||||
keyword: params.keyword || undefined,
|
||||
sortOrder: params.sortOrder || 'upload_time_desc',
|
||||
page: parseInt(params.page || "1", 10),
|
||||
pageSize: parseInt(params.pageSize || "10", 10)
|
||||
};
|
||||
|
||||
// 根据 reviewType 添加类型过滤
|
||||
if (reviewType === 'contract') {
|
||||
searchParams.fileType = 'contract';
|
||||
} else if (reviewType === 'record') {
|
||||
// 在 API 层处理 type_id 为 2 或 3 的过滤
|
||||
searchParams.fileType = 'record';
|
||||
}
|
||||
|
||||
// 如果用户手动选择了文件类型,优先使用用户选择的
|
||||
if (params.fileType) {
|
||||
searchParams.fileType = params.fileType;
|
||||
}
|
||||
|
||||
// 从 localStorage 获取用户ID(与 token 管理保持一致)
|
||||
const userId = getUserId();
|
||||
if (!userId) {
|
||||
throw new Error('用户身份验证失败,无法获取评查文件列表');
|
||||
}
|
||||
|
||||
// 获取文件列表(token 由 axios 拦截器自动添加)
|
||||
const filesResponse = await getReviewFiles(searchParams, null, userId);
|
||||
if (filesResponse.error) {
|
||||
throw new Error(filesResponse.error);
|
||||
}
|
||||
|
||||
setFiles(filesResponse.data?.files || []);
|
||||
setTotalCount(filesResponse.data?.total || 0);
|
||||
} catch (error) {
|
||||
console.error('获取评查文件列表失败:', error);
|
||||
toastService.error('获取评查文件列表失败: ' + (error instanceof Error ? error.message : '未知错误'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [reviewType, getUserId]); // 使用 getUserId 辅助函数
|
||||
|
||||
// 在组件挂载时从 sessionStorage 获取 reviewType 并加载数据
|
||||
useEffect(() => {
|
||||
try {
|
||||
if (typeof window !== 'undefined') {
|
||||
const storedReviewType = sessionStorage.getItem('reviewType');
|
||||
|
||||
// 根据 reviewType 过滤文档类型选项
|
||||
if (storedReviewType) {
|
||||
setReviewType(storedReviewType);
|
||||
|
||||
if (storedReviewType === 'contract') {
|
||||
// 只保留 id=1 的选项
|
||||
const filteredTypes = allDocumentTypes.filter((type: {id: number}) => type.id === 1);
|
||||
setDocumentTypes(filteredTypes);
|
||||
} else if (storedReviewType === 'record') {
|
||||
// 只保留 id=2 和 id=3 的选项
|
||||
const filteredTypes = allDocumentTypes.filter((type: {id: number}) => type.id === 2 || type.id === 3 || type.id === 155);
|
||||
setDocumentTypes(filteredTypes);
|
||||
}
|
||||
|
||||
// 直接使用 storedReviewType 构建搜索参数
|
||||
const currentParams = Object.fromEntries(searchParams.entries());
|
||||
const apiSearchParams: DocumentSearchParams = {
|
||||
fileType: currentParams.fileType || undefined,
|
||||
reviewStatus: currentParams.reviewStatus || undefined,
|
||||
dateFrom: currentParams.dateFrom || undefined,
|
||||
dateTo: currentParams.dateTo || undefined,
|
||||
keyword: currentParams.keyword || undefined,
|
||||
sortOrder: currentParams.sortOrder || 'upload_time_desc',
|
||||
page: parseInt(currentParams.page || "1", 10),
|
||||
pageSize: parseInt(currentParams.pageSize || "10", 10)
|
||||
};
|
||||
|
||||
// 根据 storedReviewType 添加类型过滤
|
||||
if (storedReviewType === 'contract') {
|
||||
apiSearchParams.fileType = '1';
|
||||
} else if (storedReviewType === 'record') {
|
||||
apiSearchParams.fileType = 'record';
|
||||
}
|
||||
|
||||
// 如果用户手动选择了文件类型,优先使用用户选择的
|
||||
if (currentParams.fileType) {
|
||||
apiSearchParams.fileType = currentParams.fileType;
|
||||
}
|
||||
|
||||
// 设置加载状态
|
||||
setIsLoading(true);
|
||||
|
||||
// 从 localStorage 获取用户ID
|
||||
const userId = getUserId();
|
||||
if (!userId) {
|
||||
toastService.error('用户身份验证失败,无法获取评查文件列表');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取文件列表(token 由 axios 拦截器自动添加)
|
||||
getReviewFiles(apiSearchParams, null, userId)
|
||||
.then(filesResponse => {
|
||||
if (filesResponse.error) {
|
||||
throw new Error(filesResponse.error);
|
||||
}
|
||||
|
||||
setFiles(filesResponse.data?.files || []);
|
||||
setTotalCount(filesResponse.data?.total || 0);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('获取评查文件列表失败:', error);
|
||||
toastService.error('获取评查文件列表失败: ' + (error instanceof Error ? error.message : '未知错误'));
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取 sessionStorage 中的 reviewType 失败:', error);
|
||||
}
|
||||
}, [allDocumentTypes, searchParams]);
|
||||
|
||||
// 监听 URL 参数变化,重新获取数据
|
||||
useEffect(() => {
|
||||
if (reviewType) {
|
||||
fetchData(Object.fromEntries(searchParams.entries()));
|
||||
}
|
||||
}, [searchParams, fetchData, reviewType]);
|
||||
|
||||
// 处理筛选条件变更
|
||||
const handleFilterChange = (e: React.ChangeEvent<HTMLSelectElement | HTMLInputElement>) => {
|
||||
const { name, value } = e.target;
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
|
||||
if (value) {
|
||||
newParams.set(name, value);
|
||||
} else {
|
||||
newParams.delete(name);
|
||||
}
|
||||
|
||||
// 切换筛选条件时,重置到第一页
|
||||
newParams.set('page', '1');
|
||||
|
||||
persistSearchParams(newParams);
|
||||
setSearchParams(newParams);
|
||||
};
|
||||
|
||||
// 处理搜索操作
|
||||
const handleSearch = (keyword: string) => {
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
if (keyword) {
|
||||
newParams.set('keyword', keyword);
|
||||
} else {
|
||||
newParams.delete('keyword');
|
||||
}
|
||||
|
||||
// 搜索时,重置到第一页
|
||||
newParams.set('page', '1');
|
||||
|
||||
persistSearchParams(newParams);
|
||||
setSearchParams(newParams);
|
||||
};
|
||||
|
||||
// 处理页码变更
|
||||
const handlePageChange = (page: number) => {
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
newParams.set('page', page.toString());
|
||||
persistSearchParams(newParams);
|
||||
setSearchParams(newParams);
|
||||
};
|
||||
|
||||
// 处理每页条数变更
|
||||
const handlePageSizeChange = (size: number) => {
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
newParams.set('pageSize', size.toString());
|
||||
newParams.set('page', '1'); // 改变每页条数时重置为第一页
|
||||
persistSearchParams(newParams);
|
||||
setSearchParams(newParams);
|
||||
};
|
||||
|
||||
// 查看评查文件
|
||||
const handleReviewFileClick = async (fileId: string, auditStatus: number | null) => {
|
||||
// 检查audit_status是否为0,如果是则更新为2
|
||||
if (auditStatus === 0 || auditStatus === null) {
|
||||
try {
|
||||
// 从 localStorage 获取用户ID
|
||||
const userId = getUserId();
|
||||
if (!userId) {
|
||||
toastService.error('用户身份验证失败');
|
||||
return;
|
||||
}
|
||||
|
||||
// token 由 axios 拦截器自动添加
|
||||
const response = await updateDocumentAuditStatus(fileId, 2, userId);
|
||||
if (response.error) {
|
||||
throw new Error(response.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('更新文件审核状态时出错:', error);
|
||||
toastService.error(`更新文件审核状态时出错:${error instanceof Error ? error.message : '未知错误'}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 导航到评查详情页
|
||||
// 在离开当前页前保存当前查询参数,返回时可恢复
|
||||
if (typeof window !== 'undefined') {
|
||||
sessionStorage.setItem(SEARCH_PARAMS_STORAGE_KEY, searchParams.toString());
|
||||
}
|
||||
navigate(`/reviews?id=${fileId}&previousRoute=rulesFiles`);
|
||||
};
|
||||
|
||||
// 渲染问题摘要
|
||||
const renderIssues = (file: ReviewFileUI) => {
|
||||
// 如果文件状态为完成
|
||||
if (file.status === 'Processed') {
|
||||
// 如果没有问题,显示"所有评查点均通过"
|
||||
if (file.warningCount <= 0 && file.failCount <= 0) {
|
||||
return (
|
||||
<div className="text-sm text-success">
|
||||
<i className="ri-check-double-line mr-1"></i>所有评查点均通过
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 如果评查状态为不通过,显示"统计分数为:{file.score || 0}。分数低于80分。"
|
||||
// if (file.reviewStatus === 'fail') {
|
||||
// return (
|
||||
// <div className="text-sm text-error">
|
||||
// <i className="ri-error-warning-line mr-1"></i>统计分数为:{file.score || 0}。分数低于80分。
|
||||
// </div>
|
||||
// );
|
||||
// }
|
||||
|
||||
// 显示问题列表
|
||||
if (file.issues && file.issues.length > 0) {
|
||||
// 最多显示2个问题
|
||||
const displayIssues = file.issues.slice(0, 2);
|
||||
|
||||
return (
|
||||
<div className="text-sm">
|
||||
{displayIssues.map((issue, index) => (
|
||||
<div key={index} className="mb-1">
|
||||
<i className="ri-circle-fill mr-1 text-warning"></i>
|
||||
{issue.message}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{file.issues.length > 2 && (
|
||||
<div className="text-secondary mt-1">
|
||||
还有 {file.issues.length - 2} 个问题...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
// 其他状态显示占位符
|
||||
return <div className="text-sm text-secondary">-</div>;
|
||||
};
|
||||
|
||||
|
||||
// 下载文件
|
||||
const handleDownload = async (path: string) => {
|
||||
try {
|
||||
// 使用axios封装的下载方法
|
||||
const blob = await downloadFile(path);
|
||||
|
||||
// 创建Blob URL
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
|
||||
// 创建一个隐藏的a标签并点击它
|
||||
const a = document.createElement('a');
|
||||
a.style.display = 'none';
|
||||
a.href = blobUrl;
|
||||
// 从路径中获取文件名
|
||||
const fileName = path.split('/').pop() || 'document';
|
||||
a.download = decodeURIComponent(fileName);
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
|
||||
// 清理
|
||||
setTimeout(() => {
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
}, 100);
|
||||
} catch (error) {
|
||||
console.error('下载文件失败:', error);
|
||||
toastService.error(`下载文件失败: ${error instanceof Error ? error.message : '未知错误'}`);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理时间范围变更
|
||||
const handleDateChange = (field: 'dateFrom' | 'dateTo', value: string) => {
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
if(value) {
|
||||
newParams.set(field, value);
|
||||
} else {
|
||||
newParams.delete(field);
|
||||
}
|
||||
newParams.set('page', '1');
|
||||
setSearchParams(newParams);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
const newParams = new URLSearchParams();
|
||||
const searchInput = document.querySelector('input[name="keyword"]');
|
||||
if(searchInput) {
|
||||
(searchInput as HTMLInputElement).value = '';
|
||||
}
|
||||
if (typeof window !== 'undefined') {
|
||||
sessionStorage.removeItem(SEARCH_PARAMS_STORAGE_KEY);
|
||||
}
|
||||
setSearchParams(newParams);
|
||||
};
|
||||
|
||||
// 辅助:格式化文件大小
|
||||
const formatFileSize = (bytes: number) => {
|
||||
if (bytes === 0) return "0 Bytes";
|
||||
const k = 1024;
|
||||
const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
|
||||
};
|
||||
|
||||
// 选择附件文件
|
||||
const handleAttachmentFilesSelected = (files: FileList) => {
|
||||
try {
|
||||
if (files.length > 0) {
|
||||
const validFiles: File[] = [];
|
||||
let hasInvalidFiles = false;
|
||||
Array.from(files).forEach(file => {
|
||||
const fileName = file.name.toLowerCase();
|
||||
const isValidType =
|
||||
file.type === 'application/pdf' || fileName.endsWith('.pdf') ||
|
||||
file.type === 'application/msword' || fileName.endsWith('.doc') ||
|
||||
file.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' || fileName.endsWith('.docx') ||
|
||||
file.type === 'application/zip' || fileName.endsWith('.zip') ||
|
||||
file.type === 'application/x-rar-compressed' || fileName.endsWith('.rar');
|
||||
if (isValidType) {
|
||||
validFiles.push(file);
|
||||
} else {
|
||||
hasInvalidFiles = true;
|
||||
}
|
||||
});
|
||||
if (hasInvalidFiles) {
|
||||
messageService.error('只支持PDF、Word、ZIP、RAR格式的文件', {
|
||||
title: '文件类型错误',
|
||||
confirmText: '确定',
|
||||
cancelText: '',
|
||||
});
|
||||
}
|
||||
if (validFiles.length > 0) {
|
||||
setAttachmentFiles(validFiles);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('【附件追加】处理文件选择时发生错误:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 执行附件追加
|
||||
const handleAttachmentUpload = async () => {
|
||||
if (!selectedDocumentId || attachmentFiles.length === 0) {
|
||||
toastService.error('请选择文档和附件文件');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setAttachmentUploading(true);
|
||||
const docId = parseInt(selectedDocumentId, 10);
|
||||
const result = await appendContractAttachments(
|
||||
docId,
|
||||
attachmentFiles,
|
||||
attachmentMergeMode,
|
||||
true,
|
||||
attachmentRemark || undefined
|
||||
);
|
||||
if (result.error) {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
toastService.success('附件追加成功!');
|
||||
// 重置并关闭
|
||||
setAttachmentFiles([]);
|
||||
setAttachmentRemark("");
|
||||
setShowAttachmentUpload(false);
|
||||
setSelectedDocumentId(null);
|
||||
// 刷新列表
|
||||
fetchData(Object.fromEntries(searchParams.entries()));
|
||||
} catch (error) {
|
||||
console.error('【附件追加】上传失败:', error);
|
||||
toastService.error(error instanceof Error ? error.message : '附件追加失败');
|
||||
} finally {
|
||||
setAttachmentUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 文件类型选项
|
||||
const fileTypeOptions = documentTypes.map((type: {id: number, name: string}) => ({
|
||||
value: type.id.toString(),
|
||||
label: type.name
|
||||
}));
|
||||
|
||||
// 定义表格列配置
|
||||
const columns = [
|
||||
{
|
||||
title: "文件名称",
|
||||
key: "fileName",
|
||||
width: "30%",
|
||||
render: (_: unknown, file: ReviewFileUI) => (
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0 flex items-center self-center">
|
||||
<FileIcon fileName={file.fileName} className="text-lg w-10 h-10" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 flex flex-col py-2 ml-2">
|
||||
<div className="font-normal text-base break-words whitespace-normal leading-normal" title={file.fileName}>{file.fileName}</div>
|
||||
<div className="text-xs text-secondary mt-2">
|
||||
文件编号:{file.fileCode}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: "文件类型",
|
||||
key: "fileType",
|
||||
width: "12%",
|
||||
render: (_: unknown, file: ReviewFileUI) => (
|
||||
<FileTypeTag
|
||||
type="other"
|
||||
typeName={file.fileType}
|
||||
text={file.fileType}
|
||||
size="sm"
|
||||
showIcon={false}
|
||||
colorMode="light"
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: "上传时间",
|
||||
key: "uploadTime",
|
||||
width: "12%",
|
||||
render: (_: unknown, file: ReviewFileUI) => {
|
||||
const [date, time] = file.uploadTime.split(' ');
|
||||
return (
|
||||
<div>
|
||||
<span className="text-base">{date}</span>
|
||||
<br />
|
||||
<span className="text-xs text-secondary">{time}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
title: "评查统计",
|
||||
key: "reviewStatus",
|
||||
width: "12%",
|
||||
render: (_: unknown, file: ReviewFileUI) =>
|
||||
// 要文件切分处理完之后,再显示评查统计
|
||||
file.status === 'Processed' ? (
|
||||
<div>
|
||||
{file.passCount > 0 && (
|
||||
<StatusBadge
|
||||
status="pass"
|
||||
text={`通过(${file.passCount})`}
|
||||
showIcon={true}
|
||||
className="my-2"
|
||||
/>
|
||||
)}
|
||||
{file.warningCount > 0 && (
|
||||
<StatusBadge
|
||||
status="warning"
|
||||
text={`警告(${file.warningCount})`}
|
||||
showIcon={true}
|
||||
className="my-2"
|
||||
/>
|
||||
)}
|
||||
{file.failCount > 0 && (
|
||||
<StatusBadge
|
||||
status="fail"
|
||||
text={`不通过(${file.failCount})`}
|
||||
showIcon={true}
|
||||
className="my-2"
|
||||
/>
|
||||
)}
|
||||
{file.manualCount > 0 && (
|
||||
<StatusBadge
|
||||
status="pending"
|
||||
text={`需人工(${file.manualCount})`}
|
||||
showIcon={true}
|
||||
className="my-2"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
) : (
|
||||
<div className="text-sm">
|
||||
-
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: "问题摘要",
|
||||
key: "issues",
|
||||
width: "20%",
|
||||
render: (_: unknown, file: ReviewFileUI) => renderIssues(file)
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "operation",
|
||||
width: "20%",
|
||||
render: (_: unknown, file: ReviewFileUI) => (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleReviewFileClick(file.id, file.auditStatus)}
|
||||
disabled={file.status !== 'Processed'}
|
||||
className={`text-xs px-2 py-1 h-7 mr-1 ${file.status === 'Processed' ? 'hover:underline hover:text-primary' : 'opacity-60 cursor-not-allowed pointer-events-none'}`}
|
||||
>
|
||||
<i className="ri-eye-line"></i>
|
||||
查看
|
||||
</button>
|
||||
{file.fileTypeId === 1 && file.status === 'Processed' && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs px-2 py-1 h-7 mr-1 hover:underline hover:text-primary"
|
||||
onClick={() => {
|
||||
setSelectedDocumentId(file.id);
|
||||
setShowAttachmentUpload(true);
|
||||
}}
|
||||
>
|
||||
<i className="ri-attachment-line"></i>
|
||||
追加附件
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs px-2 py-1 h-7 mr-1 text-gray-500 hover:underline hover:text-gray-700"
|
||||
onClick={() => handleDownload(file.path)}
|
||||
>
|
||||
<i className="ri-download-2-line"></i>
|
||||
下载
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="review-files-page">
|
||||
{/* 页面头部 */}
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<div className="flex items-center">
|
||||
<h2 className="text-xl font-normal">评查文件列表</h2>
|
||||
<div className="flex items-center ml-4 bg-white px-3 py-1 rounded-md">
|
||||
<i className="ri-file-list-3-line text-primary text-lg mr-1"></i>
|
||||
<span className="text-sm text-secondary">总文件数:</span>
|
||||
{isLoading ? (
|
||||
<NumberSkeleton className="ml-1" />
|
||||
) : (
|
||||
<span className="text-base font-normal text-primary ml-1">{totalCount}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button type="primary" icon="ri-file-upload-line" to="/files/upload">
|
||||
上传新文件
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 筛选区域 */}
|
||||
<FilterPanel className="px-3 py-3" noActionDivider={true}
|
||||
actions={
|
||||
<>
|
||||
<Button type="default" icon="ri-refresh-line" onClick={handleReset} className="mr-2 hover:!border-gray-300">
|
||||
重置
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<FilterSelect
|
||||
label="文件类型"
|
||||
name="fileType"
|
||||
value={searchParams.get('fileType') || ''}
|
||||
options={fileTypeOptions}
|
||||
onChange={handleFilterChange}
|
||||
className="mr-2 w-40"
|
||||
/>
|
||||
|
||||
{/* <FilterSelect
|
||||
label="评查状态"
|
||||
name="reviewStatus"
|
||||
value={searchParams.get('reviewStatus') || ''}
|
||||
options={reviewStatusOptions}
|
||||
onChange={handleFilterChange}
|
||||
className="mr-2 w-40"
|
||||
/> */}
|
||||
|
||||
{/* <FilterSelect
|
||||
label="时间范围"
|
||||
name="dateRange"
|
||||
value={searchParams.get('dateRange') || ''}
|
||||
options={dateRangeOptions}
|
||||
onChange={handleFilterChange}
|
||||
className="mr-2 w-40"
|
||||
/> */}
|
||||
|
||||
<DateRangeFilter
|
||||
label="时间范围"
|
||||
startDate={dateFrom}
|
||||
endDate={dateTo}
|
||||
onStartDateChange={(value) => handleDateChange('dateFrom', value)}
|
||||
onEndDateChange={(value) => handleDateChange('dateTo', value)}
|
||||
simple={true}
|
||||
colorMode="light"
|
||||
/>
|
||||
|
||||
<FilterSelect
|
||||
label="排序方式"
|
||||
name="sortOrder"
|
||||
value={searchParams.get('sortOrder') || 'upload_time_desc'}
|
||||
onChange={handleFilterChange}
|
||||
className="w-32"
|
||||
options={[
|
||||
{ value: "upload_time_desc", label: "上传时间 ↓" },
|
||||
{ value: "upload_time_asc", label: "上传时间 ↑" },
|
||||
// { value: "issue_count_desc", label: "问题数量 ↓" },
|
||||
// { value: "issue_count_asc", label: "问题数量 ↑" }
|
||||
]}
|
||||
/>
|
||||
<SearchFilter
|
||||
label="搜索"
|
||||
placeholder="搜索文件名、合同编号"
|
||||
value={searchParams.get('keyword') || ''}
|
||||
onSearch={handleSearch}
|
||||
buttonText=""
|
||||
className="mr-2 flex-1"
|
||||
/>
|
||||
</FilterPanel>
|
||||
|
||||
{/* 文件列表 */}
|
||||
<Card>
|
||||
<div className={isLoading ? "opacity-70 pointer-events-none transition-opacity" : ""}>
|
||||
{isLoading && <LoadingIndicator />}
|
||||
|
||||
{isLoading && files.length === 0 ? (
|
||||
<TableRowSkeleton count={5} />
|
||||
) : (
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={files}
|
||||
rowKey="id"
|
||||
emptyText={isLoading ? "加载中..." : "暂无文件数据"}
|
||||
className="files-table table-auto-height"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 分页组件 */}
|
||||
{totalCount > 0 && (
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
total={totalCount}
|
||||
pageSize={pageSize}
|
||||
onChange={handlePageChange}
|
||||
onPageSizeChange={handlePageSizeChange}
|
||||
showTotal={true}
|
||||
showPageSizeChanger={true}
|
||||
pageSizeOptions={[10, 20, 30, 50]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{/* 附件追加模态框 */}
|
||||
{showAttachmentUpload && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-lg font-semibold">追加合同附件</h3>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowAttachmentUpload(false);
|
||||
setSelectedDocumentId(null);
|
||||
setAttachmentFiles([]);
|
||||
setAttachmentRemark("");
|
||||
}}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<i className="ri-close-line text-xl"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="bg-gray-50 p-3 rounded">
|
||||
<p className="text-sm text-gray-600">
|
||||
目标文档ID: <span className="font-medium">{selectedDocumentId}</span>
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
支持PDF、Word、ZIP、RAR格式,ZIP/RAR内仅合并其中的PDF文件
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
选择附件文件 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center hover:border-gray-400 transition-colors">
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
accept=".pdf,.doc,.docx,.zip,.rar"
|
||||
onChange={(e) => e.target.files && handleAttachmentFilesSelected(e.target.files)}
|
||||
className="hidden"
|
||||
id="attachment-file-input"
|
||||
/>
|
||||
<label htmlFor="attachment-file-input" className="cursor-pointer">
|
||||
<i className="ri-attachment-line text-3xl text-gray-400 mb-2 block"></i>
|
||||
<p className="text-sm text-gray-600">点击选择文件或拖拽文件到此处</p>
|
||||
<p className="text-xs text-gray-500 mt-1">支持PDF、Word、ZIP、RAR格式,可多选</p>
|
||||
</label>
|
||||
</div>
|
||||
{attachmentFiles.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<p className="text-sm text-green-600 mb-2">
|
||||
<i className="ri-checkbox-circle-line"></i> 已选择 {attachmentFiles.length} 个文件
|
||||
</p>
|
||||
<div className="space-y-1 max-h-32 overflow-y-auto">
|
||||
{attachmentFiles.map((file, index) => (
|
||||
<div key={index} className="text-xs text-gray-600 bg-gray-50 p-2 rounded">
|
||||
<i className="ri-file-line mr-1"></i>
|
||||
{file.name} ({formatFileSize(file.size)})
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
合并模式
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
name="mergeMode"
|
||||
value="overwrite"
|
||||
checked={attachmentMergeMode === 'overwrite'}
|
||||
onChange={(e) => setAttachmentMergeMode(e.target.value as 'overwrite' | 'new')}
|
||||
className="mr-2"
|
||||
/>
|
||||
<span className="text-sm">覆盖原文档(推荐)</span>
|
||||
</label>
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
name="mergeMode"
|
||||
value="new"
|
||||
checked={attachmentMergeMode === 'new'}
|
||||
onChange={(e) => setAttachmentMergeMode(e.target.value as 'overwrite' | 'new')}
|
||||
className="mr-2"
|
||||
/>
|
||||
<span className="text-sm">新建文档记录</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
备注(可选)
|
||||
</label>
|
||||
<textarea
|
||||
value={attachmentRemark}
|
||||
onChange={(e) => setAttachmentRemark(e.target.value)}
|
||||
className="w-full p-2 border border-gray-300 rounded-md text-sm"
|
||||
rows={3}
|
||||
placeholder="请输入备注信息..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4 border-t">
|
||||
<button
|
||||
className="px-4 py-2 text-sm border border-gray-300 rounded-md hover:bg-gray-50"
|
||||
onClick={() => {
|
||||
setShowAttachmentUpload(false);
|
||||
setSelectedDocumentId(null);
|
||||
setAttachmentFiles([]);
|
||||
setAttachmentRemark("");
|
||||
}}
|
||||
disabled={attachmentUploading}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
className="px-4 py-2 text-sm bg-primary text-white rounded-md hover:bg-primary-dark disabled:opacity-50"
|
||||
onClick={handleAttachmentUpload}
|
||||
disabled={attachmentFiles.length === 0 || attachmentUploading}
|
||||
>
|
||||
{attachmentUploading ? '上传中...' : '开始追加'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 错误边界
|
||||
export function ErrorBoundary() {
|
||||
return (
|
||||
<div className="error-container p-6">
|
||||
<h1 className="text-xl font-normal text-red-500 mb-4">出错了</h1>
|
||||
<p className="mb-4">加载评查文件列表时发生错误。请稍后再试,或联系管理员。</p>
|
||||
<Button type="primary" to="/">返回首页</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+113
-79
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { type MetaFunction, type LoaderFunctionArgs } from "@remix-run/node";
|
||||
import { useLoaderData, useSearchParams, Link, useNavigate, useFetcher, useRouteLoaderData, useLocation } from "@remix-run/react";
|
||||
import { Button } from '~/components/ui/Button';
|
||||
@@ -38,6 +38,10 @@ export const meta: MetaFunction = () => {
|
||||
];
|
||||
};
|
||||
|
||||
export const handle = {
|
||||
breadcrumb: "评查点列表"
|
||||
};
|
||||
|
||||
// 声明loader返回的数据类型
|
||||
export type LoaderData = {
|
||||
rules: Rule[];
|
||||
@@ -70,9 +74,17 @@ interface ActionResponse {
|
||||
}
|
||||
|
||||
function mapApiRuleToModel(apiRule: ApiRule): Rule {
|
||||
// 🔑 清洗评查点编码:移除最后一个 '--' 及其后面的字符
|
||||
// 例如:'code-mis--mz' --> 'code-mis', 'code-mbs--alsi--gz' --> 'code-mbs--alsi'
|
||||
let cleanedCode = apiRule.code;
|
||||
const lastDoubleHyphenIndex = cleanedCode.lastIndexOf('--');
|
||||
if (lastDoubleHyphenIndex !== -1) {
|
||||
cleanedCode = cleanedCode.substring(0, lastDoubleHyphenIndex);
|
||||
}
|
||||
|
||||
return {
|
||||
id: apiRule.id,
|
||||
code: apiRule.code,
|
||||
code: cleanedCode,
|
||||
name: apiRule.name,
|
||||
ruleType: apiRule.ruleType as RuleType, // 类型转换
|
||||
ruleGroupId: apiRule.groupId,
|
||||
@@ -105,22 +117,13 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
||||
const { getUserSession } = await import("~/api/login/auth.server");
|
||||
const { frontendJWT } = await getUserSession(request);
|
||||
|
||||
// 获取评查点类型列表,供前端筛选使用
|
||||
const typeResponse = await getRuleTypes(undefined, frontendJWT);
|
||||
|
||||
if (typeResponse.error) {
|
||||
console.error('获取评查点类型失败:', typeResponse.error);
|
||||
}
|
||||
|
||||
const ruleTypes = typeResponse.error ? [] : typeResponse.data;
|
||||
|
||||
// 返回初始空数据,客户端将根据 sessionStorage 中的 reviewType 加载实际数据
|
||||
// 返回初始空数据,客户端将根据 sessionStorage 中的 documentTypeIds 加载实际数据
|
||||
return Response.json({
|
||||
rules: [],
|
||||
totalCount: 0,
|
||||
currentPage: params.page,
|
||||
pageSize: params.pageSize,
|
||||
ruleTypes,
|
||||
ruleTypes: [], // 服务端无法访问 sessionStorage,客户端加载
|
||||
initialLoad: true,
|
||||
frontendJWT
|
||||
}, {
|
||||
@@ -198,16 +201,16 @@ export default function RulesIndex() {
|
||||
// 状态管理
|
||||
const [ruleGroups, setRuleGroups] = useState<RuleGroup[]>([]);
|
||||
const [loadingGroups, setLoadingGroups] = useState(false);
|
||||
const [reviewType, setReviewType] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [filteredRules, setFilteredRules] = useState<Rule[]>(initialRules);
|
||||
const [filteredTotalCount, setFilteredTotalCount] = useState<number>(initialTotalCount);
|
||||
const [ruleTypes, setRuleTypes] = useState<ApiRuleType[]>(initialRuleTypes);
|
||||
|
||||
// 添加一个路由变化计数器
|
||||
const [routeChangeCount, setRouteChangeCount] = useState(0);
|
||||
|
||||
// 添加一个状态来跟踪是否执行了删除操作
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
// 使用 ref 跟踪是否正在加载数据,避免重复加载
|
||||
const isLoadingRef = useRef(false);
|
||||
|
||||
// 查询参数记忆 key 与保存/恢复
|
||||
const SEARCH_PARAMS_STORAGE_KEY = 'rules.searchParams';
|
||||
@@ -231,19 +234,13 @@ export default function RulesIndex() {
|
||||
|
||||
// 获取当前的ruleType值
|
||||
const ruleTypeParam = searchParams.get('ruleType');
|
||||
|
||||
// 追踪路由变化
|
||||
useEffect(() => {
|
||||
// console.log("路由变化:", location.key);
|
||||
setRouteChangeCount(prev => prev + 1);
|
||||
}, [location]);
|
||||
|
||||
|
||||
// 判断是否禁用规则组选择
|
||||
const isRuleGroupSelectDisabled = loadingGroups || !ruleTypeParam || ruleGroups.length === 0;
|
||||
|
||||
// 检查用户是否为开发者角色
|
||||
const userRole = rootData?.userRole || 'common';
|
||||
const isDeveloper = userRole === 'admin';
|
||||
const isDeveloper = userRole.includes('admin');
|
||||
|
||||
// 调试日志
|
||||
// console.log("🔑 [Rules List] rootData:", rootData);
|
||||
@@ -261,56 +258,93 @@ export default function RulesIndex() {
|
||||
useEffect(() => {
|
||||
if(loaderData.error) {
|
||||
toastService.error(loaderData.error);
|
||||
}else if(loaderData.ruleTypes.length === 0){
|
||||
toastService.error("评查点类型数据为空");
|
||||
}
|
||||
}, [loaderData.error,loaderData.ruleTypes]);
|
||||
// ❌ 不再检查 loaderData.ruleTypes,因为服务端永远返回空数组
|
||||
// 如果需要检查评查点类型数据,应该在 fetchData 完成后检查状态 ruleTypes
|
||||
}, [loaderData.error]);
|
||||
|
||||
// 客户端数据加载函数
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
// 从sessionStorage获取reviewType
|
||||
const storedReviewType = typeof window !== 'undefined' ? sessionStorage.getItem('reviewType') : null;
|
||||
const typeToUse = reviewType || storedReviewType;
|
||||
|
||||
if (!typeToUse) {
|
||||
console.warn('无法加载评查点数据:未找到reviewType');
|
||||
// 🔑 如果正在加载,避免重复调用
|
||||
if (isLoadingRef.current) {
|
||||
console.log('📋 [fetchData] 正在加载中,跳过重复调用');
|
||||
return;
|
||||
}
|
||||
|
||||
// console.log("fetchData被调用,加载评查点数据", typeToUse);
|
||||
|
||||
// 🔑 从 sessionStorage 获取 documentTypeIds
|
||||
const typeIdsStr = typeof window !== 'undefined' ? sessionStorage.getItem('documentTypeIds') : null;
|
||||
const documentTypeIds = typeIdsStr ? JSON.parse(typeIdsStr) : null;
|
||||
|
||||
if (!documentTypeIds || documentTypeIds.length === 0) {
|
||||
console.warn('无法加载评查点数据:未找到 documentTypeIds');
|
||||
return;
|
||||
}
|
||||
|
||||
isLoadingRef.current = true;
|
||||
|
||||
// 🔑 从 localStorage 获取 user_info 中的 area
|
||||
let userArea: string | undefined = undefined;
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
const userInfoStr = localStorage.getItem('user_info');
|
||||
if (userInfoStr) {
|
||||
const userInfo = JSON.parse(userInfoStr);
|
||||
userArea = userInfo.area;
|
||||
console.log("📋 [fetchData] 从 localStorage 获取到用户地区:", userArea);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('解析 user_info 失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("📋 [fetchData] 开始加载评查点数据, documentTypeIds:", documentTypeIds, "area:", userArea);
|
||||
setLoading(true);
|
||||
|
||||
// 获取评查点类型
|
||||
|
||||
// 🔑 获取评查点类型(通过 documentTypeIds)
|
||||
let loadedRuleTypes: ApiRuleType[] = [];
|
||||
try {
|
||||
const typeResponse = await getRuleTypes(typeToUse, loaderData.frontendJWT);
|
||||
const typeResponse = await getRuleTypes(documentTypeIds, loaderData.frontendJWT);
|
||||
if (typeResponse.data) {
|
||||
setRuleTypes(typeResponse.data);
|
||||
loadedRuleTypes = typeResponse.data;
|
||||
setRuleTypes(loadedRuleTypes);
|
||||
console.log("📋 [fetchData] 获取到评查点类型:", loadedRuleTypes);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载评查点类型失败:', error);
|
||||
}
|
||||
|
||||
|
||||
// 构建查询参数
|
||||
// 🔑 当选择"全部"或未选择评查点类型时,使用下拉框中所有评查点类型的 id 组合
|
||||
let finalRuleType: string | undefined = undefined;
|
||||
if (ruleTypeParam && ruleTypeParam !== 'all') {
|
||||
// 选择了具体的评查点类型
|
||||
finalRuleType = ruleTypeParam;
|
||||
} else if (loadedRuleTypes && loadedRuleTypes.length > 0) {
|
||||
// 选择"全部"或未选择,使用刚加载的评查点类型的 id
|
||||
finalRuleType = loadedRuleTypes.map(type => type.id).join(',');
|
||||
console.log("📋 [fetchData] 选择全部类型,使用 loadedRuleTypes 的 id 组合:", finalRuleType);
|
||||
}
|
||||
|
||||
const queryParams = {
|
||||
ruleType: ruleTypeParam || undefined,
|
||||
ruleType: finalRuleType,
|
||||
groupId: searchParams.get('groupId') || undefined,
|
||||
isActive: searchParams.get('isActive') ? searchParams.get('isActive') === 'true' : undefined,
|
||||
keyword: searchParams.get('keyword') || undefined,
|
||||
area: userArea, // 添加地区过滤
|
||||
page: currentPage,
|
||||
pageSize,
|
||||
reviewType: typeToUse,
|
||||
token: loaderData.frontendJWT
|
||||
};
|
||||
|
||||
|
||||
// 调用 API 获取数据
|
||||
const response = await getRulesList(queryParams);
|
||||
|
||||
|
||||
if (response.data) {
|
||||
const apiRules = response.data.rules || [];
|
||||
const total = response.data.totalCount || 0;
|
||||
const mappedRules = apiRules.map((apiRule: ApiRule) => mapApiRuleToModel(apiRule));
|
||||
|
||||
|
||||
setFilteredRules(mappedRules);
|
||||
setFilteredTotalCount(total);
|
||||
}
|
||||
@@ -319,8 +353,9 @@ export default function RulesIndex() {
|
||||
toastService.error('加载评查点列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
isLoadingRef.current = false;
|
||||
}
|
||||
}, [reviewType, ruleTypeParam, searchParams, currentPage, pageSize]);
|
||||
}, [ruleTypeParam, searchParams, currentPage, pageSize, loaderData.frontendJWT]);
|
||||
|
||||
// 当评查点类型变化时,加载对应的规则组
|
||||
useEffect(() => {
|
||||
@@ -380,55 +415,54 @@ export default function RulesIndex() {
|
||||
}
|
||||
}, [fetcher.data, fetcher.state, fetchData, isDeleting]);
|
||||
|
||||
// 在组件挂载时从 sessionStorage 获取 reviewType 并加载数据
|
||||
// 在组件挂载时从 sessionStorage 获取 documentTypeIds 并加载数据
|
||||
useEffect(() => {
|
||||
try {
|
||||
if (typeof window !== 'undefined') {
|
||||
const storedReviewType = sessionStorage.getItem('reviewType');
|
||||
// console.log("组件挂载,从sessionStorage获取reviewType:", storedReviewType);
|
||||
|
||||
if (storedReviewType !== reviewType) {
|
||||
setReviewType(storedReviewType);
|
||||
}
|
||||
|
||||
// 无论如何,都加载数据,不依赖于reviewType的变化
|
||||
if (storedReviewType) {
|
||||
// 使用setTimeout确保该操作在其他状态更新之后执行
|
||||
const typeIdsStr = sessionStorage.getItem('documentTypeIds');
|
||||
const documentTypeIds = typeIdsStr ? JSON.parse(typeIdsStr) : null;
|
||||
console.log("📋 组件挂载,从 sessionStorage 获取 documentTypeIds:", documentTypeIds);
|
||||
|
||||
// 如果有 documentTypeIds,加载数据
|
||||
if (documentTypeIds && documentTypeIds.length > 0) {
|
||||
// 使用 setTimeout 确保该操作在其他状态更新之后执行
|
||||
setTimeout(() => {
|
||||
fetchData();
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取 sessionStorage 中的 reviewType 失败:', error);
|
||||
console.error('获取 sessionStorage 中的 documentTypeIds 失败:', error);
|
||||
}
|
||||
}, [initialLoad, fetchData]);
|
||||
|
||||
// 监听路由变化,每次路由到此页面时刷新数据
|
||||
useEffect(() => {
|
||||
if (routeChangeCount > 0) {
|
||||
// console.log("路由变化触发数据刷新,计数:", routeChangeCount);
|
||||
const storedReviewType = typeof window !== 'undefined' ? sessionStorage.getItem('reviewType') : null;
|
||||
// console.log("storedReviewType:", storedReviewType);
|
||||
if (storedReviewType) {
|
||||
if (storedReviewType !== reviewType) {
|
||||
setReviewType(storedReviewType);
|
||||
}
|
||||
|
||||
// 使用setTimeout确保该操作在其他状态更新之后执行
|
||||
setTimeout(() => {
|
||||
fetchData();
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
}, [routeChangeCount, fetchData]);
|
||||
// 注释掉重复的路由监听逻辑,避免与searchParams监听重复触发
|
||||
// useEffect(() => {
|
||||
// if (routeChangeCount > 0) {
|
||||
// console.log("📋 路由变化触发数据刷新,计数:", routeChangeCount);
|
||||
// const typeIdsStr = typeof window !== 'undefined' ? sessionStorage.getItem('documentTypeIds') : null;
|
||||
// const documentTypeIds = typeIdsStr ? JSON.parse(typeIdsStr) : null;
|
||||
// console.log("📋 documentTypeIds:", documentTypeIds);
|
||||
|
||||
// if (documentTypeIds && documentTypeIds.length > 0) {
|
||||
// // 使用 setTimeout 确保该操作在其他状态更新之后执行
|
||||
// setTimeout(() => {
|
||||
// fetchData();
|
||||
// }, 0);
|
||||
// }
|
||||
// }
|
||||
// }, [routeChangeCount, fetchData]);
|
||||
|
||||
// 监听 URL 参数变化,重新获取数据
|
||||
useEffect(() => {
|
||||
if (reviewType) {
|
||||
// 检查是否有 documentTypeIds
|
||||
const typeIdsStr = typeof window !== 'undefined' ? sessionStorage.getItem('documentTypeIds') : null;
|
||||
const documentTypeIds = typeIdsStr ? JSON.parse(typeIdsStr) : null;
|
||||
|
||||
if (documentTypeIds && documentTypeIds.length > 0) {
|
||||
fetchData();
|
||||
}
|
||||
}, [searchParams, fetchData, reviewType]);
|
||||
}, [searchParams, fetchData]);
|
||||
|
||||
// 筛选评查点
|
||||
const handleFilterChange = (e: React.ChangeEvent<HTMLSelectElement | HTMLInputElement>) => {
|
||||
|
||||
+110
-26
@@ -26,7 +26,7 @@
|
||||
*/
|
||||
|
||||
import { type MetaFunction } from "@remix-run/node";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { BasicInfo } from "~/components/rules/new/BasicInfo";
|
||||
import { ExtractionSettings } from "~/components/rules/new/ExtractionSettings";
|
||||
import { ReviewSettings } from "~/components/rules/new/ReviewSettings";
|
||||
@@ -138,13 +138,17 @@ export default function RuleNew() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [isEditMode, setIsEditMode] = useState(false);
|
||||
const [isCopyMode, setIsCopyMode] = useState(false); // 添加复制模式状态
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [instanceKey, setInstanceKey] = useState<string>('new');
|
||||
// 从root路由获取用户角色和JWT token
|
||||
const rootData = useRouteLoaderData("root") as { userRole: UserRole; frontendJWT?: string };
|
||||
const userRole = rootData?.userRole || 'common';
|
||||
const frontendJWT = rootData?.frontendJWT;
|
||||
|
||||
|
||||
// 使用 ref 跟踪当前加载的 URL,避免重复加载
|
||||
const loadedUrlRef = useRef<string>('');
|
||||
|
||||
const [formData, setFormData] = useState<EvaluationPoint>({});
|
||||
const [evaluationPointGroups, setEvaluationPointGroups] = useState<EvaluationPointGroup[]>([]);
|
||||
|
||||
@@ -266,11 +270,12 @@ export default function RuleNew() {
|
||||
* 获取评查点数据
|
||||
* 编辑模式下从API获取指定ID的评查点数据
|
||||
* @param id 评查点ID
|
||||
* @param isCopy 是否为复制模式
|
||||
*/
|
||||
const fetchEvaluationPoint = useCallback(async (id: number) => {
|
||||
const fetchEvaluationPoint = useCallback(async (id: number, isCopy = false) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
// console.log(`获取评查点数据,ID: ${id}`);
|
||||
// console.log(`获取评查点数据,ID: ${id}, 复制模式: ${isCopy}`);
|
||||
// 使用 postgrestGet 替代直接调用 fetch
|
||||
const postgrestParams = {
|
||||
filter: {
|
||||
@@ -283,23 +288,43 @@ export default function RuleNew() {
|
||||
if (response.data) {
|
||||
// 使用extractApiData从响应中提取数据
|
||||
const evaluationPoints = extractApiData<EvaluationPoint[]>(response.data);
|
||||
|
||||
|
||||
if (evaluationPoints && Array.isArray(evaluationPoints) && evaluationPoints.length > 0) {
|
||||
try {
|
||||
// 使用JSON序列化和反序列化来进行深拷贝,避免浏览器差异
|
||||
const originalData = evaluationPoints[0];
|
||||
const jsonString = JSON.stringify(originalData);
|
||||
const data = JSON.parse(jsonString);
|
||||
|
||||
|
||||
// 🔄 复制模式:删除不应该复制的字段
|
||||
if (isCopy) {
|
||||
delete data.id;
|
||||
delete data.created_at;
|
||||
delete data.updated_at;
|
||||
delete data.usage_count;
|
||||
|
||||
// console.log('📋 复制模式:已清除不应复制的字段(id, created_at, updated_at, usage_count)');
|
||||
}
|
||||
|
||||
// 🔑 清洗评查点编码:移除最后一个 '--' 及其后面的字符
|
||||
// 例如:'code-mis--mz' --> 'code-mis', 'code-mbs--alsi--gz' --> 'code-mbs--alsi'
|
||||
if (data.code) {
|
||||
const lastDoubleHyphenIndex = data.code.lastIndexOf('--');
|
||||
if (lastDoubleHyphenIndex !== -1) {
|
||||
data.code = data.code.substring(0, lastDoubleHyphenIndex);
|
||||
// console.log('🔑 已清洗评查点编码:', data.code);
|
||||
}
|
||||
}
|
||||
|
||||
// 设置表单数据
|
||||
setFormData(data);
|
||||
|
||||
|
||||
// 初始化extractionFields
|
||||
const extractedFields = extractFieldsFromFormData(data);
|
||||
setExtractionFields(extractedFields);
|
||||
|
||||
// 设置编辑模式的实例键
|
||||
setInstanceKey(`edit_${id}_${Date.now()}`);
|
||||
|
||||
// 设置实例键
|
||||
setInstanceKey(isCopy ? `copy_${id}_${Date.now()}` : `edit_${id}_${Date.now()}`);
|
||||
} catch (jsonError) {
|
||||
console.error('JSON处理错误:', jsonError);
|
||||
toastService.error(`数据处理错误: ${jsonError instanceof Error ? jsonError.message : '未知错误'}`);
|
||||
@@ -332,14 +357,30 @@ export default function RuleNew() {
|
||||
*/
|
||||
const fetchEvaluationPointGroups = useCallback(async () => {
|
||||
try {
|
||||
// console.log("获取评查点组数据");
|
||||
// console.log("🔍 [fetchEvaluationPointGroups] 开始获取评查点组数据");
|
||||
const response = await postgrestGet('evaluation_point_groups', { token: frontendJWT });
|
||||
|
||||
if (response.data && Array.isArray(response.data) && response.data.length > 0) {
|
||||
setEvaluationPointGroups(response.data);
|
||||
// console.log("🔍 [fetchEvaluationPointGroups] API响应:", response);
|
||||
|
||||
if (response.data) {
|
||||
// 使用 extractApiData 提取数据(处理可能的包装格式)
|
||||
const extractedData = extractApiData<EvaluationPointGroup[]>(response.data);
|
||||
// console.log("🔍 [fetchEvaluationPointGroups] 提取后的数据:", extractedData);
|
||||
|
||||
if (extractedData && Array.isArray(extractedData) && extractedData.length > 0) {
|
||||
setEvaluationPointGroups(extractedData);
|
||||
// console.log(`✅ [fetchEvaluationPointGroups] 成功加载 ${extractedData.length} 个评查点组`);
|
||||
} else {
|
||||
console.warn("⚠️ [fetchEvaluationPointGroups] 提取的数据为空或格式不正确");
|
||||
setEvaluationPointGroups([]);
|
||||
}
|
||||
} else if (response.error) {
|
||||
console.error('❌ [fetchEvaluationPointGroups] API返回错误:', response.error);
|
||||
setEvaluationPointGroups([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取评查点组数据失败:', error);
|
||||
console.error('❌ [fetchEvaluationPointGroups] 获取评查点组数据失败:', error);
|
||||
setEvaluationPointGroups([]);
|
||||
// 显示错误提示但不影响应用继续使用
|
||||
toastService.error(`获取评查点组数据失败: ${error instanceof Error ? error.message : '未知错误'}\n将使用默认数据`);
|
||||
}
|
||||
@@ -761,6 +802,7 @@ export default function RuleNew() {
|
||||
let response;
|
||||
if (isEditMode) {
|
||||
response = await postgrestPut('evaluation_points', finalData, {id: formData.id!}, frontendJWT);
|
||||
// console.log("最终提交的数据", finalData)
|
||||
} else {
|
||||
response = await postgrestPost('evaluation_points', finalData, frontendJWT);
|
||||
}
|
||||
@@ -909,46 +951,85 @@ export default function RuleNew() {
|
||||
* 3. 获取评查点组数据(用于表单选择项)
|
||||
*/
|
||||
useEffect(() => {
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
const id = searchParams.get('id');
|
||||
const currentUrl = location.search;
|
||||
const currentPathname = location.pathname;
|
||||
const fullUrl = `${currentPathname}${currentUrl}`;
|
||||
|
||||
// 🔑 如果 URL 没有变化,不重复加载(避免无限循环)
|
||||
// 使用完整路径(pathname + search)进行比较,避免新增页面被拦截
|
||||
if (loadedUrlRef.current === fullUrl) {
|
||||
console.log('🔄 [useEffect] URL未变化,跳过重复加载:', fullUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
// console.log('🔄 [useEffect] URL变化,开始加载数据:', { previous: loadedUrlRef.current, current: fullUrl });
|
||||
|
||||
const searchParams = new URLSearchParams(currentUrl);
|
||||
const id = searchParams.get('id');
|
||||
const mode = searchParams.get('mode');
|
||||
|
||||
|
||||
// 判断是否为复制模式
|
||||
const isCopy = mode === 'copy';
|
||||
|
||||
// 编辑或复制模式下设置加载状态
|
||||
if (id || mode === 'copy') {
|
||||
if (id || isCopy) {
|
||||
setIsLoading(true);
|
||||
}
|
||||
|
||||
// 设置编辑模式
|
||||
if (mode && mode === 'copy') {
|
||||
|
||||
// 设置复制模式状态
|
||||
setIsCopyMode(isCopy);
|
||||
|
||||
// 设置编辑模式(复制模式不是编辑模式)
|
||||
if (isCopy) {
|
||||
setIsEditMode(false);
|
||||
} else {
|
||||
setIsEditMode(!!id);
|
||||
}
|
||||
|
||||
|
||||
if (id) {
|
||||
// 编辑模式:获取数据
|
||||
fetchEvaluationPoint(parseInt(id));
|
||||
// 编辑或复制模式:获取数据(传入复制模式标志)
|
||||
console.log('📝 [useEffect] 编辑/复制模式,加载评查点数据,ID:', id);
|
||||
fetchEvaluationPoint(parseInt(id), isCopy);
|
||||
} else {
|
||||
// 新建模式:重置表单数据
|
||||
console.log('📝 [useEffect] 新建模式,重置表单数据');
|
||||
resetFormData();
|
||||
}
|
||||
|
||||
// 获取评查点组数据
|
||||
// console.log('📝 [useEffect] 获取评查点组数据');
|
||||
fetchEvaluationPointGroups();
|
||||
// 获取VLM字段类型选项
|
||||
// console.log('📝 [useEffect] 获取VLM字段类型选项');
|
||||
fetchVlmFieldTypeOptions();
|
||||
}, [location.search, fetchEvaluationPoint, fetchEvaluationPointGroups, fetchVlmFieldTypeOptions, resetFormData]);
|
||||
|
||||
// 记录已加载的 URL(使用完整路径)
|
||||
loadedUrlRef.current = fullUrl;
|
||||
}, [location.search, location.pathname, fetchEvaluationPoint, fetchEvaluationPointGroups, fetchVlmFieldTypeOptions, resetFormData]);
|
||||
|
||||
// 渲染页面内容
|
||||
return (
|
||||
<div className="container">
|
||||
{/* 页面标题和右上角保存按钮 */}
|
||||
<PageHeader
|
||||
title={isEditMode ? (isReadOnly ? "查看评查点" : "编辑评查点") : "新增评查点"}
|
||||
title={isCopyMode ? "复制评查点" : (isEditMode ? (isReadOnly ? "查看评查点" : "编辑评查点") : "新增评查点")}
|
||||
onSave={handleSave}
|
||||
showSaveButton={!isReadOnly}
|
||||
/>
|
||||
|
||||
{/* 复制模式提示 */}
|
||||
{isCopyMode && !isLoading && (
|
||||
<div className="mb-4 p-1 bg-blue-50 border border-blue-200 rounded-md">
|
||||
<div className="flex items-center">
|
||||
<i className="ri-information-line text-blue-500 text-xl mr-2"></i>
|
||||
<div className="text-sm text-blue-800">
|
||||
<span className="font-medium">复制模式:</span>
|
||||
请检查并修改评查点信息后保存。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 加载状态显示 */}
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center items-center p-12">
|
||||
@@ -965,6 +1046,7 @@ export default function RuleNew() {
|
||||
{/* 评查点基本信息设置 */}
|
||||
<div className="mb-8">
|
||||
<BasicInfo
|
||||
key={instanceKey}
|
||||
onChange={handleBasicInfoChange}
|
||||
initialData={formData}
|
||||
evaluationPointGroups={evaluationPointGroups}
|
||||
@@ -975,6 +1057,7 @@ export default function RuleNew() {
|
||||
{/* 抽取设置 - 配置从文档中提取的字段 */}
|
||||
<div className="mb-8">
|
||||
<ExtractionSettings
|
||||
key={instanceKey}
|
||||
onChange={handleExtractionSettingsChange}
|
||||
initialData={formData}
|
||||
promptTypeOptions={EVALUATION_OPTIONS.llmPromptTypeOptions}
|
||||
@@ -985,6 +1068,7 @@ export default function RuleNew() {
|
||||
{/* 评查设置 - 配置评查规则、消息等 */}
|
||||
<div className="mb-8">
|
||||
<ReviewSettings
|
||||
key={instanceKey}
|
||||
onChange={handleReviewSettingsChange}
|
||||
initialData={{
|
||||
rules: formData.evaluation_config?.rules || [],
|
||||
|
||||
Reference in New Issue
Block a user