Merge remote-tracking branch 'origin/Wren' into PingChuan

This commit is contained in:
PingChuan
2025-11-25 10:56:57 +08:00
35 changed files with 7412 additions and 1085 deletions
+4
View File
@@ -12,3 +12,7 @@ docreview-frontend-deploy.tar.gz
# Claude Code local settings
.claude/
.doc/
.database/
.auth_doc/
+70 -9
View File
@@ -493,11 +493,11 @@ 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}, JWT前20字符: ${jwt?.substring(0, 20)}`);
if (!jwt) {
console.error('❌ [User Routes] JWT token 未提供');
toastService.error("认证信息缺失,请重新登录");
// 不显示 toast,让 root loader 处理重定向
return { success: false, error: "JWT token 未提供", shouldRedirectToHome: true };
}
@@ -519,15 +519,34 @@ export async function getUserRoutesByRole(roleKey: string, jwt?: string, include
// 检查响应是否成功
if (response.error) {
console.error('❌ [User Routes] API 请求失败:', response.error);
// 🔑 如果是令牌过期错误,标记需要重定向到登录页
const isTokenExpired = response.error.includes('令牌已过期') ||
response.error.includes('令牌') ||
response.error.includes('token') ||
response.error.includes('expired') ||
response.error.includes('认证') ||
response.error.includes('401');
console.log('🔍 [User Routes] 错误检测:', {
error: response.error,
isTokenExpired,
willRedirect: isTokenExpired
});
// 只在客户端显示toast(服务端调用时跳过)
if (!isTokenExpired && typeof window !== 'undefined') {
toastService.error(response.error);
return { success: false, error: response.error, shouldRedirectToHome: true };
}
return { success: false, error: response.error, shouldRedirectToHome: isTokenExpired };
}
// 检查响应数据
if (!response.data) {
console.error('❌ [User Routes] 后端未返回数据');
if (typeof window !== 'undefined') {
toastService.error("获取路由数据失败");
return { success: false, error: "后端未返回数据", shouldRedirectToHome: true };
}
return { success: false, error: "后端未返回数据", shouldRedirectToHome: false };
}
const backendResponse = response.data;
@@ -535,23 +554,45 @@ export async function getUserRoutesByRole(roleKey: string, jwt?: string, include
// 检查业务状态码(后端使用 code: 0 表示成功)
if (backendResponse.code !== 0 && backendResponse.code !== 200) {
console.error(`❌ [User Routes] 后端返回错误: ${backendResponse.msg}`);
// 🔑 如果是令牌过期错误,标记需要重定向到登录页
const isTokenExpired = backendResponse.msg?.includes('令牌已过期') ||
backendResponse.msg?.includes('令牌') ||
backendResponse.msg?.includes('token') ||
backendResponse.msg?.includes('expired') ||
backendResponse.msg?.includes('认证') ||
backendResponse.msg?.includes('401');
console.log('🔍 [User Routes] 业务错误检测:', {
msg: backendResponse.msg,
code: backendResponse.code,
isTokenExpired,
willRedirect: isTokenExpired
});
// 只在客户端显示toast
if (!isTokenExpired && typeof window !== 'undefined') {
toastService.error(backendResponse.msg || "获取路由权限失败");
return { success: false, error: backendResponse.msg || "获取路由权限失败", shouldRedirectToHome: true };
}
return { success: false, error: backendResponse.msg || "获取路由权限失败", shouldRedirectToHome: isTokenExpired };
}
// 检查数据完整性
if (!backendResponse.data || !Array.isArray(backendResponse.data.routes)) {
console.error('❌ [User Routes] 后端未返回路由数据');
if (typeof window !== 'undefined') {
toastService.error("未获取到路由权限,请联系管理员配置");
return { success: false, error: "后端未返回路由数据", shouldRedirectToHome: true };
}
return { success: false, error: "后端未返回路由数据", shouldRedirectToHome: false };
}
const routes = backendResponse.data.routes;
if (routes.length === 0) {
console.log(`⚠️ [User Routes] 用户没有分配任何路由权限`);
if (typeof window !== 'undefined') {
toastService.error("您的角色没有分配任何路由权限,请联系管理员配置");
return { success: false, error: "用户没有分配任何路由权限", shouldRedirectToHome: true };
}
return { success: false, error: "用户没有分配任何路由权限", shouldRedirectToHome: false };
}
// console.log('🔍 [User Routes] 后端返回的原始路由数据:', JSON.stringify(routes, null, 2));
@@ -568,11 +609,31 @@ export async function getUserRoutesByRole(roleKey: string, jwt?: string, include
} catch (error) {
console.error("❌ [User Routes] 获取用户路由时发生错误:", error);
const errorMessage = error instanceof Error ? error.message : String(error);
// 🔑 如果是认证相关错误,标记需要重定向到登录页
const isAuthError = errorMessage.includes('令牌') ||
errorMessage.includes('token') ||
errorMessage.includes('expired') ||
errorMessage.includes('认证') ||
errorMessage.includes('401') ||
errorMessage.includes('403');
console.log('🔍 [User Routes] 异常错误检测:', {
errorMessage,
isAuthError,
willRedirect: isAuthError
});
// 只在客户端显示toast
if (!isAuthError && typeof window !== 'undefined') {
toastService.error("获取用户路由时发生错误,请稍后再试");
}
return {
success: false,
error: `获取用户路由失败: ${error instanceof Error ? error.message : String(error)}`,
shouldRedirectToHome: true
error: `获取用户路由失败: ${errorMessage}`,
shouldRedirectToHome: isAuthError
};
}
}
+96 -9
View File
@@ -12,7 +12,7 @@ export type ApiResponse<T> = {
headers?: Record<string, string>;
};
export type QueryParams = Record<string, string | number | boolean | undefined>;
export type QueryParams = Record<string, string | number | boolean | undefined | number[] | string[]>;
// 获取 API 基础 URL (从配置文件导入)
// const API_BASE_URL = 'http://172.16.0.58:8008';
@@ -52,6 +52,12 @@ const AUTH_WHITELIST = [
'/oauth/userinfo'
];
// 错误容忍白名单 - 这些接口即使返回 401/403 也不触发强制登出
const ERROR_TOLERANT_WHITELIST = [
'/admin/statistics/top-error-points',
'/admin/statistics/top-risk-users'
];
/**
* 检查请求URL是否在白名单中
*/
@@ -60,6 +66,14 @@ function isInAuthWhitelist(url?: string): boolean {
return AUTH_WHITELIST.some(path => url.includes(path));
}
/**
* 检查请求URL是否在错误容忍白名单中
*/
function isInErrorTolerantWhitelist(url?: string): boolean {
if (!url) return false;
return ERROR_TOLERANT_WHITELIST.some(path => url.includes(path));
}
/**
* 请求拦截器 - 自动添加 Authorization 头
*/
@@ -67,6 +81,7 @@ axiosInstance.interceptors.request.use(
(config) => {
// 检查是否在白名单中
if (isInAuthWhitelist(config.url)) {
console.log('🔓 [Request Interceptor] URL在白名单中,跳过Authorization:', config.url);
return config;
}
@@ -75,12 +90,24 @@ axiosInstance.interceptors.request.use(
const token = localStorage.getItem('access_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
console.log('🔑 [Request Interceptor] 添加Authorization头:', {
url: config.url,
method: config.method,
hasToken: !!token,
tokenPreview: token.substring(0, 20) + '...'
});
} else {
console.warn('⚠️ [Request Interceptor] 没有找到access_token:', {
url: config.url,
localStorage: Object.keys(localStorage)
});
}
}
return config;
},
(error) => {
console.error('❌ [Request Interceptor] 请求拦截器错误:', error);
return Promise.reject(error);
}
);
@@ -100,14 +127,34 @@ export class AuthenticationError extends Error {
*/
axiosInstance.interceptors.response.use(
(response) => {
console.log('✅ [Response Interceptor] 请求成功:', {
url: response.config.url,
status: response.status,
statusText: response.statusText
});
return response;
},
(error) => {
console.error('❌ [Response Interceptor] 请求失败:', {
url: error.config?.url,
status: error.response?.status,
statusText: error.response?.statusText,
data: error.response?.data
});
if (isAxiosError(error) && error.response?.status === 401) {
// 检查是否在错误容忍白名单中
const requestUrl = error.config?.url;
if (isInErrorTolerantWhitelist(requestUrl)) {
console.warn('⚠️ [容错白名单] 接口返回 401,但不触发强制登出:', requestUrl);
// 直接返回错误,不触发登出
return Promise.reject(error);
}
// Token 过期或无效
console.warn('⚠️ Token 已过期或无效,请重新登录');
console.warn('⚠️ 401 错误详情:', {
url: error.config?.url,
url: requestUrl,
status: error.response?.status,
statusText: error.response?.statusText,
data: error.response?.data,
@@ -208,8 +255,13 @@ function buildUrl(endpoint: string, params?: QueryParams): string {
if (params) {
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined) {
// 处理数组参数:使用逗号分隔
if (Array.isArray(value)) {
url.searchParams.append(key, value.join(','));
} else {
url.searchParams.append(key, String(value));
}
}
});
}
@@ -293,14 +345,17 @@ export async function apiRequest<T>(
// 构建 URL
const url = buildUrl(endpoint, params);
// 设置默认请求头
const headers = options.headers || {};
// 只有在 options.headers 存在时才处理,否则让拦截器处理
let headers = options.headers;
if (headers) {
// 设置默认请求头(仅当 headers 已存在时)
if (!headers['Content-Type'] && options.method !== 'GET') {
headers['Content-Type'] = 'application/json';
}
if (!headers['Accept']) {
headers['Accept'] = 'application/json';
}
}
// 针对 PostgREST 的额外处理
if (endpoint.includes('evaluation_point_groups') && (options.method === 'POST' || options.method === 'PATCH')) {
@@ -327,21 +382,26 @@ export async function apiRequest<T>(
// console.log(`axios-client.ts->请求体: \n${typeof options.data === 'string' ? options.data : JSON.stringify(options.data)}`);
}
// 发送请求
// 构建请求配置
// 如果没有传入 headers,就不设置 headers,让拦截器自动添加
const config: AxiosRequestConfig = {
...options,
url,
headers,
// 确保使用默认超时时间
timeout: options.timeout || DEFAULT_TIMEOUT
};
// 只有在 headers 存在时才设置
if (headers) {
config.headers = headers;
// 🔍 调试:打印 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警告
@@ -407,10 +467,37 @@ export async function apiRequest<T>(
// 检查API返回的状态码
const data = response.data;
if (data && typeof data === 'object' && 'code' in data && data.code !== 0) {
console.error(`API请求失败: ${data.message || data.msg || '未知错误'} - ${url}`);
// 修复:支持code=0PostgREST)和code=200RBAC API)两种成功响应
if (data && typeof data === 'object' && 'code' in data && data.code !== 0 && data.code !== 200) {
const errorMessage = data.message || data.msg || '未知错误';
console.error(`API请求失败: ${errorMessage} - ${url}`);
// 🔑 检测令牌过期错误
const isTokenExpired = errorMessage.includes('令牌已过期') ||
errorMessage.includes('令牌') ||
errorMessage.includes('token') ||
errorMessage.includes('expired') ||
errorMessage.includes('认证') ||
errorMessage.includes('未授权');
if (isTokenExpired) {
console.error('🔑 [API Client] 检测到令牌过期,准备清除会话并重定向...');
// 只在客户端执行重定向
if (typeof window !== 'undefined') {
console.error('🔑 [API Client] 客户端环境,清除 localStorage 并重定向到登录页');
// 清除所有认证相关数据
localStorage.removeItem('access_token');
localStorage.removeItem('user_info');
sessionStorage.clear();
// 重定向到登录页
window.location.href = '/login?expired=true';
}
}
return {
error: data.message || data.msg || '请求失败',
error: errorMessage,
status: response.status,
headers: responseHeaders
};
+242
View File
@@ -0,0 +1,242 @@
/**
* 入口模块管理 API 客户端
* 提供入口模块的增删改查功能
*/
import { postgrestGet, postgrestPost, postgrestPut, postgrestDelete } from "../postgrest-client";
/**
* 入口模块数据接口
*/
export interface EntryModule {
id?: number;
name: string;
description?: string;
path?: string; // logo图片路径
areas?: string[]; // 地区数组
created_at?: string;
updated_at?: string;
}
/**
* 入口模块搜索参数
*/
export interface EntryModuleSearchParams {
name?: string;
area?: string;
page?: number;
pageSize?: number;
}
/**
* 入口模块列表响应
*/
export interface EntryModulesResponse {
modules: EntryModule[];
total: number;
}
/**
* 获取入口模块列表
* @param searchParams 搜索参数
* @param jwtToken JWT令牌
* @returns 入口模块列表和总数
*/
export async function getEntryModules(
searchParams: EntryModuleSearchParams = {},
jwtToken?: string | null
): Promise<{ data?: EntryModulesResponse; error?: string }> {
try {
const { name, area, page = 1, pageSize = 10 } = searchParams;
// 构建过滤条件
const filter: Record<string, string> = {};
if (name) {
filter.name = `ilike.*${name}*`;
}
// 如果有地区筛选,使用 JSONB 查询
if (area) {
filter.areas = `cs.{"${area}"}`; // cs = contains (JSONB数组包含)
}
// 计算分页
const offset = (page - 1) * pageSize;
// 构建查询参数(一次请求获取数据和总数)
const queryParams: any = {
select: "*",
order: "created_at.desc",
limit: pageSize,
offset: offset,
headers: {
'Prefer': 'count=exact'
},
token: jwtToken
};
// 只在有过滤条件时添加 filter
if (Object.keys(filter).length > 0) {
queryParams.filter = filter;
}
// 获取分页数据
const result = await postgrestGet<EntryModule[]>("entry_modules", queryParams);
if (result.error) {
return { error: result.error };
}
// 从 Content-Range 头获取总数
let totalCount = 0;
const responseWithHeaders = result as {
data: unknown;
headers?: Record<string, string>;
};
if (responseWithHeaders.headers) {
const rangeHeader = responseWithHeaders.headers['content-range'];
if (rangeHeader) {
const total = rangeHeader.split('/')[1];
if (total !== '*') {
totalCount = parseInt(total, 10);
}
}
}
return {
data: {
modules: result.data || [],
total: totalCount || (result.data?.length || 0)
}
};
} catch (error) {
console.error("获取入口模块列表失败:", error);
return { error: error instanceof Error ? error.message : "获取入口模块列表失败" };
}
}
/**
* 根据ID获取入口模块
* @param id 入口模块ID
* @param jwtToken JWT令牌
* @returns 入口模块数据
*/
export async function getEntryModuleById(
id: number,
jwtToken?: string | null
): Promise<{ data?: EntryModule; error?: string }> {
try {
const result = await postgrestGet<EntryModule[]>("entry_modules", {
filter: { id: `eq.${id}` },
token: jwtToken
});
if (result.error) {
return { error: result.error };
}
const module = result.data?.[0];
if (!module) {
return { error: "入口模块不存在" };
}
return { data: module };
} catch (error) {
console.error("获取入口模块失败:", error);
return { error: error instanceof Error ? error.message : "获取入口模块失败" };
}
}
/**
* 创建入口模块
* @param module 入口模块数据
* @param jwtToken JWT令牌
* @returns 创建的入口模块
*/
export async function createEntryModule(
module: Omit<EntryModule, "id" | "created_at" | "updated_at">,
jwtToken?: string | null
): Promise<{ data?: EntryModule; error?: string }> {
try {
const result = await postgrestPost<EntryModule[], EntryModule>(
"entry_modules",
module as EntryModule,
jwtToken
);
if (result.error) {
return { error: result.error };
}
const createdModule = Array.isArray(result.data) ? result.data[0] : result.data;
return { data: createdModule as EntryModule };
} catch (error) {
console.error("创建入口模块失败:", error);
return { error: error instanceof Error ? error.message : "创建入口模块失败" };
}
}
/**
* 更新入口模块
* @param id 入口模块ID
* @param module 更新的入口模块数据
* @param jwtToken JWT令牌
* @returns 更新的入口模块
*/
export async function updateEntryModule(
id: number,
module: Partial<Omit<EntryModule, "id" | "created_at" | "updated_at">>,
jwtToken?: string | null
): Promise<{ data?: EntryModule; error?: string }> {
try {
const result = await postgrestPut<EntryModule[], Partial<EntryModule>>(
"entry_modules",
module,
{ id: `eq.${id}` },
jwtToken
);
if (result.error) {
return { error: result.error };
}
const updatedModule = Array.isArray(result.data) ? result.data[0] : result.data;
return { data: updatedModule as EntryModule };
} catch (error) {
console.error("更新入口模块失败:", error);
return { error: error instanceof Error ? error.message : "更新入口模块失败" };
}
}
/**
* 删除入口模块
* @param id 入口模块ID
* @param jwtToken JWT令牌
* @returns 是否成功
*/
export async function deleteEntryModule(
id: number,
jwtToken?: string | null
): Promise<{ success: boolean; error?: string }> {
try {
const result = await postgrestDelete(
"entry_modules",
{ id: `eq.${id}` },
jwtToken
);
if (result.error) {
return { success: false, error: result.error };
}
return { success: true };
} catch (error) {
console.error("删除入口模块失败:", error);
return {
success: false,
error: error instanceof Error ? error.message : "删除入口模块失败"
};
}
}
+4 -4
View File
@@ -507,12 +507,12 @@ export async function getDocumentsListFromAPI(searchParams: {
if (dateFrom) params.start_time = dateFrom;
if (dateTo) params.end_time = dateTo;
// 处理文档类型ID数组 - 传递为数组或单个值
// 处理文档类型ID数组 - 转换为逗号分隔的字符串
if (documentTypeIds && documentTypeIds.length > 0) {
params.type_id = documentTypeIds;
params.type_id = documentTypeIds.join(',');
}
console.log('📤 [getDocumentsListFromAPI] 请求参数:', params);
// console.log('📤 [getDocumentsListFromAPI] 请求参数:', params);
// 调用后端API
const axios = await import('axios').then(m => m.default);
@@ -529,7 +529,7 @@ export async function getDocumentsListFromAPI(searchParams: {
const totalCount = data.total || 0;
const totalPages = data.total_pages || 0;
console.log(`📥 [getDocumentsListFromAPI] 获取到 ${backendDocuments.length} 个文档,总数: ${totalCount}`);
// console.log(`📥 [getDocumentsListFromAPI] 获取到 ${backendDocuments.length} 个文档,总数: ${totalCount}`);
// 转换后端数据为前端 DocumentUI 格式
const convertedDocuments: DocumentUI[] = backendDocuments.map((doc: any) => {
+301 -382
View File
@@ -1,5 +1,6 @@
import { postgrestGet, postgrestPost, type PostgrestParams } from "../postgrest-client";
import dayjs from 'dayjs';
import { postgrestGet, type PostgrestParams } from "../postgrest-client";
import { apiRequest } from "../axios-client";
// import dayjs from 'dayjs';
/**
* 从不同格式的 API 响应中提取数据
@@ -77,397 +78,107 @@ interface HomeStatistics {
}
/**
* 通过传入的 reviewType 参数构建类型过滤条件
* @param reviewType 文档类型
* @returns 过滤条件字符串
* 后端统计接口响应类型(蛇形命名)
*/
function buildTypeFilter(reviewType: string | null): string {
let typeFilter = '';
if (reviewType === 'contract') {
typeFilter = 'type_id.eq.1';
} else if (reviewType === 'record') {
typeFilter = '(type_id.eq.2,type_id.eq.3)';
}
return typeFilter;
interface BackendStatisticsResponse {
today_pending_files: number;
monthly_reviewed_files: number;
monthly_review_growth: {
value: number;
is_up: boolean;
};
monthly_pass_rate: number;
pass_rate_growth: {
value: number;
is_up: boolean;
};
issues_detected: number;
issues_growth: {
value: number;
is_up: boolean;
};
}
/**
* 获取主页数据
* @param reviewType 从客户端传入的 reviewType 值
* @param userId 用户ID
* @param reviewType 从客户端传入的 reviewType 值(已废弃,现在从sessionStorage读取)
* @param userId 用户ID(已废弃,后端通过JWT自动识别)
* @param token JWT token
* @returns 主页数据
*/
export async function getHomeData(reviewType?: string | null,userId?: string | number, token?: string): Promise<HomeStatistics> {
export async function getHomeData(reviewType?: string | null, userId?: string | number, token?: string): Promise<HomeStatistics> {
try {
// 获取当前日期和时间相关值
const startOfToday = dayjs().startOf('day').format('YYYY-MM-DD HH:mm:ss');
const startOfThisMonth = dayjs().startOf('month').format('YYYY-MM-DD HH:mm:ss');
const endOfThisMonth = dayjs().endOf('month').format('YYYY-MM-DD HH:mm:ss');
const startOfLastMonth = dayjs().subtract(1, 'month').startOf('month').format('YYYY-MM-DD HH:mm:ss');
const endOfLastMonth = dayjs().subtract(1, 'month').endOf('month').format('YYYY-MM-DD HH:mm:ss');
// console.log('传入的 reviewType', reviewType);
// console.log('传入的 userId', userId);
// 基于 reviewType 构建类型过滤条件
const typeFilter = buildTypeFilter(reviewType || null);
// console.log('构建的 typeFilter', typeFilter);
// 通用API响应处理函数
const handleApiResponse = async <T>(
apiCall: Promise<{
data?: unknown;
headers?: Record<string, string>;
error?: string;
status?: number
}>,
errorMessage: string,
defaultValue: T
): Promise<T> => {
// 🔑 从 sessionStorage 获取文档类型IDs
let typeIds: string | null = null;
if (typeof window !== 'undefined') {
const storedTypeIds = sessionStorage.getItem('documentTypeIds');
if (storedTypeIds) {
try {
const response = await apiCall;
if (response.error) {
console.error(`${errorMessage}: ${response.error}`);
return defaultValue;
const typeIdsArray = JSON.parse(storedTypeIds) as number[];
if (Array.isArray(typeIdsArray) && typeIdsArray.length > 0) {
typeIds = typeIdsArray.join(',');
console.log('📊 [getHomeData] 从 sessionStorage 获取文档类型:', typeIds);
}
const data = extractApiData<T>(response.data);
if (!data) {
console.warn(`${errorMessage}: 无法提取有效数据`);
return defaultValue;
}
return data;
} catch (error) {
console.error(`${errorMessage}: ${error instanceof Error ? error.message : '未知错误'}`);
return defaultValue;
console.error('❌ [getHomeData] 解析 documentTypeIds 失败:', error);
}
}
}
// 🔑 构建请求参数
const params: Record<string, string> = {
time_range: '30days' // 默认近30天
};
// 1. 今日待审核文件 - 获取今天的待审核文件数量 (audit_status = 0 或 2)
const todayPendingParams: PostgrestParams = {
select: 'count',
filter: {
or: `(audit_status.eq.0,audit_status.eq.2,audit_status.is.null)`,
created_at: `gte.${startOfToday}`,
is_test_document: `eq.false`,
user_id: `eq.${userId}`
}
};
// 添加类型过滤条件
if (typeFilter) {
if (typeFilter.startsWith('(')) {
// 确保 filter 已初始化
if (!todayPendingParams.filter) {
todayPendingParams.filter = {};
}
todayPendingParams.filter.or = typeFilter + ',' + todayPendingParams.filter.or;
} else {
const [field, op, value] = typeFilter.split('.');
if (!todayPendingParams.filter) {
todayPendingParams.filter = {};
}
todayPendingParams.filter[field] = `${op}.${value}`;
}
// 如果有文档类型,添加到参数
if (typeIds) {
params.type_ids = typeIds;
}
const todayPendingCount = await handleApiResponse<{ count: number }[]>(
postgrestGet('documents', { ...todayPendingParams, token }),
'获取今日待审核文件数量失败',
[]
);
const todayPendingFiles = todayPendingCount[0]?.count || 0;
console.log('📊 [getHomeData] 请求参数:', params);
// 2. 本月已审核文件 - 获取本月已审核文件数量 (audit_status != 0 且 != 2)
const thisMonthReviewedParams: PostgrestParams = {
select: 'count',
filter: {
and: `(audit_status.neq.0,audit_status.neq.2)`,
upload_time: `gte.${startOfThisMonth}`,
is_test_document: `eq.false`,
user_id: `eq.${userId}`
}
};
// 添加类型过滤条件
if (typeFilter) {
if (typeFilter.startsWith('(')) {
thisMonthReviewedParams.or = typeFilter;
} else {
const [field, op, value] = typeFilter.split('.');
if (!thisMonthReviewedParams.filter) {
thisMonthReviewedParams.filter = {};
}
thisMonthReviewedParams.filter[field] = `${op}.${value}`;
}
}
const thisMonthReviewedCount = await handleApiResponse<{ count: number }[]>(
postgrestGet('documents', { ...thisMonthReviewedParams, token }),
'获取本月已审核文件数量失败',
[]
);
// 本月已审核文件数量
const monthlyReviewedFiles = thisMonthReviewedCount[0]?.count || 0;
// 上月已审核文件
const lastMonthReviewedParams: PostgrestParams = {
select: 'count',
filter: {
// or: `(audit_status.eq.1,audit_status.eq.-1)`,
and: `(upload_time.gte.${startOfLastMonth},upload_time.lte.${endOfLastMonth},audit_status.neq.0,audit_status.neq.2)`,
is_test_document: `eq.false`,
user_id: `eq.${userId}`
}
};
// 添加类型过滤条件
if (typeFilter) {
if (typeFilter.startsWith('(')) {
// 确保 filter 已初始化
if (!lastMonthReviewedParams.filter) {
lastMonthReviewedParams.filter = {};
}
lastMonthReviewedParams.filter.or = typeFilter;
} else {
const [field, op, value] = typeFilter.split('.');
if (!lastMonthReviewedParams.filter) {
lastMonthReviewedParams.filter = {};
}
lastMonthReviewedParams.filter[field] = `${op}.${value}`;
}
}
const lastMonthReviewedCount = await handleApiResponse<{ count: number }[]>(
postgrestGet('documents', { ...lastMonthReviewedParams, token }),
'获取上月已审核文件数量失败',
[]
);
// 上月已审核文件数量
const lastMonthReviewed = lastMonthReviewedCount[0]?.count || 0;
// console.log('上月已审核文件查询参数', lastMonthReviewedParams);
// console.log('上月已审核文件数量', lastMonthReviewed);
// 计算同比增长
let reviewGrowthValue = 0;
let reviewGrowthIsUp = true;
if (lastMonthReviewed > 0) {
const growthRate = ((monthlyReviewedFiles - lastMonthReviewed) / lastMonthReviewed) * 100;
reviewGrowthValue = Math.abs(parseFloat(growthRate.toFixed(1)));
reviewGrowthIsUp = growthRate >= 0;
} else if (lastMonthReviewed == 0 && monthlyReviewedFiles > 0) {
reviewGrowthValue = 100;
reviewGrowthIsUp = true;
}
// 3. 审核通过率 - 本月审核通过率
const thisMonthTotalParams: PostgrestParams = {
select: 'count',
filter: {
audit_status: `eq.1`,
created_at: `gte.${startOfThisMonth}`,
is_test_document: `eq.false`,
user_id: `eq.${userId}`
}
};
// 添加类型过滤条件
if (typeFilter) {
if (typeFilter.startsWith('(')) {
thisMonthTotalParams.or = typeFilter;
} else {
const [field, op, value] = typeFilter.split('.');
if (!thisMonthTotalParams.filter) {
thisMonthTotalParams.filter = {};
}
thisMonthTotalParams.filter[field] = `${op}.${value}`;
}
}
const thisMonthTotalCount = await handleApiResponse<{ count: number }[]>(
postgrestGet('documents', { ...thisMonthTotalParams, token }),
'获取本月审核通过数量失败',
[]
);
// console.log('本月审核通过数量查询参数', thisMonthTotalParams);
// 本月审核通过数量
const thisMonthPassTotal = thisMonthTotalCount[0]?.count || 0;
// console.log('本月审核通过数量', thisMonthPassTotal);
// console.log('本月已审核文件数量', monthlyReviewedFiles);
// 本月审核通过率
const monthlyPassRate = (thisMonthPassTotal > 0 && monthlyReviewedFiles > 0)
? parseFloat(((thisMonthPassTotal / monthlyReviewedFiles) * 100).toFixed(1))
: 0;
// 上月审核通过率
const lastMonthTotalParams: PostgrestParams = {
select: 'count',
filter: {
audit_status: `eq.1`,
and: `(upload_time.gte.${startOfLastMonth},upload_time.lte.${endOfLastMonth})`,
is_test_document: `eq.false`,
user_id: `eq.${userId}`
}
};
// 添加类型过滤条件
if (typeFilter) {
if (typeFilter.startsWith('(')) {
lastMonthTotalParams.or = typeFilter;
} else {
const [field, op, value] = typeFilter.split('.');
if (!lastMonthTotalParams.filter) {
lastMonthTotalParams.filter = {};
}
lastMonthTotalParams.filter[field] = `${op}.${value}`;
}
}
const lastMonthTotalCount = await handleApiResponse<{ count: number }[]>(
postgrestGet('documents', { ...lastMonthTotalParams, token }),
'获取上月审核通过数量失败',
[]
);
// 上月审核通过数量
const lastMonthTotal = lastMonthTotalCount[0]?.count || 0;
// 上月审核通过率
const lastMonthPassRate = (lastMonthTotal > 0 && lastMonthReviewed > 0)
? parseFloat(((lastMonthTotal / lastMonthReviewed) * 100).toFixed(1))
: 0;
// console.log('上个月-------', lastMonthPassRate);
// 计算通过率同比增长
let passRateGrowthValue = 0;
let passRateGrowthIsUp = true;
if (lastMonthPassRate > 0) {
const passRateGrowth = ((monthlyPassRate - lastMonthPassRate) / lastMonthPassRate) * 100;
passRateGrowthValue = Math.abs(parseFloat(passRateGrowth.toFixed(1)));
passRateGrowthIsUp = passRateGrowth >= 0;
} else if (lastMonthPassRate == 0 && monthlyPassRate > 0) {
passRateGrowthValue = 100;
passRateGrowthIsUp = true;
}
// console.log('上月通过率-------', lastMonthPassRate);
// console.log('本月通过率-------', monthlyPassRate);
// 4. 检查出的问题总数(从评估结果表中统计)
// 使用新的数据库函数 count_evaluation_results_by_type 获取指定类型文档的问题数量
let thisMonthIssuesCount = 0;
let lastMonthIssuesCount = 0;
// 根据 reviewType 设置要查询的文档类型
if (reviewType === 'contract') {
// 合同类型 - 直接查询类型 1
const typeToQuery = [1];
// 调用数据库函数获取本月指定类型的问题数量
const thisMonthIssuesResponse = await handleApiResponse<{ count: number }[]>(
postgrestPost('rpc/count_evaluation_results_by_type', {
start_time: startOfThisMonth,
end_time: endOfThisMonth,
type_val: typeToQuery,
userid: parseInt(userId as string)
}, token),
'获取合同本月问题数据失败',
[]
// 🔑 调用后端统计接口
const response = await apiRequest<BackendStatisticsResponse>(
'/admin/statistics/home-data',
{
method: 'GET',
headers: token ? {
'Authorization': `Bearer ${token}`
} : undefined
},
params // 查询参数
);
// 本月问题数量
thisMonthIssuesCount = thisMonthIssuesResponse[0]?.count || 0;
// 调用数据库函数获取上月指定类型的问题数量
const lastMonthIssuesResponse = await handleApiResponse<{ count: number }[]>(
postgrestPost('rpc/count_evaluation_results_by_type', {
start_time: startOfLastMonth,
end_time: endOfLastMonth,
type_val: typeToQuery,
userid: parseInt(userId as string)
}, token),
'获取上月问题数据失败',
[]
);
// 上月问题数量
lastMonthIssuesCount = lastMonthIssuesResponse[0]?.count || 0;
} else if (reviewType === 'record') {
// 记录类型 - 需要查询类型 2 和类型 3,并合并结果
const typeToQuery = [2,3];
const thisMonthType2Response = await handleApiResponse<{ count: number }[]>(
postgrestPost('rpc/count_evaluation_results_by_type', {
start_time: startOfThisMonth,
end_time: endOfThisMonth,
type_val: typeToQuery,
userid: parseInt(userId as string)
}, token),
'获取本月许可卷宗类型2问题数据失败',
[]
);
// 本月两种类型的问题数量
const thisMonthType2Count = thisMonthType2Response[0]?.count || 0;
thisMonthIssuesCount = thisMonthType2Count
// 上月两种类型的问题数量
const lastMonthType2Response = await handleApiResponse<{ count: number }[]>(
postgrestPost('rpc/count_evaluation_results_by_type', {
start_time: startOfLastMonth,
end_time: endOfLastMonth,
type_val: typeToQuery,
userid: parseInt(userId as string)
}, token),
'获取上月许可卷宗类型2问题数据失败',
[]
);
// 上月两种类型的问题数量
const lastMonthType2Count = lastMonthType2Response[0]?.count || 0;
lastMonthIssuesCount = lastMonthType2Count
if (response.error) {
console.error('❌ [getHomeData] 获取统计数据失败:', response.error);
throw new Error(response.error);
}
// 计算问题数量同比增长
let issuesGrowthValue = 0;
let issuesGrowthIsUp = true;
if (lastMonthIssuesCount > 0) {
const issuesGrowth = ((thisMonthIssuesCount - lastMonthIssuesCount) / lastMonthIssuesCount) * 100;
issuesGrowthValue = Math.abs(parseFloat(issuesGrowth.toFixed(1)));
issuesGrowthIsUp = issuesGrowth >= 0;
}else if(lastMonthIssuesCount == 0 && thisMonthIssuesCount > 0){
issuesGrowthValue = 100;
issuesGrowthIsUp = true;
const backendData = response.data;
if (!backendData) {
console.error('❌ [getHomeData] 后端未返回数据');
throw new Error('后端未返回统计数据');
}
// 返回统计结果
console.log('✅ [getHomeData] 获取统计数据成功:', backendData);
// 🔑 将后端响应(蛇形命名)转换为前端格式(驼峰命名)
return {
todayPendingFiles,
monthlyReviewedFiles,
todayPendingFiles: backendData.today_pending_files,
monthlyReviewedFiles: backendData.monthly_reviewed_files,
monthlyReviewGrowth: {
value: reviewGrowthValue,
isUp: reviewGrowthIsUp
value: backendData.monthly_review_growth.value,
isUp: backendData.monthly_review_growth.is_up
},
monthlyPassRate,
monthlyPassRate: backendData.monthly_pass_rate,
passRateGrowth: {
value: passRateGrowthValue,
isUp: passRateGrowthIsUp
value: backendData.pass_rate_growth.value,
isUp: backendData.pass_rate_growth.is_up
},
issuesDetected: thisMonthIssuesCount,
issuesDetected: backendData.issues_detected,
issuesGrowth: {
value: issuesGrowthValue,
isUp: issuesGrowthIsUp
value: backendData.issues_growth.value,
isUp: backendData.issues_growth.is_up
}
};
} catch (error) {
@@ -485,6 +196,15 @@ export async function getHomeData(reviewType?: string | null,userId?: string | n
}
}
/**
* 地区配置类型定义
*/
export interface AreaConfig {
area: string; // 地区名称
enabled: boolean; // 是否启用
sort_order: number; // 排序顺序
}
/**
* 入口模块类型定义
*/
@@ -493,7 +213,7 @@ export interface EntryModule {
name: string;
description: string | null;
path: string | null;
areas: string[];
areas: AreaConfig[]; // 修改为对象数组
created_at: string;
updated_at: string;
document_types?: Array<{
@@ -518,20 +238,11 @@ export async function getEntryModules(userRole: string | null | undefined, userA
// console.log('🔍 [getEntryModules] 查询地区:', userArea);
// 查询 entry_modules 表,筛选 areas 数组中包含用户地区的模块
// 使用 PostgreSQL JSONB 操作符 @> 检查数组是否包含值
// 查询 entry_modules 表,获取所有模块(在客户端进行过滤)
const params: PostgrestParams = {
select: 'id,name,description,path,areas,created_at,updated_at',
filter: {
// areas 数组中包含用户的 area
// areas: `cs.["${userArea}"]` // cs = contains (PostgreSQL @> 操作符)
}
filter: {}
};
if (userRole != 'provincial_admin'){
params.filter = {
areas: `cs.["${userArea}"]`
}
}
const modulesResponse = await postgrestGet('entry_modules', { ...params, token });
@@ -540,13 +251,38 @@ export async function getEntryModules(userRole: string | null | undefined, userA
return [];
}
const modules = extractApiData<EntryModule[]>(modulesResponse.data);
if (!modules || modules.length === 0) {
console.warn('⚠️ [getEntryModules] 未找到匹配的入口模块');
const allModules = extractApiData<EntryModule[]>(modulesResponse.data);
if (!allModules || allModules.length === 0) {
console.warn('⚠️ [getEntryModules] 未找到任何入口模块');
return [];
}
console.log(`✅ [getEntryModules] 找到 ${modules.length} 个入口模块`);
// 🔑 在客户端过滤:只保留包含用户地区且已启用的模块
const modules = allModules.filter(module => {
// 省级管理员可以看到所有模块
if (userRole === 'provincial_admin') {
return true;
}
// 检查 areas 数组中是否存在匹配的地区配置
if (!module.areas || !Array.isArray(module.areas)) {
return false;
}
// 查找用户地区的配置
const areaConfig = module.areas.find(config =>
config.area === userArea && config.enabled === true
);
return !!areaConfig; // 找到且启用才返回 true
});
if (modules.length === 0) {
console.warn('⚠️ [getEntryModules] 未找到已启用的入口模块');
return [];
}
// console.log(`✅ [getEntryModules] 找到 ${modules.length} 个已启用的入口模块`);
// 为每个模块查询关联的 document_types
const modulesWithTypes = await Promise.all(
@@ -579,7 +315,7 @@ export async function getEntryModules(userRole: string | null | undefined, userA
})
);
console.log('✅ [getEntryModules] 入口模块数据(含文档类型):', JSON.stringify(modulesWithTypes));
// console.log('✅ [getEntryModules] 入口模块数据(含文档类型):', JSON.stringify(modulesWithTypes));
// 默认会多加一个 智慧法务大模型 入口 默认所有人都可以用,看到
modulesWithTypes.push({
@@ -597,7 +333,8 @@ export async function getEntryModules(userRole: string | null | undefined, userA
"code": "空"
}
]
})
}
)
return modulesWithTypes;
@@ -607,3 +344,185 @@ export async function getEntryModules(userRole: string | null | undefined, userA
}
}
/**
* 高频错误评查点数据类型
*/
export interface TopErrorPoint {
rank: number;
evaluation_point_id: number;
point_name: string;
error_user_count: number;
}
export interface TopErrorPointsResponse {
available: boolean; // 标记该模块是否可用(是否有权限访问)
total: number;
items: TopErrorPoint[];
}
/**
* 获取高频错误评查点 Top N
* @param limit 返回 Top N 条记录,默认 10
* @param startDate 开始时间(格式:YYYY-MM-DD
* @param endDate 结束时间(格式:YYYY-MM-DD
* @param typeId 文档类型ID数组
* @param token JWT token
* @returns 高频错误评查点列表
*/
export async function getTopErrorPoints(
limit: number = 10,
startDate?: string,
endDate?: string,
typeId?: number[],
token?: string
): Promise<TopErrorPointsResponse> {
try {
console.log('🔍 [getTopErrorPoints] 请求参数:', { limit, startDate, endDate, typeId, hasToken: !!token });
// 构建查询参数
const params: Record<string, string | number | number[]> = {
limit: limit
};
if (startDate) {
params.start_date = startDate;
}
if (endDate) {
params.end_date = endDate;
}
if (typeId && typeId.length > 0) {
// 直接传递数组,axios 会自动处理序列化
params.type_id = typeId;
}
// 构建请求配置
const requestOptions: { method: string; headers?: Record<string, string> } = {
method: 'GET'
};
// 只有在显式传入 token 时才添加 Authorization header
// 否则让 axios 拦截器自动处理(从 localStorage 获取)
if (token) {
requestOptions.headers = {
'Authorization': `Bearer ${token}`
};
}
// 调用 API
const response = await apiRequest<TopErrorPointsResponse>(
'/admin/statistics/top-error-points',
requestOptions,
params
);
if (response.error) {
console.error('❌ [getTopErrorPoints] 获取高频错误评查点失败:', response.error);
// 请求失败(如权限不足),标记为不可用
return { available: false, total: 0, items: [] };
}
console.log('✅ [getTopErrorPoints] 成功获取高频错误评查点数据:', response.data);
// 请求成功,标记为可用(即使数据为空)
const data = response.data || { total: 0, items: [] };
return { available: true, ...data };
} catch (error) {
console.error('❌ [getTopErrorPoints] 获取高频错误评查点异常:', error instanceof Error ? error.message : String(error));
// 请求异常,标记为不可用
return { available: false, total: 0, items: [] };
}
}
/**
* 高风险用户数据类型
*/
export interface TopRiskUser {
rank: number;
user_id: number;
user_name: string;
department: string;
total_errors: number;
avg_errors_per_doc: number;
}
export interface TopRiskUsersResponse {
available: boolean; // 标记该模块是否可用(是否有权限访问)
total: number;
items: TopRiskUser[];
}
/**
* 获取高风险用户 Top N
* @param limit 返回 Top N 条记录,默认 5
* @param startDate 开始时间(格式:YYYY-MM-DD
* @param endDate 结束时间(格式:YYYY-MM-DD
* @param typeId 文档类型ID数组
* @param token JWT token
* @returns 高风险用户列表
*/
export async function getTopRiskUsers(
limit: number = 5,
startDate?: string,
endDate?: string,
typeId?: number[],
token?: string
): Promise<TopRiskUsersResponse> {
try {
console.log('🔍 [getTopRiskUsers] 请求参数:', { limit, startDate, endDate, typeId, hasToken: !!token });
// 构建查询参数
const params: Record<string, string | number | number[]> = {
limit: limit
};
if (startDate) {
params.start_date = startDate;
}
if (endDate) {
params.end_date = endDate;
}
if (typeId && typeId.length > 0) {
// 直接传递数组,axios 会自动处理序列化
params.type_id = typeId;
}
// 构建请求配置
const requestOptions: { method: string; headers?: Record<string, string> } = {
method: 'GET'
};
// 只有在显式传入 token 时才添加 Authorization header
// 否则让 axios 拦截器自动处理(从 localStorage 获取)
if (token) {
requestOptions.headers = {
'Authorization': `Bearer ${token}`
};
}
// 调用 API
const response = await apiRequest<TopRiskUsersResponse>(
'/admin/statistics/top-risk-users',
requestOptions,
params
);
if (response.error) {
console.error('❌ [getTopRiskUsers] 获取高风险用户失败:', response.error);
// 请求失败(如权限不足),标记为不可用
return { available: false, total: 0, items: [] };
}
console.log('✅ [getTopRiskUsers] 成功获取高风险用户数据:', response.data);
// 请求成功,标记为可用(即使数据为空)
const data = response.data || { total: 0, items: [] };
return { available: true, ...data };
} catch (error) {
console.error('❌ [getTopRiskUsers] 获取高风险用户异常:', error instanceof Error ? error.message : String(error));
// 请求异常,标记为不可用
return { available: false, total: 0, items: [] };
}
}
+29 -132
View File
@@ -455,15 +455,27 @@ export async function logout(request: Request) {
const accessToken = session.get("accessToken");
const appId = OAUTH_CONFIG.appId || 'idaasoauth2';
// 如果存在访问令牌,调用IDaaS单点登出
console.log("🚪 [Logout] 开始登出流程...");
console.log("🔑 [Logout] accessToken 存在:", !!accessToken);
console.log("📱 [Logout] appId:", appId);
// 如果存在访问令牌,调用IDaaS单点登出(仅 OAuth 登录用户)
if (accessToken && appId) {
console.log("🌐 [Logout] OAuth 用户,准备调用 IDaaS 单点登出...");
try {
await callIDaaSLogout(accessToken, appId);
console.log("IDaaS单点登出成功");
console.log("✅ [Logout] IDaaS单点登出成功");
} catch (error) {
console.error("IDaaS单点登出失败:", error);
console.error("❌ [Logout] IDaaS单点登出失败:");
console.error(" 错误详情:", error);
if (error instanceof Error) {
console.error(" 错误消息:", error.message);
console.error(" 错误堆栈:", error.stack);
}
// 即使IDaaS登出失败,也继续清除本地会话
}
} else {
console.log("️ [Logout] 管理员登录用户,无需调用 IDaaS 登出");
}
return new Response(null, {
@@ -487,6 +499,11 @@ async function callIDaaSLogout(accessToken: string, appId: string): Promise<void
const redirectUri = OAUTH_CONFIG.redirectUri || 'http://10.79.97.17/';
const logoutUrl = `${serverUrl}/public/sp/slo/${appId}`;
console.log("📡 [callIDaaSLogout] 准备发送登出请求:");
console.log(" 登出URL:", logoutUrl);
console.log(" 重定向URL:", redirectUri);
console.log(" accessToken:", accessToken ? `${accessToken.substring(0, 20)}...` : 'null');
const formData = new URLSearchParams();
formData.append('access_token', accessToken);
formData.append('redirect_url', encodeURIComponent(redirectUri));
@@ -498,13 +515,19 @@ async function callIDaaSLogout(accessToken: string, appId: string): Promise<void
},
});
console.log("IDaaS单点登出请求成功");
console.log("✅ [callIDaaSLogout] IDaaS单点登出请求成功");
console.log(" 响应状态:", response.status);
console.log(" 响应数据:", response.data);
} catch (error) {
if (axios.isAxiosError(error)) {
console.error("调用IDaaS登出接口失败:", error.response?.status, error.response?.statusText);
console.error("❌ [callIDaaSLogout] 调用IDaaS登出接口失败:");
console.error(" HTTP状态:", error.response?.status);
console.error(" 状态文本:", error.response?.statusText);
console.error(" 响应数据:", error.response?.data);
console.error(" 请求配置:", error.config?.url, error.config?.method);
throw new Error(`IDaaS登出失败: ${error.response?.status} ${error.response?.statusText}`);
}
console.error("调用IDaaS登出接口失败:", error);
console.error("❌ [callIDaaSLogout] 调用IDaaS登出接口失败(非HTTP错误):", error);
throw error;
}
}
@@ -751,129 +774,3 @@ export async function getUserBySub(sub: string) {
};
}
}
/**
* 账号密码登录接口
*
* @param username - 用户名
* @param password - 密码
* @param redirectTo - 登录成功后重定向的URL
* @returns HTTP重定向响应或错误响应
*/
export async function simpleRootLogin(
username: string,
password: string,
redirectTo: string
) {
try {
// 输入验证
if (!username?.trim() || !password?.trim()) {
return new Response(JSON.stringify({
success: false,
error: "用户名和密码不能为空"
}), {
status: 400,
headers: { "Content-Type": "application/json" }
});
}
// 调用登录接口
const loginResponse = await axios.post(`${API_BASE_URL}/password_login`, {
sub: username.trim(),
password: password.trim()
}, {
headers: {
'Content-Type': 'application/json',
}
});
const loginResult = loginResponse.data;
console.log('登录接口返回', loginResult);
// 检查重试次数
const retryCount = loginResult.retryCount || loginResult.retry_count || 0;
console.log('登录重试次数:', retryCount);
if (loginResult.code === 0 && loginResult.data) {
// 登录成功,构建用户信息
const userData = loginResult.data;
// console.log('管理员登录userData', userData);
const userRole = userData.role; // 默认角色
// 生成模拟的OAuth token信息
const mockTokenExpiresIn = 7200; // 2小时
const mockAccessToken = `mock_access_token_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const mockRefreshToken = `mock_refresh_token_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
// 生成前端JWT
const jwtUserInfo: UserInfoForJWT = {
sub: userData.sub,
user_id: userData.user_id,
username: userData.username,
nick_name: userData.nick_name,
email: userData.email,
phone_number: userData.phone_number,
ou_id: userData.ou_id,
ou_name: userData.ou_name,
is_leader: userData.is_leader,
user_role: userRole
};
const frontendJWT = JWTUtils.generateJWT(jwtUserInfo, mockTokenExpiresIn);
// 构建增强的用户信息对象
const enhancedUserInfo = {
...userData,
user_id: userData.user_id,
user_role: userRole,
frontend_jwt: frontendJWT
};
// 使用统一的session创建函数
return createUserSession({
isAuthenticated: true,
userRole: userRole,
redirectTo,
accessToken: mockAccessToken,
refreshToken: mockRefreshToken,
tokenExpiresIn: mockTokenExpiresIn,
userInfo: enhancedUserInfo,
frontendJWT
});
} else {
// 登录失败,检查账户是否被锁定
let errorMsg = loginResult.msg || "登录失败,请检查用户名和密码";
let isLocked = false;
// 检查是否因重试次数过多被锁定
if (retryCount >= 5) {
errorMsg = "账户已被锁定,密码错误次数过多,请联系管理员";
isLocked = true;
} else if (retryCount > 0) {
// 显示剩余尝试次数
const remainingAttempts = 5 - retryCount;
errorMsg = `${loginResult.msg || "用户名或密码错误"},还有 ${remainingAttempts} 次尝试机会`;
}
return new Response(JSON.stringify({
success: false,
error: errorMsg,
retryCount: retryCount,
isLocked: isLocked,
remainingAttempts: isLocked ? 0 : (5 - retryCount)
}), {
status: isLocked ? 403 : 401, // 403 表示禁止访问(账户被锁)
headers: { "Content-Type": "application/json" }
});
}
} catch (error) {
console.error("登录请求失败:", error);
return new Response(JSON.stringify({
success: false,
error: "登录请求失败,请稍后重试"
}), {
status: 500,
headers: { "Content-Type": "application/json" }
});
}
}
+65
View File
@@ -278,6 +278,22 @@ export async function postgrestGet<T>(endpoint: string, params?: PostgrestParams
);
if (response.error) {
// 🔑 检测令牌过期错误
const isTokenExpired = response.error.includes('令牌已过期') ||
response.error.includes('令牌') ||
response.error.includes('token') ||
response.error.includes('expired') ||
response.error.includes('认证') ||
response.error.includes('未授权');
if (isTokenExpired && typeof window !== 'undefined') {
console.error('🔑 [PostgREST Client - GET] 检测到令牌过期,清除会话并重定向到登录页');
localStorage.removeItem('access_token');
localStorage.removeItem('user_info');
sessionStorage.clear();
window.location.href = '/login?expired=true';
}
throw new Error(response.error);
}
@@ -421,6 +437,23 @@ export async function postgrestPost<T, D = Record<string, unknown>>(endpoint: st
if (response.error) {
console.error(`POST请求失败: ${response.error}`);
// 🔑 检测令牌过期错误
const isTokenExpired = response.error.includes('令牌已过期') ||
response.error.includes('令牌') ||
response.error.includes('token') ||
response.error.includes('expired') ||
response.error.includes('认证') ||
response.error.includes('未授权');
if (isTokenExpired && typeof window !== 'undefined') {
console.error('🔑 [PostgREST Client] 检测到令牌过期,清除会话并重定向到登录页');
localStorage.removeItem('access_token');
localStorage.removeItem('user_info');
sessionStorage.clear();
window.location.href = '/login?expired=true';
}
throw new Error(response.error);
}
@@ -550,6 +583,22 @@ export async function postgrestPut<T, D extends object>(
);
if (response.error) {
// 🔑 检测令牌过期错误
const isTokenExpired = response.error.includes('令牌已过期') ||
response.error.includes('令牌') ||
response.error.includes('token') ||
response.error.includes('expired') ||
response.error.includes('认证') ||
response.error.includes('未授权');
if (isTokenExpired && typeof window !== 'undefined') {
console.error('🔑 [PostgREST Client - PATCH] 检测到令牌过期,清除会话并重定向到登录页');
localStorage.removeItem('access_token');
localStorage.removeItem('user_info');
sessionStorage.clear();
window.location.href = '/login?expired=true';
}
throw new Error(response.error);
}
@@ -597,6 +646,22 @@ export async function postgrestDelete<T>(endpoint: string, params?: PostgrestPar
);
if (response.error) {
// 🔑 检测令牌过期错误
const isTokenExpired = response.error.includes('令牌已过期') ||
response.error.includes('令牌') ||
response.error.includes('token') ||
response.error.includes('expired') ||
response.error.includes('认证') ||
response.error.includes('未授权');
if (isTokenExpired && typeof window !== 'undefined') {
console.error('🔑 [PostgREST Client - DELETE] 检测到令牌过期,清除会话并重定向到登录页');
localStorage.removeItem('access_token');
localStorage.removeItem('user_info');
sessionStorage.clear();
window.location.href = '/login?expired=true';
}
throw new Error(response.error);
}
File diff suppressed because it is too large Load Diff
+31 -8
View File
@@ -90,12 +90,16 @@ export function Sidebar({ onToggle, collapsed, userRole, frontendJWT = '' }: Sid
fetchUserRoutes();
}, [userRole, frontendJWT, navigate]);
// 从 sessionStorage 读取当前选中的模块名称和图片路径
// 🔑 检查是否处于系统设置模式
const [isSettingsMode, setIsSettingsMode] = useState<boolean>(false);
// 从 sessionStorage 读取当前选中的模块名称和图片路径,以及系统设置模式标志
useEffect(() => {
if (typeof window !== 'undefined') {
try {
const moduleName = sessionStorage.getItem('selectedModuleName');
const modulePicPath = sessionStorage.getItem('selectedModulePicPath');
const settingsMode = sessionStorage.getItem('settingsMode');
if (moduleName) {
setSelectedModuleName(moduleName);
@@ -106,6 +110,14 @@ export function Sidebar({ onToggle, collapsed, userRole, frontendJWT = '' }: Sid
setSelectedModulePicPath(modulePicPath);
console.log('🖼️ [Sidebar] 模块图片路径:', modulePicPath);
}
// 🔑 检查是否处于系统设置模式
if (settingsMode === 'true') {
setIsSettingsMode(true);
console.log('⚙️ [Sidebar] 进入系统设置模式');
} else {
setIsSettingsMode(false);
}
} catch (error) {
console.error('❌ [Sidebar] 读取 sessionStorage 失败:', error);
}
@@ -154,18 +166,29 @@ export function Sidebar({ onToggle, collapsed, userRole, frontendJWT = '' }: Sid
// console.log('子菜单点击:', child.title, '路径:', child.path);
};
const isPort51707 = typeof window !== 'undefined' && window.location.port === '51707'
// const isPort51707 = typeof window !== 'undefined' && window.location.port === '51707'
// 处理菜单项:清理子菜单结构
const processedMenuItems: MenuItem[] = menuItems.filter(item =>{
// console.log('菜单项:', item.title, 'Icon:', item.icon)
// 🔑 优先检查:如果处于系统设置模式,只显示 /settings 及其子路由
if (isSettingsMode) {
return item.path === '/settings' || item.path?.startsWith('/settings/');
}
// 🔑 重要:非系统设置模式下,隐藏所有 /settings 相关菜单
if (item.path === '/settings' || item.path?.startsWith('/settings/')) {
return false;
}
// 如果是省局访问
if(isPort51707){
if (selectedModuleName === '智慧法务大模型'){
return item.path && item.path.startsWith('/chat-with-llm')
}
return item.path && item.path.startsWith('/cross-checking')
}
// if(isPort51707){
// if (selectedModuleName === '智慧法务大模型'){
// return item.path && item.path.startsWith('/chat-with-llm')
// }
// return item.path && item.path.startsWith('/cross-checking')
// }
// 🔑 如果选择了"智慧法务大模型",只显示 /chat-with-llm 相关菜单
if (selectedModuleName === '智慧法务大模型') {
+4
View File
@@ -8,6 +8,10 @@ import { DOCUMENT_URL } from '~/api/axios-client';
import { CollaboraViewer, type CollaboraViewerHandle } from '~/components/collabora/CollaboraViewer';
import { requestPageInfo } from '~/components/collabora/lib/pageInfo';
// 导入react-pdf的CSS样式(文本层和注释层必需)
import 'react-pdf/dist/esm/Page/TextLayer.css';
import 'react-pdf/dist/esm/Page/AnnotationLayer.css';
// 设置worker路径为public目录下的worker文件
// 使用已经下载的兼容版本 (pdfjs-dist v2.12.313)
// 2025/09/28 使用新版本的pdfjs-dist v4.8.69
+4 -3
View File
@@ -140,8 +140,6 @@ export function BasicInfo({ onChange, initialData, evaluationPointGroups = [], r
// 处理条款号输入框失去焦点
const handleLawArticlesBlur = () => {
if (!lawArticlesText) return;
// 将输入的文本转换为数组
const articles = lawArticlesText
.split(',')
@@ -151,7 +149,7 @@ export function BasicInfo({ onChange, initialData, evaluationPointGroups = [], r
// 创建一个新的引用法律对象,保留现有字段
const referencesLaws = {
...(formData.references_laws || {}),
articles: articles.length > 0 ? articles : []
articles: articles // ✅ 清空时会是空数组
};
// 更新表单数据
@@ -171,6 +169,9 @@ export function BasicInfo({ onChange, initialData, evaluationPointGroups = [], r
useEffect(() => {
if (formData.references_laws?.articles && formData.references_laws.articles.length > 0) {
setLawArticlesText(formData.references_laws.articles.join(','));
} else {
// ✅ 当 articles 为空时,也清空输入框
setLawArticlesText('');
}
}, [formData.references_laws?.articles]);
+5 -2
View File
@@ -31,6 +31,9 @@ export function Table<T extends Record<string, any>>({
className = '',
onRow,
}: TableProps<T>) {
// 防御性检查:确保 dataSource 始终是数组
const safeDataSource = dataSource || [];
const getRowKey = (record: T, index: number): string => {
if (typeof rowKey === 'function') {
return rowKey(record);
@@ -58,8 +61,8 @@ export function Table<T extends Record<string, any>>({
</tr>
</thead>
<tbody>
{dataSource.length > 0 ? (
dataSource.map((record, index) => (
{safeDataSource.length > 0 ? (
safeDataSource.map((record, index) => (
<tr
key={getRowKey(record, index)}
{...(onRow ? onRow(record, index) : {})}
+20 -15
View File
@@ -37,17 +37,16 @@ const portConfigs: Record<string, Partial<ApiConfig>> = {
// 主要
// 梅州
'51703': {
baseUrl: 'http://127.0.0.1:8073',
documentUrl: 'http://127.0.0.1:8073/docauditai/',
uploadUrl: 'http://127.0.0.1:8073/admin/documents',
baseUrl: 'http://172.16.0.55:8073',
documentUrl: 'http://172.16.0.55:8073/docauditai/',
uploadUrl: 'http://172.16.0.55:8073/admin/documents',
collaboraUrl: 'http://172.16.0.81:9980',
appUrl: 'http://10.79.97.17:51703',
appUrl: 'http://172.16.0.34:51703',
oauth: {
redirectUri: 'http://10.79.97.17:51703/callback'
}
// baseUrl: 'http://nas.7bm.co:8873',
// documentUrl: 'http://nas.7bm.co:8873/docauditai/',
// uploadUrl: 'http://nas.7bm.co:8873/admin/documents'
},
@@ -115,11 +114,13 @@ const portConfigs: Record<string, Partial<ApiConfig>> = {
const configs: Record<string, ApiConfig> = {
// 开发环境
development: {
baseUrl: 'http://172.16.0.78:8073', // FastAPI后端(包含/dify代理)
documentUrl: 'http://172.16.0.78:8073/docauditai/',
uploadUrl: 'http://172.16.0.78:8073/admin/documents',
baseUrl: 'http://172.16.0.55:8073', // FastAPI后端(包含/dify代理)
documentUrl: 'http://172.16.0.55:8073/docauditai/',
uploadUrl: 'http://172.16.0.55:8073/admin/documents',
collaboraUrl: 'http://172.16.0.81:9980',
appUrl: 'http://172.16.0.78:51703',
appUrl: 'http://172.16.0.34:51703',
oauth: {
serverUrl: 'http://10.79.112.85', // IDaaS服务器地址
clientId: 'none',
@@ -216,6 +217,10 @@ const getConfigFromEnv = (defaultConfig: ApiConfig): ApiConfig => {
baseUrl: process.env.NEXT_PUBLIC_API_BASE_URL || defaultConfig.baseUrl,
documentUrl: process.env.NEXT_PUBLIC_DOCUMENT_URL || defaultConfig.documentUrl,
uploadUrl: process.env.NEXT_PUBLIC_UPLOAD_URL || defaultConfig.uploadUrl,
collaboraUrl: defaultConfig.collaboraUrl || '',
appUrl: defaultConfig.appUrl || '',
oauth: {
serverUrl: process.env.NEXT_PUBLIC_OAUTH_SERVER_URL || defaultConfig.oauth.serverUrl,
clientId: process.env.NEXT_PUBLIC_OAUTH_CLIENT_ID || defaultConfig.oauth.clientId,
@@ -360,10 +365,10 @@ export const {
* 可以安全地在客户端代码中使用
*/
export const CLIENT_OAUTH_CONFIG = {
serverUrl: OAUTH_CONFIG.serverUrl,
clientId: OAUTH_CONFIG.clientId,
redirectUri: OAUTH_CONFIG.redirectUri,
appId: OAUTH_CONFIG.appId,
serverUrl: OAUTH_CONFIG.serverUrl as string,
clientId: OAUTH_CONFIG.clientId as string,
redirectUri: OAUTH_CONFIG.redirectUri as string,
appId: OAUTH_CONFIG.appId as string,
// 客户端不需要 clientSecret
};
+31 -4
View File
@@ -87,11 +87,14 @@ function isPathAllowed(pathname: string, allowedPaths: string[]): boolean {
}
}
// 根路径特殊处理
if (pathname === '/' || pathname === '/home') {
return true; // 首页通常对所有已登录用户开放
// 根路径特殊处理(仅根路径 '/' 对所有已登录用户开放)
if (pathname === '/') {
return true; // 根路径重定向到首页,始终允许
}
// /home 路由需要检查路由权限,不再特殊处理
// 如果用户的 routes 数据中没有 /home,则返回 403
return false;
}
@@ -149,7 +152,14 @@ export async function loader({ request }: LoaderFunctionArgs) {
// 🔒 RBAC 路由权限检查
const { getUserRoutesByRole } = await import("~/api/auth/user-routes");
// 权限校验需要包含隐藏路由,确保用户可以访问隐藏的功能页面
// console.log("🔒 [Root Loader] 开始调用 getUserRoutesByRole...");
const routesResult = await getUserRoutesByRole(userRole, frontendJWT, true);
// console.log("🔒 [Root Loader] getUserRoutesByRole 返回结果:", {
// success: routesResult.success,
// hasData: !!routesResult.data,
// error: routesResult.error,
// shouldRedirectToHome: routesResult.shouldRedirectToHome
// });
if (routesResult.success && routesResult.data) {
// 从菜单数据中提取所有允许的路径
@@ -165,7 +175,24 @@ export async function loader({ request }: LoaderFunctionArgs) {
throw new Response("无权访问此页面", { status: 403 });
}
} else {
// 获取路由权限失败,只记录警告,不阻止访问(避免影响正常使用)
// 🔑 检查是否因为认证失败需要重定向到登录页
if (routesResult.shouldRedirectToHome) {
console.error("❌ [Root Loader] 获取用户路由权限失败,可能是令牌已过期,重定向到登录页");
console.error("❌ [Root Loader] 错误详情:", routesResult.error);
// 清除会话并重定向到登录页
const { sessionStorage } = await import("~/api/login/auth.server");
const session = await sessionStorage.getSession(request.headers.get("Cookie"));
const destroyedSession = await sessionStorage.destroySession(session);
return redirect("/login?expired=true", {
headers: {
"Set-Cookie": destroyedSession
}
});
}
// 其他错误,只记录警告,不阻止访问(避免影响正常使用)
console.warn("⚠️ [Root Loader] 获取用户路由权限失败,跳过权限检查");
}
} catch (error) {
+69 -9
View File
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react';
import { useNavigate, Form, useLoaderData } from '@remix-run/react';
import { type MetaFunction, type ActionFunctionArgs, LoaderFunctionArgs, redirect } from "@remix-run/node";
import { type MetaFunction, type ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node";
import styles from "~/styles/pages/home.css?url";
import dayjs from 'dayjs';
import { getUserSession, logout } from "~/api/login/auth.server";
@@ -49,8 +49,21 @@ export async function loader({ request }: LoaderFunctionArgs) {
console.warn('⚠️ [Index Loader] 用户角色为空,返回空模块列表');
}
// 返回用户信息和入口模块给客户端
return Response.json({ userRole, userInfo, entryModules });
// 🔑 检查用户是否有系统设置权限
let hasSettingsAccess = false;
if (userRole && frontendJWT) {
const { getUserRoutesByRole } = await import('~/api/auth/user-routes');
const routesResult = await getUserRoutesByRole(userRole, frontendJWT, true); // includeHidden=true
if (routesResult.success && routesResult.data) {
// 检查是否存在顶级路由 '/settings'
hasSettingsAccess = routesResult.data.some(route => route.path === '/settings');
// console.log(`🔑 [Index Loader] 用户${hasSettingsAccess ? '有' : '没有'}系统设置权限`);
}
}
// 返回用户信息、入口模块和系统设置权限给客户端
return Response.json({ userRole, userInfo, entryModules, hasSettingsAccess });
}
export default function Index() {
@@ -62,7 +75,7 @@ export default function Index() {
});
// 检查是否通过51707端口访问
const [isPort51707, setIsPort51707] = useState(false);
// const [isPort51707, setIsPort51707] = useState(false);
// 用户信息:优先使用服务端返回的,否则从 localStorage 读取
const [userInfo, setUserInfo] = useState(loaderData.userInfo);
@@ -70,7 +83,7 @@ export default function Index() {
useEffect(() => {
if (typeof window !== 'undefined') {
setIsPort51707(window.location.port === '51707');
// setIsPort51707(window.location.port === '51707');
// 如果服务端没有返回用户信息,从 localStorage 读取
if (!loaderData.userInfo || !loaderData.userRole) {
@@ -91,10 +104,21 @@ export default function Index() {
// 打印用户角色
useEffect(() => {
console.log('📋 [Index] 当前用户角色:', userRole);
console.log('👤 [Index] 当前用户信息:', userInfo);
// console.log('📋 [Index] 当前用户角色:', userRole);
// console.log('👤 [Index] 当前用户信息:', userInfo);
}, [userRole, userInfo]);
// 🔑 清除系统设置模式标志(当用户返回首页时)
useEffect(() => {
if (typeof window !== 'undefined') {
const settingsMode = sessionStorage.getItem('settingsMode');
if (settingsMode === 'true') {
sessionStorage.removeItem('settingsMode');
console.log('🔄 [Index] 清除系统设置模式标志');
}
}
}, []); // 只在组件挂载时执行一次
// 更新日期时间
useEffect(() => {
const updateDateTime = () => {
@@ -200,6 +224,21 @@ export default function Index() {
}
};
// 处理进入系统设置
const handleEnterSettings = () => {
if (typeof window !== 'undefined') {
// 🔑 设置标志:表示用户通过系统设置入口进入
sessionStorage.setItem('settingsMode', 'true');
// 清除模块相关的标志(因为不是从入口模块进入)
sessionStorage.removeItem('selectedModuleId');
sessionStorage.removeItem('selectedModuleName');
sessionStorage.removeItem('selectedModulePicPath');
}
// 跳转到系统设置的默认页面
navigate('/rule-groups');
};
return (
<div className="home-page">
{/* 登出表单 - 隐藏 */}
@@ -250,7 +289,8 @@ export default function Index() {
<div className="modules-container">
{/* 动态渲染入口模块 */}
{loaderData.entryModules && loaderData.entryModules.length > 0 ? (
loaderData.entryModules.map((module) => (
<>
{loaderData.entryModules.map((module) => (
<div
key={module.id}
className="module-card"
@@ -267,7 +307,27 @@ export default function Index() {
/>
<span className="module-name">{module.name}</span>
</div>
))
))}
{/* 🔑 系统设置入口 - 只有有权限的用户才能看到 */}
{loaderData.hasSettingsAccess && (
<div
className="module-card"
onClick={handleEnterSettings}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
handleEnterSettings();
}
}}
role="button"
tabIndex={0}
aria-label="系统设置"
>
<i className="ri-settings-4-line text-5xl text-primary"></i>
<span className="module-name"></span>
</div>
)}
</>
) : (
<div className="text-center text-gray-500 py-8">
+5 -3
View File
@@ -145,8 +145,9 @@ export async function loader({ request }: LoaderFunctionArgs) {
}
console.log("✅ [Callback] 用户信息获取成功");
// 获取重定向URL
const redirectTo = url.searchParams.get("redirect") || "/";
// 🔑 强制重定向到首页,确保用户选择入口模块并初始化 sessionStorage
// 忽略 redirect 参数,总是跳转到首页让用户选择模块
const redirectTo = "/";
// 调用后端登录接口,传递 OAuth 用户信息,获取 JWT token
const loginRequest: LoginRequest = {
@@ -271,7 +272,8 @@ export default function Callback() {
// 从 URL 参数中获取 token(如果有)
const token = searchParams.get("token");
const userInfo = searchParams.get("userInfo");
const redirectTo = searchParams.get("redirectTo") || "/";
// 🔑 强制重定向到首页,确保用户选择入口模块并初始化 sessionStorage
const redirectTo = "/";
if (token && typeof window !== 'undefined') {
console.log('🔑 [Callback] 开始保存 token 到 localStorage');
+435
View File
@@ -0,0 +1,435 @@
import { useState, useEffect } from "react";
import { useSearchParams, useNavigate, useLoaderData, useRouteLoaderData } from "@remix-run/react";
import { ActionFunctionArgs, LoaderFunctionArgs, MetaFunction } from "@remix-run/node";
import { Table } from "~/components/ui/Table";
import { Card } from "~/components/ui/Card";
import { Button } from "~/components/ui/Button";
import { Pagination } from "~/components/ui/Pagination";
import { FilterPanel, FilterSelect, SearchFilter } from "~/components/ui/FilterPanel";
import { toastService } from "~/components/ui/Toast";
import {
getEntryModules,
deleteEntryModule,
type EntryModule,
type EntryModuleSearchParams
} from "~/api/entry-modules/entry-modules";
import entryModulesStyles from "~/styles/pages/entry-modules.css?url";
import { DOCUMENT_URL } from "~/config/api-config";
// 引入CSS样式
export function links() {
return [
{ rel: "stylesheet", href: entryModulesStyles }
];
}
// 页面元数据
export const meta: MetaFunction = () => {
return [
{ title: "入口模块管理 - 中国烟草AI合同及卷宗审核系统" },
{ name: "description", content: "管理入口模块,包括查看、编辑和删除入口模块" },
];
};
// 面包屑配置
export const handle = {
breadcrumb: "入口模块管理"
};
// 定义加载器返回的数据类型
interface LoaderData {
modules: EntryModule[];
total: number;
pageSize: number;
currentPage: number;
error?: string;
frontendJWT?: string | null;
}
// 加载函数 - 获取入口模块列表
export async function loader({ request }: LoaderFunctionArgs) {
try {
// 获取用户会话信息
const { getUserSession } = await import("~/api/login/auth.server");
const { frontendJWT } = await getUserSession(request);
const url = new URL(request.url);
const name = url.searchParams.get('name') || undefined;
const area = url.searchParams.get('area') || undefined;
const page = parseInt(url.searchParams.get('page') || '1', 10);
const pageSize = parseInt(url.searchParams.get('pageSize') || '10', 10);
// 构建搜索参数
const searchParams: EntryModuleSearchParams = {
name,
area,
page,
pageSize
};
const modulesResponse = await getEntryModules(searchParams, frontendJWT);
if (modulesResponse.error) {
console.error("获取入口模块失败:", modulesResponse.error);
throw new Error(modulesResponse.error);
}
const modulesResult = modulesResponse.data?.modules || [];
return Response.json({
modules: modulesResult,
total: modulesResponse.data?.total || modulesResult.length,
pageSize,
currentPage: page,
frontendJWT
});
} catch (error) {
console.error("加载入口模块列表失败:", error);
return Response.json(
{
modules: [],
total: 0,
pageSize: 10,
currentPage: 1,
error: error instanceof Error ? error.message : "加载入口模块列表失败"
},
{ status: 500 }
);
}
}
// 动作函数 - 处理删除请求
export async function action({ request }: ActionFunctionArgs) {
// 获取表单数据
const formData = await request.formData();
const id = formData.get("id") as string;
const intent = formData.get("intent") as string;
const { getUserSession } = await import("~/api/login/auth.server");
const { frontendJWT } = await getUserSession(request);
if (intent === "delete" && id) {
try {
const result = await deleteEntryModule(parseInt(id), frontendJWT || undefined);
if (result.error) {
return Response.json({ success: false, error: result.error }, { status: 500 });
}
return Response.json({ success: true });
} catch (error) {
return Response.json(
{ success: false, error: error instanceof Error ? error.message : "删除入口模块失败" },
{ status: 500 }
);
}
}
return Response.json({ success: false, error: "无效的操作" }, { status: 400 });
}
// 地区选项
const AREA_OPTIONS = [
{ value: "", label: "全部地区" },
{ value: "梅州", label: "梅州" },
{ value: "云浮", label: "云浮" },
{ value: "揭阳", label: "揭阳" },
{ value: "潮州", label: "潮州" },
{ value: "省局", label: "省局" }
];
// 入口模块列表组件
export default function EntryModulesList() {
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const [isDeleting, setIsDeleting] = useState(false);
// 获取加载器数据
const { modules, total, error, frontendJWT } = useLoaderData<LoaderData>();
// 获取用户角色并判断权限
const rootData = useRouteLoaderData("root") as { userRole: string };
const userRole = rootData?.userRole || 'common';
const hasEditPermission = userRole.toLowerCase().includes('admin') || userRole.toLowerCase().includes('developer');
// 调试信息
useEffect(() => {
console.log('📋 [EntryModules] 用户角色:', userRole);
console.log('📋 [EntryModules] 是否有编辑权限:', hasEditPermission);
}, [userRole, hasEditPermission]);
// 获取搜索参数
const name = searchParams.get('name') || '';
const area = searchParams.get('area') || '';
const currentPage = parseInt(searchParams.get('page') || String(1), 10);
const pageSize = parseInt(searchParams.get('pageSize') || String(10), 10);
// 处理loader加载数据的时候的错误
useEffect(() => {
if (error) {
toastService.error(error);
}
}, [error]);
// 处理名称搜索
const handleNameSearch = (value: string) => {
const newParams = new URLSearchParams(searchParams);
if (value) {
newParams.set('name', value);
} else {
newParams.delete('name');
}
newParams.set('page', '1');
setSearchParams(newParams);
};
// 处理筛选变更
const handleFilterChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const { name, value } = e.target;
const newParams = new URLSearchParams(searchParams);
if (value) {
newParams.set(name, value);
} else {
newParams.delete(name);
}
// 切换筛选条件时,重置到第一页
newParams.set('page', '1');
setSearchParams(newParams);
};
// 处理重置筛选
const handleReset = () => {
const nameInput = document.querySelector('input[placeholder="请输入入口模块名称"]');
if (nameInput) {
(nameInput as HTMLInputElement).value = '';
}
// 重置所有筛选条件
setSearchParams(new URLSearchParams());
};
// 处理删除入口模块
const handleDelete = async (id: number) => {
if (confirm('确定要删除该入口模块吗?此操作不可撤销。')) {
setIsDeleting(true);
try {
const formData = new FormData();
formData.append('id', id.toString());
formData.append('intent', 'delete');
const response = await fetch('/entry-modules', {
method: 'POST',
body: formData
});
const result = await response.json();
if (result.success) {
toastService.success('删除成功!');
// 刷新页面
window.location.reload();
} else {
toastService.error(`删除失败: ${result.error || '未知错误'}`);
}
} catch (error) {
toastService.error(`删除失败: ${error instanceof Error ? error.message : '未知错误'}`);
} finally {
setIsDeleting(false);
}
}
};
// 处理编辑入口模块
const handleEdit = (id: number) => {
navigate(`/entry-modules/new?id=${id}`);
};
// 处理分页变更
const handlePageChange = (page: number) => {
const newParams = new URLSearchParams(searchParams);
newParams.set('page', page.toString());
setSearchParams(newParams);
};
// 处理每页条数变更
const handlePageSizeChange = (size: number) => {
const newParams = new URLSearchParams(searchParams);
newParams.set('pageSize', size.toString());
newParams.set('page', '1');
setSearchParams(newParams);
};
// 表格列定义
const columns = [
{
key: 'id',
title: 'ID',
width: '80px',
render: (row: EntryModule) => row.id
},
{
key: 'name',
title: '模块名称',
width: '200px',
render: (row: EntryModule) => (
<span className="font-medium text-gray-900">{row.name}</span>
)
},
{
key: 'description',
title: '描述',
width: '250px',
render: (row: EntryModule) => (
<span className="text-gray-600">{row.description || '-'}</span>
)
},
{
key: 'logo',
title: 'Logo图片',
width: '150px',
render: (row: EntryModule) => {
if (!row.path) {
return <span className="text-gray-400"></span>;
}
const logoUrl = `${DOCUMENT_URL}${row.path}`;
return (
<div className="flex items-center">
<img
src={logoUrl}
alt={row.name}
className="h-8 w-8 object-contain rounded"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
(e.target as HTMLImageElement).parentElement!.innerHTML = '<span class="text-red-500">加载失败</span>';
}}
/>
<a
href={logoUrl}
target="_blank"
rel="noopener noreferrer"
className="ml-2 text-blue-600 hover:underline text-sm"
>
</a>
</div>
);
}
},
{
key: 'areas',
title: '适用地区',
width: '200px',
render: (row: EntryModule) => (
<div className="flex flex-wrap gap-1">
{row.areas && row.areas.length > 0 ? (
row.areas.map((area, index) => (
<span
key={index}
className="inline-block px-2 py-1 text-xs bg-blue-100 text-blue-800 rounded"
>
{area}
</span>
))
) : (
<span className="text-gray-400"></span>
)}
</div>
)
},
{
key: 'created_at',
title: '创建时间',
width: '180px',
render: (row: EntryModule) =>
row.created_at ? new Date(row.created_at).toLocaleString('zh-CN') : '-'
},
{
key: 'actions',
title: '操作',
width: '150px',
render: (row: EntryModule) => (
<div className="flex items-center space-x-2">
<Button
type="link"
size="small"
onClick={() => handleEdit(row.id!)}
disabled={!hasEditPermission}
title={hasEditPermission ? "编辑" : "无权限"}
>
</Button>
<Button
type="link"
size="small"
danger
onClick={() => handleDelete(row.id!)}
disabled={isDeleting || !hasEditPermission}
title={hasEditPermission ? "删除" : "无权限"}
>
</Button>
</div>
)
}
];
return (
<div className="entry-modules-page">
<Card>
{/* 页面头部 */}
<div className="page-header">
<div>
<h1 className="text-2xl font-bold text-gray-900"></h1>
<p className="text-sm text-gray-600 mt-1">Logo图片和适用地区设置</p>
</div>
{hasEditPermission && (
<Button
type="primary"
icon="ri-add-line"
to="/entry-modules/new"
>
</Button>
)}
</div>
{/* 筛选面板 */}
<FilterPanel onReset={handleReset}>
<SearchFilter
placeholder="请输入入口模块名称"
defaultValue={name}
onSearch={handleNameSearch}
/>
<FilterSelect
label="适用地区"
name="area"
value={area}
options={AREA_OPTIONS}
onChange={handleFilterChange}
/>
</FilterPanel>
{/* 表格 */}
<Table
columns={columns}
data={modules || []}
loading={false}
emptyText="暂无入口模块数据"
/>
{/* 分页 */}
{total > 0 && (
<Pagination
current={currentPage}
pageSize={pageSize}
total={total}
onPageChange={handlePageChange}
onPageSizeChange={handlePageSizeChange}
/>
)}
</Card>
</div>
);
}
+409
View File
@@ -0,0 +1,409 @@
import { useState, useEffect, useRef } from "react";
import { useNavigate, useSearchParams, useLoaderData } from "@remix-run/react";
import { ActionFunctionArgs, LoaderFunctionArgs, MetaFunction } from "@remix-run/node";
import { Card } from "~/components/ui/Card";
import { Button } from "~/components/ui/Button";
import { toastService } from "~/components/ui/Toast";
import { Modal } from "~/components/ui/Modal";
import {
getEntryModuleById,
createEntryModule,
updateEntryModule,
type EntryModule
} from "~/api/entry-modules/entry-modules";
import { API_BASE_URL, DOCUMENT_URL } from "~/config/api-config";
// 页面元数据
export const meta: MetaFunction = () => {
return [
{ title: "入口模块编辑 - 中国烟草AI合同及卷宗审核系统" },
{ name: "description", content: "创建或编辑入口模块" },
];
};
export const handle = {
breadcrumb: "新建/编辑入口模块",
previousRoute: {
title: "入口模块管理",
to: "/entry-modules"
}
};
// 定义加载器返回的数据类型
interface LoaderData {
module?: EntryModule;
error?: string;
frontendJWT?: string | null;
}
// 加载函数 - 获取入口模块数据(编辑模式)
export async function loader({ request }: LoaderFunctionArgs) {
try {
const { getUserSession } = await import("~/api/login/auth.server");
const { frontendJWT } = await getUserSession(request);
const url = new URL(request.url);
const id = url.searchParams.get('id');
if (id) {
const moduleResponse = await getEntryModuleById(parseInt(id), frontendJWT);
if (moduleResponse.error) {
throw new Error(moduleResponse.error);
}
return Response.json({
module: moduleResponse.data,
frontendJWT
});
}
return Response.json({ frontendJWT });
} catch (error) {
console.error("加载入口模块失败:", error);
return Response.json(
{
error: error || "加载入口模块失败",
status: 500
}
);
}
}
// 地区选项
const AREA_OPTIONS = [
{ value: "梅州", label: "梅州" },
{ value: "云浮", label: "云浮" },
{ value: "揭阳", label: "揭阳" },
{ value: "潮州", label: "潮州" },
{ value: "省局", label: "省局" }
];
// 入口模块新建/编辑组件
export default function EntryModuleNew() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { module, error, frontendJWT } = useLoaderData<LoaderData>();
const id = searchParams.get('id');
const isEditMode = !!id;
// 表单状态
const [name, setName] = useState(module?.name || '');
const [description, setDescription] = useState(module?.description || '');
const [selectedAreas, setSelectedAreas] = useState<string[]>(module?.areas || []);
const [logoFile, setLogoFile] = useState<File | null>(null);
const [logoPreview, setLogoPreview] = useState<string | null>(
module?.path ? `${DOCUMENT_URL}${module.path}` : null
);
const [isSubmitting, setIsSubmitting] = useState(false);
const [showConfirmModal, setShowConfirmModal] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// 处理loader加载数据的时候的错误
useEffect(() => {
if (error) {
toastService.error(error);
}
}, [error]);
// 处理logo文件选择
const handleLogoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
// 验证文件类型
if (!file.type.startsWith('image/')) {
toastService.error('请选择图片文件');
return;
}
// 验证文件大小(限制5MB
if (file.size > 5 * 1024 * 1024) {
toastService.error('图片大小不能超过5MB');
return;
}
setLogoFile(file);
// 生成预览
const reader = new FileReader();
reader.onload = (event) => {
setLogoPreview(event.target?.result as string);
};
reader.readAsDataURL(file);
}
};
// 处理地区选择
const handleAreaToggle = (area: string) => {
setSelectedAreas(prev => {
if (prev.includes(area)) {
return prev.filter(a => a !== area);
} else {
return [...prev, area];
}
});
};
// 验证表单
const validateForm = () => {
if (!name.trim()) {
toastService.error('请输入模块名称');
return false;
}
if (selectedAreas.length === 0) {
toastService.error('请至少选择一个适用地区');
return false;
}
return true;
};
// 上传logo图片
const uploadLogo = async (): Promise<string | null> => {
if (!logoFile) return module?.path || null;
try {
const formData = new FormData();
formData.append('file', logoFile);
formData.append('folder', 'entryModule');
const response = await fetch(`${API_BASE_URL}/admin/upload`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${frontendJWT}`
},
body: formData
});
if (!response.ok) {
throw new Error('图片上传失败');
}
const result = await response.json();
console.log('图片上传结果:', result);
// 根据后端返回的数据结构提取路径
if (result.data?.path) {
return result.data.path;
} else if (result.path) {
return result.path;
} else {
throw new Error('未获取到图片路径');
}
} catch (error) {
console.error('上传logo失败:', error);
throw error;
}
};
// 处理表单提交
const handleSubmit = async () => {
if (!validateForm()) return;
setIsSubmitting(true);
try {
// 上传logo
let logoPath = module?.path || null;
if (logoFile) {
logoPath = await uploadLogo();
}
const moduleData = {
name: name.trim(),
description: description.trim() || undefined,
path: logoPath,
areas: selectedAreas
};
let result;
if (isEditMode) {
result = await updateEntryModule(parseInt(id!), moduleData, frontendJWT);
} else {
result = await createEntryModule(moduleData, frontendJWT);
}
if (result.error) {
toastService.error(result.error);
return;
}
toastService.success(isEditMode ? '更新成功!' : '创建成功!');
setTimeout(() => {
navigate('/entry-modules');
}, 1000);
} catch (error) {
console.error('提交失败:', error);
toastService.error(error instanceof Error ? error.message : '操作失败,请重试');
} finally {
setIsSubmitting(false);
}
};
// 处理取消
const handleCancel = () => {
setShowConfirmModal(true);
};
// 确认取消
const confirmCancel = () => {
navigate('/entry-modules');
};
return (
<div className="entry-modules-new-page">
<Card>
{/* 页面头部 */}
<div className="page-header">
<div>
<h1 className="text-2xl font-bold text-gray-900">
{isEditMode ? '编辑入口模块' : '新建入口模块'}
</h1>
<p className="text-sm text-gray-600 mt-1">
{isEditMode ? '修改入口模块信息' : '创建新的入口模块'}
</p>
</div>
</div>
{/* 表单内容 */}
<div className="form-content space-y-6 mt-6">
{/* 模块名称 */}
<div className="form-item">
<label className="form-label">
<span className="text-red-500 mr-1">*</span>
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="请输入模块名称,如:合同管理"
maxLength={255}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
{/* 描述 */}
<div className="form-item">
<label className="form-label"></label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="请输入模块描述"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
rows={4}
/>
</div>
{/* Logo图片上传 */}
<div className="form-item">
<label className="form-label">Logo图片</label>
<div className="space-y-3">
<div className="flex items-center space-x-3">
<Button
type="default"
icon="ri-upload-line"
onClick={() => fileInputRef.current?.click()}
>
{logoPreview ? '更换图片' : '上传图片'}
</Button>
<span className="text-sm text-gray-500">
JPGPNGGIF 5MB
</span>
</div>
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleLogoChange}
className="hidden"
/>
{logoPreview && (
<div className="mt-3">
<div className="inline-block border border-gray-300 rounded p-2">
<img
src={logoPreview}
alt="Logo预览"
className="h-24 w-24 object-contain"
/>
</div>
</div>
)}
</div>
</div>
{/* 适用地区 */}
<div className="form-item">
<label className="form-label">
<span className="text-red-500 mr-1">*</span>
</label>
<div className="flex flex-wrap gap-3">
{AREA_OPTIONS.map(option => (
<label
key={option.value}
className="flex items-center space-x-2 cursor-pointer"
>
<input
type="checkbox"
checked={selectedAreas.includes(option.value)}
onChange={() => handleAreaToggle(option.value)}
className="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
/>
<span className="text-sm text-gray-700">{option.label}</span>
</label>
))}
</div>
</div>
</div>
{/* 操作按钮 */}
<div className="form-actions mt-8 flex justify-end space-x-3">
<Button
type="default"
onClick={handleCancel}
disabled={isSubmitting}
>
</Button>
<Button
type="primary"
onClick={handleSubmit}
loading={isSubmitting}
disabled={isSubmitting}
>
{isSubmitting ? '提交中...' : (isEditMode ? '保存' : '创建')}
</Button>
</div>
</Card>
{/* 取消确认模态框 */}
<Modal
isOpen={showConfirmModal}
onClose={() => setShowConfirmModal(false)}
title="确认取消"
size="small"
footer={
<div className="flex justify-end space-x-3">
<Button
type="default"
onClick={() => setShowConfirmModal(false)}
>
</Button>
<Button
type="primary"
danger
onClick={confirmCancel}
>
</Button>
</div>
}
>
<p className="text-gray-700"></p>
</Modal>
</div>
);
}
+9
View File
@@ -0,0 +1,9 @@
import { Outlet } from "@remix-run/react";
/**
* 入口模块管理父路由
* 用于包裹子路由(列表页和新建/编辑页)
*/
export default function EntryModulesLayout() {
return <Outlet />;
}
+312 -121
View File
@@ -7,9 +7,9 @@ import { FileTag, links as fileTagLinks } from "~/components/ui/FileTag";
// import { FileTypeTag, links as fileTypeTagLinks } from "~/components/ui/FileTypeTag";
import { Tag } from "~/components/ui/Tag";
import homeStyles from "~/styles/pages/sys_overview.css?url";
import { getDocuments, type DocumentUI, type DocumentSearchParams } from "~/api/files/documents";
import { getDocumentsListFromAPI, type DocumentUI } from "~/api/files/documents";
import { useState, useEffect } from "react";
import { getHomeData } from "~/api/home/home";
import { getHomeData, getTopErrorPoints, getTopRiskUsers, type TopErrorPointsResponse, type TopRiskUsersResponse } from "~/api/home/home";
import dayjs from 'dayjs';
// import type { UserRole } from '~/api/login/auth.server';
import { type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/node";
@@ -69,7 +69,6 @@ export async function loader({ request }: LoaderFunctionArgs) {
issuesGrowth: { value: 0, isUp: true }
},
recentFiles: [],
reviewType: null,
userRole: userRole,
userInfo,
frontendJWT
@@ -105,17 +104,18 @@ export default function Home() {
date: '',
time: ''
});
const [isLoading, setIsLoading] = useState(true);
// const userRole = serverUserRole as UserRole;
// 🔑 防御性检查:如果 userInfo 不存在,重定向到登录页(理论上不应该发生,因为 loader 已经检查了)
if (!userInfo) {
console.error("❌ [Home] userInfo 不存在,重定向到登录页");
if (typeof window !== 'undefined') {
window.location.href = '/login';
}
return null;
}
// 独立的loading状态管理
const [loadingStates, setLoadingStates] = useState({
stats: true, // 统计信息
recentFiles: true, // 最近文档
errorPoints: true, // 高频错误评查点
riskUsers: true // 高风险用户
});
// 统计数据状态(初始时标记为不可用,加载后根据API响应更新)
const [topErrorPoints, setTopErrorPoints] = useState<TopErrorPointsResponse>({ available: false, total: 0, items: [] });
const [topRiskUsers, setTopRiskUsers] = useState<TopRiskUsersResponse>({ available: false, total: 0, items: [] });
// 打印服务器端传递的用户角色
useEffect(() => {
@@ -148,8 +148,7 @@ export default function Home() {
// 清除sessionStorage中的所有数据
if (typeof window !== 'undefined') {
sessionStorage.removeItem('userRole');
sessionStorage.removeItem('reviewType');
sessionStorage.removeItem('previousReviewType');
sessionStorage.removeItem('documentTypeIds');
sessionStorage.removeItem('frontendJWT');
sessionStorage.removeItem('userInfo');
sessionStorage.removeItem('accessToken');
@@ -168,82 +167,122 @@ export default function Home() {
}
};
// 在客户端挂载时,根据 sessionStorage 中的 reviewType 加载正确的数据
// 在客户端挂载时,根据 sessionStorage 中的 documentTypeIds 加载正确的数据
useEffect(() => {
const loadData = async () => {
try {
setIsLoading(true);
// 从 sessionStorage 获取 reviewType
const reviewType = sessionStorage.getItem('reviewType');
// 从 sessionStorage 获取 documentTypeIds
const documentTypeIdsStr = sessionStorage.getItem('documentTypeIds');
const documentTypeIds = documentTypeIdsStr ? JSON.parse(documentTypeIdsStr) : [];
// 加载主页数据
const newHomeData = await getHomeData(reviewType || undefined,userInfo.user_id, frontendJWT);
// 从 documentTypeIds 推断 reviewType(用于 getHomeData
const reviewType = inferReviewType(documentTypeIds);
// 并行加载所有数据,每个数据加载完成后立即更新对应的loading状态
await Promise.all([
// 加载统计信息
(async () => {
try {
const newHomeData = await getHomeData(reviewType, userInfo.user_id, frontendJWT);
setHomeData(newHomeData);
setLoadingStates(prev => ({ ...prev, stats: false }));
} catch (error) {
console.error('加载统计信息失败:', error);
setLoadingStates(prev => ({ ...prev, stats: false }));
}
})(),
// 加载文档数据
const docs = await loadDocuments(reviewType);
// 加载最近文档
(async () => {
try {
const docs = await loadDocuments(documentTypeIds);
setRecentFiles(docs);
setLoadingStates(prev => ({ ...prev, recentFiles: false }));
} catch (error) {
console.error('加载最近文档失败:', error);
setLoadingStates(prev => ({ ...prev, recentFiles: false }));
}
})(),
setIsLoading(false);
// 加载高频错误评查点
(async () => {
try {
const errorPointsData = await getTopErrorPoints(10, undefined, undefined, documentTypeIds, frontendJWT);
setTopErrorPoints(errorPointsData);
setLoadingStates(prev => ({ ...prev, errorPoints: false }));
} catch (error) {
console.error('加载高频错误评查点失败:', error);
setLoadingStates(prev => ({ ...prev, errorPoints: false }));
}
})(),
// 加载高风险用户
(async () => {
try {
const riskUsersData = await getTopRiskUsers(5, undefined, undefined, documentTypeIds, frontendJWT);
setTopRiskUsers(riskUsersData);
setLoadingStates(prev => ({ ...prev, riskUsers: false }));
} catch (error) {
console.error('加载高风险用户失败:', error);
setLoadingStates(prev => ({ ...prev, riskUsers: false }));
}
})()
]);
} catch (error) {
console.error('加载数据失败:', error);
setIsLoading(false);
// 确保所有loading状态都被重置
setLoadingStates({
stats: false,
recentFiles: false,
errorPoints: false,
riskUsers: false
});
}
};
loadData();
}, []); // 仅在组件挂载时执行一次
// 加载文档数据的函数
const loadDocuments = async (reviewType: string | null) => {
try {
const documentSearchParams: DocumentSearchParams = {
page: 1,
pageSize: 10,
userId: userInfo.user_id,
token: frontendJWT || undefined
// 从 documentTypeIds 推断 reviewType(用于 getHomeData API
const inferReviewType = (documentTypeIds: number[]): string | null => {
if (!documentTypeIds || documentTypeIds.length === 0) return null;
if (documentTypeIds.includes(1)) return 'contract';
if (documentTypeIds.includes(2) || documentTypeIds.includes(3)) return 'record';
return null;
};
// 根据 reviewType 添加过滤条件
if (reviewType === 'contract') {
documentSearchParams.documentType = '1';
const response = await getDocuments(documentSearchParams);
if (!response.error && response.data) {
// console.log('合同文档数据',response.data.documents);
return response.data.documents;
// 加载文档数据的函数
const loadDocuments = async (documentTypeIds: number[]) => {
try {
if (!frontendJWT) {
console.error('缺少 JWT token');
return [];
}
} else if (reviewType === 'record') {
// 获取类型 2 的文档
const response1 = await getDocuments({
...documentSearchParams,
documentType: '2'
const baseParams = {
page: 1,
pageSize: 10,
token: frontendJWT
};
// 直接使用 documentTypeIds 查询
if (documentTypeIds && documentTypeIds.length > 0) {
const response = await getDocumentsListFromAPI({
...baseParams,
documentTypeIds: documentTypeIds
});
// 获取类型 3 的文档
const response2 = await getDocuments({
...documentSearchParams,
documentType: '3'
});
if (!response1.error && !response2.error && response1.data && response2.data) {
// 合并文档并排序
const mergedDocs = [...response1.data.documents, ...response2.data.documents];
mergedDocs.sort((a, b) =>
new Date(b.updatedAt || '').getTime() - new Date(a.updatedAt || '').getTime()
);
// 限制数量
// console.log('卷宗文档数据',mergedDocs);
return mergedDocs.slice(0, documentSearchParams.pageSize);
if (!response.error && response.data) {
return response.data.documents;
}
} else {
// 没有定类型,获取所有文档
const response = await getDocuments(documentSearchParams);
// 没有定类型,获取所有文档
const response = await getDocumentsListFromAPI(baseParams);
if (!response.error && response.data) {
return response.data.documents;
}
}
return []; // 默认返回空数组
} catch (error) {
console.error('加载文档数据失败:', error);
@@ -251,63 +290,28 @@ export default function Home() {
}
};
// 监听 sessionStorage 中 reviewType 的变化
useEffect(() => {
const handleStorageChange = async () => {
const currentReviewType = sessionStorage.getItem('reviewType');
const previousReviewType = sessionStorage.getItem('previousReviewType');
// 如果 reviewType 发生变化
if (currentReviewType !== previousReviewType) {
setIsLoading(true);
// 更新主页数据
const newHomeData = await getHomeData(currentReviewType || undefined,userInfo.user_id, frontendJWT);
setHomeData(newHomeData);
// 更新文档数据
const docs = await loadDocuments(currentReviewType);
setRecentFiles(docs);
// 保存当前 reviewType 为上一次的值,用于比较
sessionStorage.setItem('previousReviewType', currentReviewType || '');
setIsLoading(false);
}
};
// 设置初始的 previousReviewType
const initialReviewType = sessionStorage.getItem('reviewType');
sessionStorage.setItem('previousReviewType', initialReviewType || '');
// 设置定期检查
const checkInterval = setInterval(handleStorageChange, 1000);
return () => {
clearInterval(checkInterval);
};
}, []);
// 修改useEffect定时器,每10秒自动获取最近文档数据
// 按照定时器更新最近文档
useEffect(() => {
// 避免在加载状态下进行自动更新
if (isLoading) return;
// useEffect(() => {
// // 避免在加载状态下进行自动更新
// if (loadingStates.recentFiles) return;
const fetchLatestDocuments = async () => {
const reviewType = sessionStorage.getItem('reviewType');
const docs = await loadDocuments(reviewType);
setRecentFiles(docs);
};
// const fetchLatestDocuments = async () => {
// const documentTypeIdsStr = sessionStorage.getItem('documentTypeIds');
// const documentTypeIds = documentTypeIdsStr ? JSON.parse(documentTypeIdsStr) : [];
// const docs = await loadDocuments(documentTypeIds);
// setRecentFiles(docs);
// };
// 设置10秒的定时器
const timerID = setInterval(fetchLatestDocuments, 10000);
// // 设置10秒的定时器
// const timerID = setInterval(fetchLatestDocuments, 10000);
// 组件卸载时清除定时器
return () => {
clearInterval(timerID);
};
}, [isLoading]); // 仅依赖 isLoading 状态
// // 组件卸载时清除定时器
// return () => {
// clearInterval(timerID);
// };
// }, [loadingStates.recentFiles]); // 仅依赖最近文档的loading状态
return (
<div className="dashboard-container">
@@ -349,8 +353,11 @@ export default function Home() {
</div>
{/* 统计卡片区域 */}
<Card title="统计信息" icon="ri-bar-chart-line" className="mt-6 transition-all duration-200 hover:shadow-[0_4px_15px_rgba(0,0,0,0.1)]">
<div className="stat-grid ">
<Card title="统计信息" icon="ri-bar-chart-line" className="transition-all duration-200 hover:shadow-[0_4px_15px_rgba(0,0,0,0.1)]">
{loadingStates.stats ? (
<LoadingSkeleton type="stats" />
) : (
<div className="stat-grid">
<StatCard
title="今日待审文件"
value={homeData.todayPendingFiles}
@@ -375,27 +382,143 @@ export default function Home() {
trend={{ value: homeData.issuesGrowth.value, isUp: homeData.issuesGrowth.isUp }}
/>
</div>
)}
</Card>
{/* 快捷访问区域 */}
<Card title="快捷访问" icon="ri-speed-line" className="mt-6 transition-all duration-200 hover:shadow-[0_4px_15px_rgba(0,0,0,0.1)]">
{/* <Card title="快捷访问" icon="ri-speed-line" className="mt-6 transition-all duration-200 hover:shadow-[0_4px_15px_rgba(0,0,0,0.1)]">
<div className="shortcut-grid">
<ShortcutItem icon="ri-upload-cloud-line" label="上传文件" to="/files/upload" />
<ShortcutItem icon="ri-file-list-3-line" label="文档列表" to="/documents/list" />
<ShortcutItem icon="ri-list-check-3" label="评查点列表" to="/rules" />
<ShortcutItem icon="ri-folder-open-line" label="评查点分组" to="/rule-groups" />
</div>
</Card> */}
{/* 高频错误评查点 */}
{topErrorPoints.available && (
<Card
title="高频错误评查点 Top 10"
icon="ri-error-warning-line"
className="mt-6"
>
{loadingStates.errorPoints ? (
<LoadingSkeleton type="table" rows={5} />
) : topErrorPoints.total > 0 ? (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200">
<th className="py-3 px-4 text-left font-medium text-gray-700"></th>
<th className="py-3 px-4 text-left font-medium text-gray-700"></th>
<th className="py-3 px-4 text-right font-medium text-gray-700"></th>
</tr>
</thead>
<tbody>
{topErrorPoints.items.map((item) => (
<tr key={item.evaluation_point_id} className="border-b border-gray-100 hover:bg-gray-50">
<td className="py-3 px-4">
<span className={`inline-flex items-center justify-center w-6 h-6 rounded-full text-xs font-medium ${
item.rank === 1 ? 'bg-red-100 text-red-800' :
item.rank === 2 ? 'bg-orange-100 text-orange-800' :
item.rank === 3 ? 'bg-yellow-100 text-yellow-800' :
'bg-gray-100 text-gray-600'
}`}>
{item.rank}
</span>
</td>
<td className="py-3 px-4 text-gray-900">{item.point_name}</td>
<td className="py-3 px-4 text-right">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
<i className="ri-user-line mr-1"></i>
{item.error_user_count}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="text-center py-8 text-gray-500">
<i className="ri-error-warning-line text-4xl mb-2"></i>
<p></p>
</div>
)}
</Card>
)}
{/* 高风险用户 */}
{topRiskUsers.available && (
<Card
title="高风险用户 Top 5"
icon="ri-shield-user-line"
className="mt-6"
>
{loadingStates.riskUsers ? (
<LoadingSkeleton type="table" rows={5} />
) : topRiskUsers.total > 0 ? (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200">
<th className="py-3 px-4 text-left font-medium text-gray-700"></th>
<th className="py-3 px-4 text-left font-medium text-gray-700"></th>
<th className="py-3 px-4 text-left font-medium text-gray-700"></th>
<th className="py-3 px-4 text-right font-medium text-gray-700"></th>
<th className="py-3 px-4 text-right font-medium text-gray-700"></th>
</tr>
</thead>
<tbody>
{topRiskUsers.items.map((item) => (
<tr key={item.user_id} className="border-b border-gray-100 hover:bg-gray-50">
<td className="py-3 px-4">
<span className={`inline-flex items-center justify-center w-6 h-6 rounded-full text-xs font-medium ${
item.rank === 1 ? 'bg-red-100 text-red-800' :
item.rank === 2 ? 'bg-orange-100 text-orange-800' :
item.rank === 3 ? 'bg-yellow-100 text-yellow-800' :
'bg-gray-100 text-gray-600'
}`}>
{item.rank}
</span>
</td>
<td className="py-3 px-4 text-gray-900">{item.user_name}</td>
<td className="py-3 px-4 text-gray-600">{item.department}</td>
<td className="py-3 px-4 text-right">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-orange-100 text-orange-800">
{item.total_errors}
</span>
</td>
<td className="py-3 px-4 text-right">
<span className="text-gray-600">{item.avg_errors_per_doc.toFixed(2)}</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="text-center py-8 text-gray-500">
<i className="ri-shield-user-line text-4xl mb-2"></i>
<p></p>
</div>
)}
</Card>
)}
{/* 最近文档区域 */}
<Card
title="最近文档"
icon="ri-file-list-3-line"
extra={<Button to="/documents/list" size="small"></Button>}
extra={!loadingStates.recentFiles && <Button to="/documents/list" size="small"></Button>}
className="mt-6"
>
{loadingStates.recentFiles ? (
<LoadingSkeleton type="list" rows={5} />
) : (
<div className="doc-list">
{recentFiles.map((file: DocumentUI) => (
{recentFiles.length > 0 ? (
recentFiles.map((file: DocumentUI) => (
<div key={file.id} className="doc-item">
<div className="doc-info">
<FileTag
@@ -432,9 +555,18 @@ export default function Home() {
})()}
</div>
</div>
))}
))
) : (
<div className="text-center py-8 text-gray-500">
<i className="ri-file-list-3-line text-4xl mb-2"></i>
<p></p>
</div>
)}
</div>
)}
</Card>
</div>
);
}
@@ -486,3 +618,62 @@ function ShortcutItem({ icon, label, to }: ShortcutItemProps) {
</Button>
);
}
// Loading骨架屏组件
interface LoadingSkeletonProps {
type?: 'stats' | 'table' | 'list';
rows?: number;
}
function LoadingSkeleton({ type = 'list', rows = 3 }: LoadingSkeletonProps) {
if (type === 'stats') {
return (
<div className="stat-grid">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="stat-card animate-pulse">
<div className="h-4 bg-gray-200 rounded w-3/4 mb-4"></div>
<div className="h-8 bg-gray-200 rounded w-1/2 mb-2"></div>
<div className="h-3 bg-gray-200 rounded w-2/3"></div>
</div>
))}
</div>
);
}
if (type === 'table') {
return (
<div className="animate-pulse space-y-3">
{/* 表头 */}
<div className="flex gap-4 pb-3 border-b border-gray-200">
{[1, 2, 3].map((i) => (
<div key={i} className="h-4 bg-gray-200 rounded flex-1"></div>
))}
</div>
{/* 表格行 */}
{Array.from({ length: rows }).map((_, i) => (
<div key={i} className="flex gap-4 py-3 border-b border-gray-100">
{[1, 2, 3].map((j) => (
<div key={j} className="h-4 bg-gray-200 rounded flex-1"></div>
))}
</div>
))}
</div>
);
}
// 默认列表类型
return (
<div className="animate-pulse space-y-4">
{Array.from({ length: rows }).map((_, i) => (
<div key={i} className="flex items-center gap-4 p-3 border border-gray-100 rounded">
<div className="h-10 w-10 bg-gray-200 rounded"></div>
<div className="flex-1 space-y-2">
<div className="h-4 bg-gray-200 rounded w-3/4"></div>
<div className="h-3 bg-gray-200 rounded w-1/2"></div>
</div>
<div className="h-6 w-20 bg-gray-200 rounded-full"></div>
</div>
))}
</div>
);
}
+2 -1
View File
@@ -79,7 +79,8 @@ export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const username = formData.get("username") as string;
const password = formData.get("password") as string;
const redirectTo = formData.get("redirectTo") as string || "/";
// 🔑 强制重定向到首页,确保用户选择入口模块并初始化 sessionStorage
const redirectTo = "/";
// 验证输入
if (!username?.trim()) {
+655
View File
@@ -0,0 +1,655 @@
/**
* Monaco Editor 差异对比演示页面
*
* 功能:
* - 展示两份合同文本的差异对比
* - 支持逐行高亮显示差异
* - 提供差异导航功能
* - 后续可扩展文件上传功能
*/
import { type MetaFunction } from "@remix-run/node";
import { useState, useRef, useEffect } from "react";
import { DiffEditor } from "@monaco-editor/react";
import type { editor } from "monaco-editor";
import { pdfjs } from 'react-pdf';
import { toastService } from '~/components/ui/Toast';
// 设置 PDF.js worker(与 pdf-demo.tsx 相同)
pdfjs.GlobalWorkerOptions.workerSrc = '/pdf.worker.js';
export const meta: MetaFunction = () => {
return [
{ title: "Monaco Diff Editor 演示 - 合同对比" },
{ name: "description", content: "使用 Monaco Editor 进行合同文本差异对比" }
];
};
// PDF 类型枚举
type PdfType = 'text' | 'scanned' | 'unknown';
// PDF 信息接口
interface PdfInfo {
type: PdfType;
numPages: number;
textLength: number;
confidence: number; // 文本提取置信度 (0-1)
}
// 示例合同文本 A(原始版本)
const CONTRACT_A = `中国烟草合同(原始版本)
第一条 合同双方
甲方:中国烟草总公司广东省公司
乙方:XX供应商有限公司
第二条 合同标的
甲方向乙方采购烟草包装材料,具体型号为:
1. 硬盒包装纸 10000箱
2. 烟用滤棒 5000箱
总金额:人民币壹佰万元整(¥1,000,000.00
第三条 交付时间
乙方应在签订合同后30个工作日内完成全部交付。
第四条 质量标准
产品应符合国家烟草行业标准 YC/T 207-2014。
第五条 付款方式
甲方在收到货物并验收合格后,于15个工作日内支付全部款项。
第六条 违约责任
1. 乙方延期交付,每延迟一天支付合同总额0.5%的违约金。
2. 产品质量不合格,乙方应无偿更换并承担相应损失。
第七条 争议解决
本合同履行过程中发生的争议,由双方协商解决;协商不成的,提交广州仲裁委员会仲裁。
第八条 其他约定
本合同一式两份,甲乙双方各执一份,具有同等法律效力。
签订日期:2024年1月15日
`;
// 示例合同文本 B(修改版本)
const CONTRACT_B = `中国烟草合同(修订版本)
第一条 合同双方
甲方:中国烟草总公司广东省公司
乙方:XX供应商有限公司
第二条 合同标的
甲方向乙方采购烟草包装材料,具体型号为:
1. 硬盒包装纸 15000箱(数量增加)
2. 烟用滤棒 5000箱
3. 商标纸 3000箱(新增项目)
总金额:人民币壹佰伍拾万元整(¥1,500,000.00
第三条 交付时间
乙方应在签订合同后45个工作日内完成全部交付。
如遇不可抗力,交付时间可顺延,但乙方应及时通知甲方。
第四条 质量标准
产品应符合国家烟草行业标准 YC/T 207-2014 及甲方企业标准。
第五条 付款方式
1. 签订合同后,甲方支付合同总额30%作为预付款;
2. 收到货物并验收合格后,于15个工作日内支付剩余70%款项。
第六条 违约责任
1. 乙方延期交付,每延迟一天支付合同总额1%的违约金(违约金比例提高)。
2. 产品质量不合格,乙方应无偿更换并承担相应损失。
3. 甲方延期付款,每延迟一天支付未付款项0.05%的违约金。
第七条 保密条款(新增)
双方应对合同内容及执行过程中获悉的商业秘密承担保密义务,保密期限为合同终止后2年。
第八条 争议解决
本合同履行过程中发生的争议,由双方协商解决;协商不成的,提交广州仲裁委员会仲裁。
第九条 其他约定
本合同一式两份,甲乙双方各执一份,具有同等法律效力。
签订日期:2024年3月20日
`;
export default function MonacoDemoPage() {
const [originalText, setOriginalText] = useState(CONTRACT_A);
const [modifiedText, setModifiedText] = useState(CONTRACT_B);
const diffEditorRef = useRef<editor.IStandaloneDiffEditor | null>(null);
const [diffCount, setDiffCount] = useState<number>(0);
const [currentDiff, setCurrentDiff] = useState<number>(0);
// PDF相关状态
const [pdf1Url, setPdf1Url] = useState<string>('');
const [pdf2Url, setPdf2Url] = useState<string>('');
const [pdf1Info, setPdf1Info] = useState<PdfInfo | null>(null);
const [pdf2Info, setPdf2Info] = useState<PdfInfo | null>(null);
const [isLoadingPdf1, setIsLoadingPdf1] = useState(false);
const [isLoadingPdf2, setIsLoadingPdf2] = useState(false);
const [useExample, setUseExample] = useState(true);
// PDF类型检测函数
const detectPdfType = async (pdfUrl: string): Promise<PdfInfo> => {
const loadingTask = pdfjs.getDocument(pdfUrl);
const pdf = await loadingTask.promise;
let totalTextLength = 0;
const pagesToCheck = Math.min(pdf.numPages, 3); // 检查前3页
for (let i = 1; i <= pagesToCheck; i++) {
const page = await pdf.getPage(i);
const textContent = await page.getTextContent();
const pageText = textContent.items
.map((item: any) => item.str)
.join('');
totalTextLength += pageText.length;
}
// 计算平均每页文字数量
const avgTextPerPage = totalTextLength / pagesToCheck;
// 计算置信度(0-1
const confidence = Math.min(avgTextPerPage / 500, 1);
// 判断PDF类型
let type: PdfType;
if (avgTextPerPage > 100) {
type = 'text';
} else if (avgTextPerPage > 10) {
type = 'scanned';
} else {
type = 'unknown';
}
return {
type,
numPages: pdf.numPages,
textLength: totalTextLength,
confidence
};
};
// PDF文本提取函数
const extractTextFromPdf = async (pdfUrl: string): Promise<string> => {
const loadingTask = pdfjs.getDocument(pdfUrl);
const pdf = await loadingTask.promise;
let fullText = '';
for (let i = 1; i <= pdf.numPages; i++) {
const page = await pdf.getPage(i);
const textContent = await page.getTextContent();
const pageText = textContent.items
.map((item: any) => item.str)
.join(' ');
fullText += `\n========== 第 ${i} 页 ==========\n${pageText}\n`;
}
return fullText;
};
// 加载PDF并提取文本
const loadPdfAndExtractText = async (pdfUrl: string, setPdfInfo: (info: PdfInfo | null) => void, setLoading: (loading: boolean) => void, setTextContent: (text: string) => void) => {
try {
setLoading(true);
// 1. 检测PDF类型
const pdfInfo = await detectPdfType(pdfUrl);
setPdfInfo(pdfInfo);
// 2. 提取文本
if (pdfInfo.type === 'text') {
const text = await extractTextFromPdf(pdfUrl);
setTextContent(text);
toastService.success(`PDF加载成功!共 ${pdfInfo.numPages} 页,提取了 ${pdfInfo.textLength} 个字符`);
} else if (pdfInfo.type === 'scanned') {
toastService.warning('检测到扫描版PDF,文本提取质量可能较低');
const text = await extractTextFromPdf(pdfUrl);
setTextContent(text);
} else {
toastService.error('无法识别PDF类型,可能是图片PDF');
setTextContent('');
}
} catch (error) {
console.error('PDF加载失败:', error);
toastService.error('PDF加载失败,请检查文件路径');
setPdfInfo(null);
setTextContent('');
} finally {
setLoading(false);
}
};
// Monaco Editor 挂载后的回调
const handleEditorDidMount = (editor: editor.IStandaloneDiffEditor) => {
diffEditorRef.current = editor;
// 获取差异数量
const lineChanges = editor.getLineChanges();
if (lineChanges) {
setDiffCount(lineChanges.length);
console.log(`发现 ${lineChanges.length} 处差异:`, lineChanges);
}
};
// 跳转到下一个差异
const goToNextDiff = () => {
if (!diffEditorRef.current) return;
const lineChanges = diffEditorRef.current.getLineChanges();
if (!lineChanges || lineChanges.length === 0) return;
const nextIndex = (currentDiff + 1) % lineChanges.length;
const nextChange = lineChanges[nextIndex];
// 跳转到差异位置(修改后的编辑器)
const modifiedEditor = diffEditorRef.current.getModifiedEditor();
modifiedEditor.revealLineInCenter(nextChange.modifiedStartLineNumber);
modifiedEditor.setPosition({
lineNumber: nextChange.modifiedStartLineNumber,
column: 1
});
setCurrentDiff(nextIndex);
};
// 跳转到上一个差异
const goToPreviousDiff = () => {
if (!diffEditorRef.current) return;
const lineChanges = diffEditorRef.current.getLineChanges();
if (!lineChanges || lineChanges.length === 0) return;
const prevIndex = currentDiff === 0 ? lineChanges.length - 1 : currentDiff - 1;
const prevChange = lineChanges[prevIndex];
// 跳转到差异位置(修改后的编辑器)
const modifiedEditor = diffEditorRef.current.getModifiedEditor();
modifiedEditor.revealLineInCenter(prevChange.modifiedStartLineNumber);
modifiedEditor.setPosition({
lineNumber: prevChange.modifiedStartLineNumber,
column: 1
});
setCurrentDiff(prevIndex);
};
// 重置为示例文本
const resetToExample = () => {
setOriginalText(CONTRACT_A);
setModifiedText(CONTRACT_B);
setCurrentDiff(0);
setUseExample(true);
setPdf1Info(null);
setPdf2Info(null);
// 重新计算差异数量
setTimeout(() => {
if (diffEditorRef.current) {
const lineChanges = diffEditorRef.current.getLineChanges();
if (lineChanges) {
setDiffCount(lineChanges.length);
}
}
}, 100);
};
// 从URL参数加载PDF
const loadPdfsFromUrl = () => {
if (typeof window === 'undefined') return;
const searchParams = new URLSearchParams(window.location.search);
const pdf1Path = searchParams.get('pdf1');
const pdf2Path = searchParams.get('pdf2');
if (pdf1Path || pdf2Path) {
setUseExample(false);
if (pdf1Path) {
const fullUrl = `/api/pdf-proxy?path=${encodeURIComponent(pdf1Path)}`;
setPdf1Url(fullUrl);
loadPdfAndExtractText(fullUrl, setPdf1Info, setIsLoadingPdf1, setOriginalText);
}
if (pdf2Path) {
const fullUrl = `/api/pdf-proxy?path=${encodeURIComponent(pdf2Path)}`;
setPdf2Url(fullUrl);
loadPdfAndExtractText(fullUrl, setPdf2Info, setIsLoadingPdf2, setModifiedText);
}
}
};
// 组件挂载时读取URL参数
useEffect(() => {
loadPdfsFromUrl();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div className="monaco-demo-page" style={{ height: '100vh', display: 'flex', flexDirection: 'column' }}>
{/* 页面头部 */}
<div style={{
padding: '16px 24px',
borderBottom: '1px solid #e0e0e0',
backgroundColor: '#fff',
boxShadow: '0 2px 4px rgba(0,0,0,0.05)'
}}>
<h1 style={{ margin: 0, fontSize: '24px', fontWeight: 600, color: '#333' }}>
<i className="ri-file-text-line" style={{ marginRight: '8px' }}></i>
Monaco Editor -
</h1>
<p style={{ margin: '8px 0 0 0', color: '#666', fontSize: '14px' }}>
使 Monaco Diff Editor
</p>
</div>
{/* 工具栏 */}
<div style={{
padding: '12px 24px',
borderBottom: '1px solid #e0e0e0',
backgroundColor: '#f5f5f5',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
{/* 差异统计 */}
<div style={{
padding: '6px 12px',
backgroundColor: '#fff',
border: '1px solid #d0d0d0',
borderRadius: '4px',
fontSize: '14px',
color: '#333'
}}>
<i className="ri-git-compare-line" style={{ marginRight: '6px', color: '#00684a' }}></i>
<strong style={{ color: '#00684a' }}>{diffCount}</strong>
{diffCount > 0 && ` (当前: ${currentDiff + 1}/${diffCount})`}
</div>
{/* 导航按钮 */}
<button
onClick={goToPreviousDiff}
disabled={diffCount === 0}
style={{
padding: '6px 12px',
backgroundColor: '#fff',
border: '1px solid #d0d0d0',
borderRadius: '4px',
cursor: diffCount === 0 ? 'not-allowed' : 'pointer',
fontSize: '14px',
display: 'flex',
alignItems: 'center',
gap: '6px',
opacity: diffCount === 0 ? 0.5 : 1
}}
>
<i className="ri-arrow-up-line"></i>
</button>
<button
onClick={goToNextDiff}
disabled={diffCount === 0}
style={{
padding: '6px 12px',
backgroundColor: '#fff',
border: '1px solid #d0d0d0',
borderRadius: '4px',
cursor: diffCount === 0 ? 'not-allowed' : 'pointer',
fontSize: '14px',
display: 'flex',
alignItems: 'center',
gap: '6px',
opacity: diffCount === 0 ? 0.5 : 1
}}
>
<i className="ri-arrow-down-line"></i>
</button>
</div>
<div style={{ display: 'flex', gap: '12px' }}>
{/* 重置按钮 */}
<button
onClick={resetToExample}
style={{
padding: '6px 12px',
backgroundColor: '#fff',
border: '1px solid #d0d0d0',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '14px',
display: 'flex',
alignItems: 'center',
gap: '6px'
}}
>
<i className="ri-refresh-line"></i>
</button>
{/* 未来扩展:上传按钮 */}
<button
disabled
style={{
padding: '6px 12px',
backgroundColor: '#e0e0e0',
border: '1px solid #d0d0d0',
borderRadius: '4px',
cursor: 'not-allowed',
fontSize: '14px',
display: 'flex',
alignItems: 'center',
gap: '6px',
opacity: 0.5
}}
>
<i className="ri-upload-2-line"></i>
</button>
</div>
</div>
{/* PDF加载信息 */}
{!useExample && (pdf1Info || pdf2Info || isLoadingPdf1 || isLoadingPdf2) && (
<div style={{
padding: '12px 24px',
backgroundColor: '#fff3cd',
borderBottom: '1px solid #ffc107',
fontSize: '14px',
color: '#856404'
}}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '24px' }}>
<i className="ri-file-pdf-line" style={{ fontSize: '18px', marginTop: '2px' }}></i>
<div style={{ flex: 1 }}>
<strong>PDF文档信息</strong>
<div style={{ display: 'flex', gap: '24px', marginTop: '8px' }}>
{/* PDF 1 信息 */}
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 'bold', marginBottom: '4px' }}>📄 1/</div>
{isLoadingPdf1 ? (
<div style={{ color: '#666' }}> ...</div>
) : pdf1Info ? (
<div>
<div>: <span style={{
color: pdf1Info.type === 'text' ? '#28a745' : pdf1Info.type === 'scanned' ? '#ffc107' : '#dc3545',
fontWeight: 'bold'
}}>
{pdf1Info.type === 'text' ? '✅ 文本PDF' : pdf1Info.type === 'scanned' ? '⚠️ 扫描PDF' : '❌ 未知类型'}
</span></div>
<div>: {pdf1Info.numPages} </div>
<div>: {pdf1Info.textLength} </div>
<div>: {(pdf1Info.confidence * 100).toFixed(0)}%</div>
</div>
) : (
<div style={{ color: '#999' }}></div>
)}
</div>
{/* PDF 2 信息 */}
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 'bold', marginBottom: '4px' }}>📄 2/</div>
{isLoadingPdf2 ? (
<div style={{ color: '#666' }}> ...</div>
) : pdf2Info ? (
<div>
<div>: <span style={{
color: pdf2Info.type === 'text' ? '#28a745' : pdf2Info.type === 'scanned' ? '#ffc107' : '#dc3545',
fontWeight: 'bold'
}}>
{pdf2Info.type === 'text' ? '✅ 文本PDF' : pdf2Info.type === 'scanned' ? '⚠️ 扫描PDF' : '❌ 未知类型'}
</span></div>
<div>: {pdf2Info.numPages} </div>
<div>: {pdf2Info.textLength} </div>
<div>: {(pdf2Info.confidence * 100).toFixed(0)}%</div>
</div>
) : (
<div style={{ color: '#999' }}></div>
)}
</div>
</div>
</div>
</div>
</div>
)}
{/* 说明信息 */}
<div style={{
padding: '12px 24px',
backgroundColor: '#e7f3ff',
borderBottom: '1px solid #b3d9ff',
fontSize: '14px',
color: '#004085'
}}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '8px' }}>
<i className="ri-information-line" style={{ fontSize: '18px', marginTop: '2px' }}></i>
<div style={{ flex: 1 }}>
<strong></strong>
<ul style={{ margin: '4px 0 0 0', paddingLeft: '20px' }}>
<li><span style={{ color: '#28a745', fontWeight: 'bold' }}>绿</span></li>
<li><span style={{ color: '#dc3545', fontWeight: 'bold' }}></span></li>
<li><span style={{ color: '#ffc107', fontWeight: 'bold' }}></span></li>
</ul>
{useExample && (
<div style={{ marginTop: '12px', paddingTop: '12px', borderTop: '1px solid #b3d9ff' }}>
<strong>💡 使</strong>
<div style={{ marginTop: '4px' }}>
URL参数加载PDF文档进行对比
<code style={{
display: 'block',
marginTop: '4px',
padding: '8px',
backgroundColor: 'rgba(0,0,0,0.05)',
borderRadius: '4px',
fontSize: '12px',
wordBreak: 'break-all'
}}>
/monaco-demo?pdf1=1&pdf2=2
</code>
<div style={{ marginTop: '4px', fontSize: '12px' }}>
: <code>/monaco-demo?pdf1=documents/contract_v1.pdf&pdf2=documents/contract_v2.pdf</code>
</div>
</div>
</div>
)}
</div>
</div>
</div>
{/* Diff Editor 主体 */}
<div style={{ flex: 1, overflow: 'hidden', position: 'relative' }}>
<DiffEditor
height="100%"
language="plaintext"
original={originalText}
modified={modifiedText}
onMount={handleEditorDidMount}
theme="vs"
options={{
// 编辑器选项
readOnly: true, // 只读模式
renderSideBySide: true, // 并排显示(false为内联模式)
ignoreTrimWhitespace: false, // 不忽略首尾空格差异
renderWhitespace: 'selection', // 显示选中区域的空格
fontSize: 14, // 字体大小
lineNumbers: 'on', // 显示行号
minimap: {
enabled: true // 显示缩略图
},
scrollBeyondLastLine: false, // 禁止滚动超过最后一行
wordWrap: 'on', // 自动换行
automaticLayout: true, // 自动调整布局
// Diff 特定选项
renderIndicators: true, // 显示差异指示器
diffWordWrap: 'on', // Diff 模式下自动换行
enableSplitViewResizing: true, // 允许调整分屏比例
diffAlgorithm: 'advanced', // 使用高级差异算法
}}
/>
{/* PDF加载中的遮罩层 */}
{(isLoadingPdf1 || isLoadingPdf2) && (
<div style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(255, 255, 255, 0.9)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000
}}>
<div style={{ textAlign: 'center' }}>
<div style={{
width: '48px',
height: '48px',
border: '4px solid #f3f3f3',
borderTop: '4px solid #00684a',
borderRadius: '50%',
animation: 'spin 1s linear infinite',
margin: '0 auto 16px'
}}></div>
<div style={{ fontSize: '16px', color: '#333' }}>
PDF文档并提取文本...
</div>
{isLoadingPdf1 && <div style={{ fontSize: '14px', color: '#666', marginTop: '8px' }}>📄 1</div>}
{isLoadingPdf2 && <div style={{ fontSize: '14px', color: '#666', marginTop: '8px' }}>📄 2</div>}
</div>
</div>
)}
</div>
{/* 添加旋转动画 */}
<style>{`
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
`}</style>
{/* 页面底部信息 */}
<div style={{
padding: '8px 24px',
borderTop: '1px solid #e0e0e0',
backgroundColor: '#f9f9f9',
fontSize: '12px',
color: '#666',
display: 'flex',
justifyContent: 'space-between'
}}>
<span>
<i className="ri-code-line"></i> Monaco Editor (VS Code )
</span>
<span>
使Ctrl+F
</span>
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
+28 -10
View File
@@ -1,5 +1,5 @@
import { MetaFunction, type LoaderFunctionArgs, type ActionFunctionArgs } from "@remix-run/node";
import { useSearchParams, useNavigate, useLoaderData, useFetcher } from "@remix-run/react";
import { useSearchParams, useNavigate, useLoaderData, useFetcher, useRouteLoaderData } from "@remix-run/react";
import { useState, useEffect } from "react";
import indexStyles from "~/styles/pages/prompts_index.css?url";
import { Card } from "~/components/ui/Card";
@@ -138,6 +138,17 @@ export default function PromptsIndex() {
const [isLoading, setIsLoading] = useState(false);
const fetcher = useFetcher<ActionData>();
// 获取用户角色并判断权限
const rootData = useRouteLoaderData("root") as { userRole: string };
const userRole = rootData?.userRole || 'common';
const hasEditPermission = userRole.toLowerCase().includes('provin');
// 调试信息
useEffect(() => {
console.log('📋 [Prompts] 用户角色:', userRole);
console.log('📋 [Prompts] 是否有编辑权限:', hasEditPermission);
}, [userRole, hasEditPermission]);
// 处理搜索名称
const handleNameSearch = (value: string) => {
const newParams = new URLSearchParams(searchParams);
@@ -247,7 +258,7 @@ export default function PromptsIndex() {
{
title: "模板名称",
key: "template_name",
width: "300px",
width: "25%",
render: (_: unknown, record: PromptTemplateUI) => (
<div className="flex items-center">
<i className="ri-file-list-line text-primary mr-2"></i>
@@ -258,7 +269,7 @@ export default function PromptsIndex() {
{
title: "类型",
key: "template_type",
width: "120px",
width: "100px",
render: (_: unknown, record: PromptTemplateUI) => {
let typeText = '';
let typeClass = '';
@@ -292,8 +303,9 @@ export default function PromptsIndex() {
{
title: "描述",
key: "description",
width: "30%",
render: (_: unknown, record: PromptTemplateUI) => (
<div className="text-secondary text-sm max-w-xs text-wrap" title={record.description}>
<div className="text-secondary text-sm text-wrap" title={record.description}>
{record.description}
</div>
)
@@ -301,13 +313,13 @@ export default function PromptsIndex() {
{
title: "版本",
key: "version",
width: "80px",
width: "70px",
render: (_: unknown, record: PromptTemplateUI) => record.version
},
{
title: "状态",
key: "status",
width: "100px",
width: "110px",
render: (_: unknown, record: PromptTemplateUI) => {
let statusText = '';
let statusClass = '';
@@ -333,7 +345,7 @@ export default function PromptsIndex() {
{
title: "创建者",
key: "created_by",
width: "100px",
width: "120px",
render: (_: unknown, record: PromptTemplateUI) => (
// <span className="text-secondary">用户 {record.created_by}</span>
<span className="text-secondary">{record.created_by_username}</span>
@@ -342,7 +354,7 @@ export default function PromptsIndex() {
{
title: "操作",
key: "operation",
width: "150px",
width: "160px",
render: (_: unknown, record: PromptTemplateUI) => (
<div>
{record.status === 'system' ? (
@@ -353,21 +365,24 @@ export default function PromptsIndex() {
>
<i className="ri-eye-line"></i>
</button>
{hasEditPermission && (
<button
className="operation-btn text-primary"
onClick={() => handleCloneTemplate(record.id)}
>
<i className="ri-file-copy-line"></i>
</button>
)}
</>
) : (
<>
<button
className="operation-btn text-primary"
onClick={() => handleEditTemplate(record.id)}
onClick={() => hasEditPermission ? handleEditTemplate(record.id) : handleViewTemplate(record.id)}
>
<i className="ri-edit-line"></i>
<i className={hasEditPermission ? "ri-edit-line" : "ri-eye-line"}></i> {hasEditPermission ? '编辑' : '查看'}
</button>
{hasEditPermission && (
<button
className="operation-btn text-error"
onClick={() => handleDeleteTemplate(record.id)}
@@ -375,6 +390,7 @@ export default function PromptsIndex() {
>
<i className="ri-delete-bin-line"></i>
</button>
)}
</>
)}
</div>
@@ -388,6 +404,7 @@ export default function PromptsIndex() {
<div className="page-header">
<h2 className="page-title"></h2>
<div>
{hasEditPermission && (
<Button
type="primary"
icon="ri-add-line"
@@ -395,6 +412,7 @@ export default function PromptsIndex() {
>
</Button>
)}
</div>
</div>
+5 -18
View File
@@ -317,9 +317,9 @@ export default function PromptsNew() {
const newFormData = {
...template,
id: mode === "clone" ? "" : template.id,
template_name: mode === "clone" ? `${template.template_name} (副本)` : template.template_name,
version: mode === "clone" ? "v1.0" : template.version,
id: template.id,
template_name: template.template_name,
version: template.version,
variables: variablesJson
};
@@ -348,8 +348,6 @@ export default function PromptsNew() {
setPageTitle("查看提示词模板");
} else if (mode === "edit") {
setPageTitle("编辑提示词模板");
} else if (mode === "clone") {
setPageTitle("复制创建提示词模板");
} else {
setPageTitle("新增提示词模板");
}
@@ -485,7 +483,7 @@ export default function PromptsNew() {
<div className="alert alert-info">
<i className="ri-information-line"></i>
<div>
<div>&quot;&quot;</div>
<div></div>
</div>
</div>
)}
@@ -780,17 +778,7 @@ export default function PromptsNew() {
</div>
{/* 底部按钮区域 */}
<div className="flex justify-between mt-6">
<div>
{isViewMode && (
<Link to={`/prompts/new?id=${formData.id}&mode=clone`}>
<button type="button" className="ant-btn ant-btn-default">
<i className="ri-file-copy-line"></i>
</button>
</Link>
)}
</div>
<div>
<div className="flex justify-end mt-6">
<Link to="/prompts" className="mr-2">
<button type="button" className="ant-btn ant-btn-default">
<i className="ri-close-line"></i>
@@ -807,7 +795,6 @@ export default function PromptsNew() {
</button>
)}
</div>
</div>
</Form>
</div>
);
File diff suppressed because it is too large Load Diff
+76
View File
@@ -0,0 +1,76 @@
/* 入口模块管理页面样式 */
.entry-modules-page {
padding: 24px;
min-height: 100vh;
}
.entry-modules-new-page {
padding: 24px;
min-height: 100vh;
}
/* 页面头部 */
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
/* 表单内容 */
.form-content {
max-width: 800px;
}
.form-item {
margin-bottom: 20px;
}
.form-label {
display: block;
font-size: 14px;
font-weight: 500;
color: #374151;
margin-bottom: 8px;
}
.form-actions {
padding-top: 24px;
border-top: 1px solid #e5e7eb;
}
/* Logo预览样式 */
.logo-preview {
display: inline-block;
border: 1px solid #e5e7eb;
border-radius: 4px;
padding: 8px;
background-color: #f9fafb;
}
/* 响应式设计 */
@media (max-width: 768px) {
.entry-modules-page,
.entry-modules-new-page {
padding: 16px;
}
.page-header {
flex-direction: column;
align-items: flex-start;
gap: 16px;
}
.form-content {
max-width: 100%;
}
.form-actions {
flex-direction: column-reverse;
}
.form-actions button {
width: 100%;
}
}
+58 -16
View File
@@ -80,7 +80,7 @@
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
justify-content: flex-start; /* 改为从顶部开始 */
padding-bottom: 0;
margin-bottom: 0;
background-color: #f0f7f4;
@@ -91,25 +91,59 @@
}
.index-main-content-container {
padding: 2rem 0;
padding: 0;
margin: 0 auto;
width: 90%;
max-width: 1200px;
transform: translateY(-7rem);
height: 100%;
display: flex;
flex-direction: column;
}
.welcome-text {
font-size: 1.95rem;
font-weight: 500;
color: #333;
margin-bottom: 5rem;
text-align: center;
/* 标题固定在页面上方 1/4 处,水平垂直居中 */
height: 25vh; /* 占据上方 25% 的高度 */
display: flex;
align-items: center;
justify-content: center;
margin: 0;
flex-shrink: 0; /* 防止被压缩 */
}
.modules-container {
display: flex;
flex-wrap: wrap; /* 自动换行 */
justify-content: center;
align-content: flex-start; /* 内容从顶部开始排列 */
gap: 2.5rem;
margin-bottom: 3rem;
flex: 1; /* 占据剩余空间 */
overflow-y: auto; /* 超出高度时显示垂直滚动条 */
overflow-x: hidden; /* 隐藏水平滚动条 */
padding: 2rem 0 3rem 0; /* 上下留出一些空间 */
}
/* 滚动条样式优化 */
.modules-container::-webkit-scrollbar {
width: 8px;
}
.modules-container::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.05);
border-radius: 4px;
}
.modules-container::-webkit-scrollbar-thumb {
background: rgba(0, 104, 74, 0.3);
border-radius: 4px;
transition: background 0.2s;
}
.modules-container::-webkit-scrollbar-thumb:hover {
background: rgba(0, 104, 74, 0.5);
}
.module-card {
@@ -120,6 +154,7 @@
padding: 0 2rem;
height: 136px;
width: 290px;
flex-shrink: 0; /* 防止卡片被压缩 */
background: linear-gradient(180deg, #ebf1f7 0%, #ffffff 100%);
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
@@ -234,22 +269,28 @@
.index-main-content-container {
width: 95%;
padding: 1rem 0;
transform: translateY(-2rem);
padding: 0;
}
.welcome-text {
font-size: 1.3rem;
margin-bottom: 2.5rem;
height: 20vh; /* 移动端标题区域稍小一点 */
padding: 0 1rem;
}
/* 模块容器改为纵向排列 */
.modules-container {
flex-direction: column;
flex-wrap: nowrap; /* 移动端不需要换行 */
gap: 1.25rem;
margin-bottom: 1.5rem;
align-items: center;
overflow-y: auto; /* 移动端超出长度滚动显示 */
padding: 1rem 0 2rem 0;
}
/* 移动端滚动条样式 */
.modules-container::-webkit-scrollbar {
width: 6px;
}
/* 模块卡片调整 */
@@ -259,6 +300,7 @@
height: 100px;
padding: 0 1.5rem;
gap: 1.25rem;
flex-shrink: 0; /* 移动端也防止卡片被压缩 */
}
.module-card img {
@@ -299,7 +341,7 @@
.welcome-text {
font-size: 1.15rem;
margin-bottom: 2rem;
height: 18vh; /* 超小屏幕标题区域更小 */
}
.module-card {
@@ -327,20 +369,20 @@
@media (min-width: 769px) and (max-width: 1024px) {
.index-main-content-container {
width: 85%;
transform: translateY(-5rem);
}
.welcome-text {
font-size: 1.75rem;
height: 22vh; /* 平板电脑标题区域高度 */
}
.modules-container {
gap: 2rem;
padding: 1.5rem 0 2.5rem 0;
}
.module-card {
width: 260px;
height: 120px;
}
.welcome-text {
font-size: 1.75rem;
margin-bottom: 4rem;
}
}
+224
View File
@@ -505,6 +505,230 @@
}
}
/* ==================== 表单样式 ==================== */
.role-form {
display: flex;
flex-direction: column;
gap: 20px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.form-group label {
font-size: 14px;
font-weight: 500;
color: #303133;
}
.form-group label.required::after {
content: ' *';
color: var(--color-error);
}
.form-input,
.form-textarea,
.form-select {
padding: 10px 12px;
border: 1px solid #dcdfe6;
border-radius: 4px;
font-size: 14px;
color: #303133;
transition: border-color 0.2s;
}
.form-input:focus,
.form-textarea:focus,
.form-select:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 2px rgba(0, 104, 74, 0.1);
}
.form-input.error,
.form-textarea.error,
.form-select.error {
border-color: var(--color-error);
}
.form-input:disabled,
.form-textarea:disabled,
.form-select:disabled {
background: #f5f7fa;
cursor: not-allowed;
color: #909399;
}
.form-hint {
font-size: 12px;
color: #909399;
line-height: 1.5;
}
.form-error {
font-size: 12px;
color: var(--color-error);
line-height: 1.5;
}
.form-notice {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
border-radius: 4px;
font-size: 14px;
background: #ecf5ff;
border: 1px solid #b3d8ff;
color: #606266;
}
.form-notice.warning {
background: #fef0f0;
border-color: #fbc4c4;
color: #606266;
}
.form-notice i {
font-size: 18px;
color: #409eff;
}
.form-notice.warning i {
color: var(--color-error);
}
/* 分配用户模态框 */
.assign-user-modal {
display: flex;
flex-direction: column;
gap: 16px;
}
/* 全选栏 */
.select-all-bar {
padding: 12px 16px;
background: #f5f7fa;
border-radius: 6px;
border: 1px solid #e4e7ed;
}
.select-all-bar .user-checkbox-item {
padding: 0;
margin: 0;
}
.select-all-bar .user-checkbox-item:hover {
background: transparent;
}
.select-all-bar .user-name {
font-weight: 600;
color: #303133;
}
/* 用户复选框列表 */
.users-checkbox-list {
max-height: 400px;
overflow-y: auto;
border: 1px solid #e4e7ed;
border-radius: 6px;
padding: 12px;
background: #fafbfc;
}
.user-checkbox-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
border-radius: 6px;
cursor: pointer;
transition: background 0.2s;
}
.user-checkbox-item:hover {
background: #e6e8eb;
}
.user-checkbox-item input[type="checkbox"] {
width: 18px;
height: 18px;
cursor: pointer;
accent-color: var(--color-primary);
}
.user-checkbox-item .user-info {
flex: 1;
min-width: 0;
}
.user-checkbox-item .user-name {
font-size: 14px;
font-weight: 500;
color: #303133;
margin-bottom: 4px;
}
.user-checkbox-item .user-meta {
font-size: 12px;
color: #909399;
}
/* 搜索框 */
.search-box {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border: 1px solid #dcdfe6;
border-radius: 6px;
background: white;
margin-bottom: 16px;
}
.search-box i {
font-size: 18px;
color: #909399;
}
.search-box input {
flex: 1;
border: none;
outline: none;
font-size: 14px;
color: #303133;
}
.search-box input::placeholder {
color: #c0c4cc;
}
/* 无权限卡片 */
.no-permission-card {
max-width: 800px;
margin: 60px auto;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
}
.no-permission-card .empty-state {
text-align: center;
}
.no-permission-card h2 {
margin: 0;
}
.no-permission-card p {
margin: 0;
}
/* ==================== 响应式布局 ==================== */
/* 响应式布局 */
@media (max-width: 1200px) {
.permissions-container {
+709
View File
@@ -0,0 +1,709 @@
# 用户权限管理接口 - 前端对接文档
## 📋 文档信息
- **版本**: v2.0
- **更新日期**: 2025-01-24
- **API基础URL**: `http://YOUR_HOST:8000`
- **认证方式**: JWT Bearer Token
---
## ⚠️ 重要提示 - 前端对接必读
### 1. Token过期处理
**问题现象**: API返回 `code: 4001, msg: "token参数无效"`
**原因**: JWT Token已过期(默认有效期1小时)
**解决方案**:
```javascript
// 检查Token是否过期
const isTokenExpired = (token) => {
try {
const payload = JSON.parse(atob(token.split('.')[1]));
return Date.now() >= payload.exp * 1000;
} catch {
return true;
}
};
// 在发送请求前检查
if (isTokenExpired(token)) {
await login(); // 重新登录获取新Token
}
```
### 2. 实际角色ID对应关系
⚠️ **数据库中的实际角色ID** (请以实际数据为准):
| 角色名称 | role_key | 实际role_id | 说明 |
|---------|----------|------------|------|
| 省级管理员 | provincial_admin | **52** | 拥有完整RBAC管理权限 |
| 市级管理员 | admin | **1** | 负责本地区业务管理 |
| 普通员工 | common | **2** | 只能查看和管理自己的数据 |
**调用示例**:
```javascript
// ✅ 正确:使用实际的role_id
const provincialAdminUsers = await getRoleUsers(52);
const adminUsers = await getRoleUsers(1);
const commonUsers = await getRoleUsers(2);
// ❌ 错误:使用Mock数据的ID
const wrongUsers = await getRoleUsers(3); // 数据库中不存在
```
### 3. 响应格式说明
所有接口统一返回格式:
```json
{
"code": 200,
"msg": "success",
"data": { ... }
}
```
部分接口(如用户列表)直接返回数据对象:
```json
{
"users": [...],
"total": 10
}
```
### 4. 权限要求
| 接口模块 | 权限要求 | 说明 |
|---------|----------|------|
| RBAC管理接口 | `system:rbac:manage` | 仅provincial_admin角色 |
| 用户管理接口 | JWT认证即可 | 所有登录用户 |
| 路由权限接口 | JWT认证即可 | 所有登录用户 |
---
## 📚 接口总览
### 一、RBAC管理接口(18个)
**基础路径**: `/api/v3/rbac`
#### 1.1 角色管理(5个接口)
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/roles` | 获取角色列表(支持分页、搜索) |
| GET | `/roles/{role_id}` | 获取角色详情 |
| POST | `/roles` | 创建角色 |
| PUT | `/roles/{role_id}` | 更新角色 |
| DELETE | `/roles/{role_id}` | 删除角色(支持force参数) |
#### 1.2 权限管理(5个接口)
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/permissions` | 获取权限列表(支持树形/平铺) |
| GET | `/permissions/{permission_id}` | 获取权限详情 |
| POST | `/permissions` | 创建权限 |
| PUT | `/permissions/{permission_id}` | 更新权限 |
| DELETE | `/permissions/{permission_id}` | 删除权限 |
#### 1.3 角色权限关联(4个接口)
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/roles/{role_id}/permissions` | 获取角色的所有权限 |
| POST | `/roles/{role_id}/permissions` | 批量分配权限给角色(支持替换/追加) |
| PUT | `/roles/{role_id}/permissions/{permission_id}` | 更新单个权限配置 |
| DELETE | `/roles/{role_id}/permissions/{permission_id}` | 移除角色权限 |
#### 1.4 用户角色管理(4个接口)
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/roles/{role_id}/users` | 获取拥有某角色的用户列表 |
| GET | `/users/{user_id}/roles` | 获取用户的所有角色 |
| POST | `/users/{user_id}/roles` | 为用户分配角色 |
| DELETE | `/users/{user_id}/roles/{role_id}` | 移除用户角色 |
### 二、用户管理接口(3个)
**基础路径**: `/admin/users`
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/users` | 获取用户列表(支持分页、搜索) |
| GET | `/organizations` | 获取组织架构(树形结构) |
| GET | `/organizations/flat` | 获取组织列表(扁平结构) |
### 三、路由权限接口(5个)
**基础路径**: `/rbac`
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/user/routes` | 获取当前用户可访问路由(树形) |
| GET | `/user/routes/flat` | 获取当前用户可访问路由(扁平) |
| GET | `/roles/{role_id}/routes` | 获取角色可访问路由 |
| PUT | `/roles/{role_id}/routes` | **批量更新角色路由权限** ⭐新增 |
| GET | `/check-route` | 检查路由访问权限 |
---
## 📖 详细接口说明
## 一、RBAC管理接口
### 1.1 获取角色列表
**接口**: `GET /api/v3/rbac/roles`
**Query参数**:
```javascript
{
page: 1, // 页码
page_size: 20, // 每页数量
role_key: "", // 角色标识过滤(可选)
role_name: "", // 角色名称模糊搜索(可选)
include_system: true // 是否包含系统角色(可选)
}
```
**响应示例**:
```json
{
"code": 200,
"message": "success",
"data": {
"total": 3,
"page": 1,
"page_size": 20,
"items": [
{
"id": 52,
"role_key": "provincial_admin",
"role_name": "省级管理员",
"description": "省级权限,可管理所有地区",
"data_scope": "ALL",
"is_system": true,
"user_count": 1,
"permission_count": 15
},
{
"id": 1,
"role_key": "admin",
"role_name": "市级管理员",
"data_scope": "DEPT",
"is_system": true,
"user_count": 6
}
]
}
}
```
---
### 1.2 创建角色
**接口**: `POST /api/v3/rbac/roles`
**请求体**:
```json
{
"role_key": "department_leader",
"role_name": "部门负责人",
"description": "负责部门日常管理",
"data_scope": "DEPT",
"metadata": {}
}
```
**响应示例**:
```json
{
"code": 200,
"message": "角色创建成功",
"data": {
"id": 6,
"role_key": "department_leader",
"role_name": "部门负责人",
"data_scope": "DEPT",
"is_system": false
}
}
```
---
### 1.3 获取角色的所有用户
**接口**: `GET /api/v3/rbac/roles/{role_id}/users`
**Query参数**:
```javascript
{
page: 1,
page_size: 20,
area: "梅州", // 按地区过滤(可选)
username: "admin" // 用户名模糊搜索(可选)
}
```
**响应示例**:
```json
{
"code": 200,
"message": "success",
"data": {
"total": 6,
"page": 1,
"page_size": 20,
"items": [
{
"user_id": 8,
"username": "梅州烟草",
"nick_name": "梅州烟草",
"area": "梅州管理员账号",
"ou_name": "梅州管理员账号",
"phone_number": null,
"email": null,
"assigned_at": "2025-11-18T01:40:25.030949+00:00"
}
]
}
}
```
---
### 1.4 批量分配权限给角色
**接口**: `POST /api/v3/rbac/roles/{role_id}/permissions`
**请求体**:
```json
{
"permissions": [
{
"permission_id": 10,
"grant_type": "GRANT",
"data_scope": "ALL"
},
{
"permission_id": 11,
"grant_type": "GRANT",
"data_scope": "DEPT"
}
],
"replace": true
}
```
**参数说明**:
- `replace`: true=替换全部权限,false=追加权限
- `grant_type`: GRANT(授予)或 DENY(拒绝)
- `data_scope`: ALL(全部数据)/ DEPT(本部门)/ SELF(仅自己)
**响应示例**:
```json
{
"code": 200,
"message": "权限分配成功",
"data": {
"role_id": 2,
"assigned_count": 2
}
}
```
---
### 1.5 为用户分配角色
**接口**: `POST /api/v3/rbac/users/{user_id}/roles`
**请求体**:
```json
{
"role_ids": [1, 2]
}
```
**响应示例**:
```json
{
"code": 200,
"message": "角色分配成功",
"data": {
"user_id": 20,
"username": "test_user",
"assigned_roles": [
{"role_id": 1, "role_name": "市级管理员"},
{"role_id": 2, "role_name": "普通员工"}
]
}
}
```
**注意事项**:
- ⚠️ 不能给自己分配角色(防止提权)
- ⚠️ provincial_admin角色只能由省级管理员分配
- ⚠️ admin角色只能给本地区用户分配角色
---
## 二、用户管理接口
### 2.1 获取用户列表
**接口**: `GET /admin/users/users`
**Query参数**:
```javascript
{
page: 1,
page_size: 20,
ou_id: "000", // 组织ID过滤(可选)
is_leader: true, // 是否领导过滤(可选)
search: "admin" // 搜索关键词(可选)
}
```
**响应示例**:
```json
{
"users": [
{
"id": 5,
"username": "admin",
"nick_name": "admin",
"ou_id": "000",
"ou_name": "test",
"is_leader": true,
"status": 0
}
],
"total": 10
}
```
⚠️ **已知问题**: 当前版本 `total` 字段返回0,请以 `users` 数组长度为准。
---
### 2.2 获取组织架构(树形)
**接口**: `GET /admin/users/organizations`
**Query参数**:
```javascript
{
include_users: true // 是否包含用户信息
}
```
**响应示例**:
```json
{
"organizations": [
{
"ou_id": "000",
"ou_name": "总部",
"parent_ou_id": null,
"level": 0,
"children": [
{
"ou_id": "0000000A1ML",
"ou_name": "梅州市局",
"parent_ou_id": "000",
"level": 1,
"users": [
{
"id": 8,
"username": "梅州烟草",
"nick_name": "梅州烟草",
"ou_id": "0000000A1ML",
"ou_name": "梅州市局"
}
]
}
],
"users": []
}
],
"total_organizations": 5,
"total_users": 10
}
```
---
## 三、路由权限接口
### 3.1 获取当前用户可访问路由
**接口**: `GET /rbac/user/routes`
**响应示例**:
```json
{
"code": 200,
"msg": "success",
"data": {
"user_id": 5,
"username": "admin",
"routes": [
{
"id": 1,
"route_path": "/",
"route_name": "Home",
"route_title": "首页",
"component": "Layout",
"parent_id": null,
"icon": "home",
"sort_order": 0,
"is_hidden": false,
"is_cache": true,
"meta": {},
"children": [
{
"id": 11,
"route_path": "/dashboard",
"route_name": "Dashboard",
"route_title": "工作台",
"parent_id": 1
}
]
}
],
"routes_flat": [
{"id": 1, "route_path": "/", "route_name": "Home"},
{"id": 11, "route_path": "/dashboard", "route_name": "Dashboard"}
]
}
}
```
**说明**:
- `routes`: 树形结构(用于构建菜单)
- `routes_flat`: 扁平结构(用于路由守卫权限检查)
---
### 3.2 获取角色可访问路由
**接口**: `GET /rbac/roles/{role_id}/routes`
**响应示例**:
```json
{
"code": 200,
"msg": "success",
"data": {
"role_id": 2,
"routes": [
{"id": 1, "route_path": "/", "route_name": "Home", "route_title": "首页"},
{"id": 11, "route_path": "/dashboard", "route_name": "Dashboard"}
]
}
}
```
---
### 3.3 批量更新角色路由权限 ⭐新增
**接口**: `PUT /rbac/roles/{role_id}/routes`
**功能说明**:
- 批量更新指定角色的路由权限
- 采用**替换模式**:先删除现有所有关联,再插入新关联
- 自动清除相关缓存(角色缓存 + 用户缓存)
**请求体**:
```json
{
"route_ids": [1, 11, 12, 3, 31, 32, 2, 21],
"permission": "RW"
}
```
**参数说明**:
- `route_ids`: 路由ID列表(必填)
- `permission`: 权限类型,可选值:
- `R`: 只读权限
- `W`: 只写权限
- `RW`: 读写权限(默认)
**响应示例**:
```json
{
"code": 200,
"msg": "success",
"data": {
"role_id": 2,
"assigned_count": 8,
"removed_count": 5,
"route_ids": [1, 11, 12, 3, 31, 32, 2, 21]
}
}
```
**前端调用示例**:
```javascript
const updateRoleRoutes = async (roleId, routeIds) => {
const response = await fetch(`/rbac/roles/${roleId}/routes`, {
method: 'PUT',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
route_ids: routeIds,
permission: 'RW'
})
});
const result = await response.json();
if (result.code === 200) {
console.log(`成功分配 ${result.data.assigned_count} 个路由`);
console.log(`移除了 ${result.data.removed_count} 个旧路由`);
}
return result;
};
// 使用示例
await updateRoleRoutes(2, [1, 11, 12, 3, 31, 32, 2, 21]);
```
**注意事项**:
- ⚠️ 所有 `route_ids` 必须存在且未删除
- ⚠️ 使用事务确保数据一致性
- ⚠️ 会自动清除所有受影响用户的路由缓存
---
### 3.4 检查路由访问权限
**接口**: `GET /rbac/check-route`
**Query参数**:
```javascript
{
route_path: "/system/users"
}
```
**响应示例**:
```json
{
"code": 200,
"msg": "success",
"data": {
"route_path": "/system/users",
"has_access": true
}
}
```
---
## 🚨 错误码说明
### HTTP状态码
| 状态码 | 说明 | 处理建议 |
|--------|------|---------|
| 200 | 成功 | - |
| 400 | 请求参数错误 | 检查请求体格式和必填字段 |
| 401 | 未认证 | Token过期或无效,需要重新登录 |
| 403 | 无权限 | 显示权限不足提示 |
| 404 | 资源不存在 | 检查ID是否正确 |
| 409 | 冲突 | 如role_key重复 |
| 500 | 服务器错误 | 联系后端排查 |
### 常见错误处理
```javascript
// 统一错误处理
const handleError = (error) => {
if (error.response?.status === 401) {
// Token过期,跳转登录
localStorage.removeItem('token');
window.location.href = '/login';
} else if (error.response?.status === 403) {
alert('权限不足');
} else if (error.response?.data?.detail) {
alert(error.response.data.detail);
} else {
alert('操作失败,请稍后重试');
}
};
```
---
## 💡 常见问题FAQ
### Q1: 为什么所有接口返回 code: 4001?
**A**: Token已过期,请重新登录获取新Token
### Q2: role_id应该用哪个?文档里写的1,2,3还是52,1,2
**A**: 使用实际数据库中的ID:
- provincial_admin = **52**
- admin = **1**
- common = **2**
### Q3: 需要用Mock数据吗?
**A**: **不需要**,所有接口已完整实现,直接调用真实API
### Q4: 如何判断当前用户是否有RBAC管理权限?
```javascript
// 方式1:检查JWT中的user_role
const payload = JSON.parse(atob(token.split('.')[1]));
const isProvincialAdmin = payload.user_role === 'provincial_admin';
// 方式2:调用权限检查接口(推荐)
const routes = await fetch('/rbac/user/routes');
const hasRBACAccess = routes.data.routes.some(r => r.route_path === '/rbac');
```
### Q5: 如何实现角色权限页面的路由分配功能?
**A**: 使用新的批量更新接口 `PUT /rbac/roles/{role_id}/routes`:
1. 通过 `GET /rbac/roles/{role_id}/routes` 获取当前角色已有路由
2. 用户选择要分配的路由ID列表
3. 调用 `PUT /rbac/roles/{role_id}/routes` 提交更新
4. 接口会自动替换所有路由权限并清除缓存
### Q6: total字段为什么返回0
**A**: `/admin/users/users` 接口的 `total` 字段当前版本存在bug,请以 `users` 数组长度为准。该问题已记录,将在后续版本修复。
### Q7: 批量更新路由权限后,用户需要重新登录吗?
**A**: 不需要。接口会自动清除相关用户的路由缓存,用户刷新页面即可看到新权限。
---
## 📦 前端开发清单
- [ ] 移除所有RBAC相关的Mock数据
- [ ] 使用实际的role_id52, 1, 2
- [ ] 实现Token过期自动刷新机制
- [ ] 处理401/403错误(跳转登录/权限提示)
- [ ] 使用 `PUT /rbac/roles/{role_id}/routes` 实现路由权限分配
- [ ] 处理 `total` 字段为0的情况(用数组长度代替)
---
## 📞 技术支持
如有问题,请联系后端开发团队。
**文档版本**: v2.0
**最后更新**: 2025-01-24
**维护者**: Backend Team
+69
View File
@@ -9,6 +9,7 @@
"@ant-design/icons": "^5.6.1",
"@codemirror/lang-javascript": "^6.2.3",
"@codemirror/theme-one-dark": "^6.1.2",
"@monaco-editor/react": "^4.7.0",
"@remix-run/node": "^2.16.2",
"@remix-run/react": "^2.16.2",
"@remix-run/serve": "^2.16.2",
@@ -29,6 +30,7 @@
"jszip": "^3.10.1",
"katex": "^0.16.22",
"mammoth": "^1.9.0",
"monaco-editor": "^0.55.1",
"pdf-lib": "^1.17.1",
"pg": "^8.14.1",
"pm2": "^6.0.8",
@@ -1596,6 +1598,29 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/@monaco-editor/loader": {
"version": "1.7.0",
"resolved": "https://registry.npmmirror.com/@monaco-editor/loader/-/loader-1.7.0.tgz",
"integrity": "sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==",
"license": "MIT",
"dependencies": {
"state-local": "^1.0.6"
}
},
"node_modules/@monaco-editor/react": {
"version": "4.7.0",
"resolved": "https://registry.npmmirror.com/@monaco-editor/react/-/react-4.7.0.tgz",
"integrity": "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==",
"license": "MIT",
"dependencies": {
"@monaco-editor/loader": "^1.5.0"
},
"peerDependencies": {
"monaco-editor": ">= 0.25.0 < 1",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "0.2.12",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
@@ -3353,6 +3378,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmmirror.com/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT",
"optional": true
},
"node_modules/@types/unist": {
"version": "2.0.11",
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz",
@@ -6794,6 +6826,15 @@
"jszip": ">=3.0.0"
}
},
"node_modules/dompurify": {
"version": "3.2.7",
"resolved": "https://registry.npmmirror.com/dompurify/-/dompurify-3.2.7.tgz",
"integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/dotenv": {
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
@@ -11495,6 +11536,18 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/marked": {
"version": "14.0.0",
"resolved": "https://registry.npmmirror.com/marked/-/marked-14.0.0.tgz",
"integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -18281,6 +18334,16 @@
"integrity": "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==",
"license": "MIT"
},
"node_modules/monaco-editor": {
"version": "0.55.1",
"resolved": "https://registry.npmmirror.com/monaco-editor/-/monaco-editor-0.55.1.tgz",
"integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==",
"license": "MIT",
"dependencies": {
"dompurify": "3.2.7",
"marked": "14.0.0"
}
},
"node_modules/morgan": {
"version": "1.10.1",
"resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.1.tgz",
@@ -23621,6 +23684,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/state-local": {
"version": "1.0.7",
"resolved": "https://registry.npmmirror.com/state-local/-/state-local-1.0.7.tgz",
"integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==",
"license": "MIT"
},
"node_modules/statuses": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
+2
View File
@@ -20,6 +20,7 @@
"@ant-design/icons": "^5.6.1",
"@codemirror/lang-javascript": "^6.2.3",
"@codemirror/theme-one-dark": "^6.1.2",
"@monaco-editor/react": "^4.7.0",
"@remix-run/node": "^2.16.2",
"@remix-run/react": "^2.16.2",
"@remix-run/serve": "^2.16.2",
@@ -40,6 +41,7 @@
"jszip": "^3.10.1",
"katex": "^0.16.22",
"mammoth": "^1.9.0",
"monaco-editor": "^0.55.1",
"pdf-lib": "^1.17.1",
"pg": "^8.14.1",
"pm2": "^6.0.8",
Binary file not shown.
+1 -1
View File
@@ -53,7 +53,7 @@ export default defineConfig({
server: {
host: '0.0.0.0',
// port: 5173,
port: Number(process.env.PORT) || 51703,
port: Number(process.env.PORT) || 51709,
open: true,
// open: false,
allowedHosts: ['nas.7bm.co', 'localhost', '127.0.0.1'], // 允许的主机名列表1