Merge remote-tracking branch 'origin/Wren' into PingChuan
This commit is contained in:
@@ -12,3 +12,7 @@ docreview-frontend-deploy.tar.gz
|
||||
|
||||
# Claude Code local settings
|
||||
.claude/
|
||||
|
||||
.doc/
|
||||
.database/
|
||||
.auth_doc/
|
||||
|
||||
@@ -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
@@ -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=0(PostgREST)和code=200(RBAC 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
|
||||
};
|
||||
|
||||
@@ -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 : "删除入口模块失败"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
+300
-381
@@ -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> {
|
||||
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
@@ -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" }
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 === '智慧法务大模型') {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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">
|
||||
暂无可用模块
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
支持 JPG、PNG、GIF 格式,大小不超过 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Outlet } from "@remix-run/react";
|
||||
|
||||
/**
|
||||
* 入口模块管理父路由
|
||||
* 用于包裹子路由(列表页和新建/编辑页)
|
||||
*/
|
||||
export default function EntryModulesLayout() {
|
||||
return <Outlet />;
|
||||
}
|
||||
+311
-120
@@ -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,7 +353,10 @@ 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)]">
|
||||
<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="今日待审文件"
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
|
||||
@@ -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>您正在查看系统预设模板,此模板不可修改。如需基于此模板创建新模板,请点击"复制创建"按钮。</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
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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_id(52, 1, 2)
|
||||
- [ ] 实现Token过期自动刷新机制
|
||||
- [ ] 处理401/403错误(跳转登录/权限提示)
|
||||
- [ ] 使用 `PUT /rbac/roles/{role_id}/routes` 实现路由权限分配
|
||||
- [ ] 处理 `total` 字段为0的情况(用数组长度代替)
|
||||
|
||||
---
|
||||
|
||||
## 📞 技术支持
|
||||
|
||||
如有问题,请联系后端开发团队。
|
||||
|
||||
**文档版本**: v2.0
|
||||
**最后更新**: 2025-01-24
|
||||
**维护者**: Backend Team
|
||||
Generated
+69
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user