feat: 1.修改提示词模板的不用角色的操作权限。
2. 对接数据看板的数据。 3. 添加入口模块管理的页面。
This commit is contained in:
+41
-6
@@ -12,7 +12,7 @@ export type ApiResponse<T> = {
|
|||||||
headers?: Record<string, string>;
|
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 (从配置文件导入)
|
// 获取 API 基础 URL (从配置文件导入)
|
||||||
// const API_BASE_URL = 'http://172.16.0.58:8008';
|
// const API_BASE_URL = 'http://172.16.0.58:8008';
|
||||||
@@ -52,6 +52,12 @@ const AUTH_WHITELIST = [
|
|||||||
'/oauth/userinfo'
|
'/oauth/userinfo'
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// 错误容忍白名单 - 这些接口即使返回 401/403 也不触发强制登出
|
||||||
|
const ERROR_TOLERANT_WHITELIST = [
|
||||||
|
'/admin/statistics/top-error-points',
|
||||||
|
'/admin/statistics/top-risk-users'
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 检查请求URL是否在白名单中
|
* 检查请求URL是否在白名单中
|
||||||
*/
|
*/
|
||||||
@@ -60,6 +66,14 @@ function isInAuthWhitelist(url?: string): boolean {
|
|||||||
return AUTH_WHITELIST.some(path => url.includes(path));
|
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 头
|
* 请求拦截器 - 自动添加 Authorization 头
|
||||||
*/
|
*/
|
||||||
@@ -104,10 +118,18 @@ axiosInstance.interceptors.response.use(
|
|||||||
},
|
},
|
||||||
(error) => {
|
(error) => {
|
||||||
if (isAxiosError(error) && error.response?.status === 401) {
|
if (isAxiosError(error) && error.response?.status === 401) {
|
||||||
|
// 检查是否在错误容忍白名单中
|
||||||
|
const requestUrl = error.config?.url;
|
||||||
|
if (isInErrorTolerantWhitelist(requestUrl)) {
|
||||||
|
console.warn('⚠️ [容错白名单] 接口返回 401,但不触发强制登出:', requestUrl);
|
||||||
|
// 直接返回错误,不触发登出
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
|
||||||
// Token 过期或无效
|
// Token 过期或无效
|
||||||
console.warn('⚠️ Token 已过期或无效,请重新登录');
|
console.warn('⚠️ Token 已过期或无效,请重新登录');
|
||||||
console.warn('⚠️ 401 错误详情:', {
|
console.warn('⚠️ 401 错误详情:', {
|
||||||
url: error.config?.url,
|
url: requestUrl,
|
||||||
status: error.response?.status,
|
status: error.response?.status,
|
||||||
statusText: error.response?.statusText,
|
statusText: error.response?.statusText,
|
||||||
data: error.response?.data,
|
data: error.response?.data,
|
||||||
@@ -208,8 +230,13 @@ function buildUrl(endpoint: string, params?: QueryParams): string {
|
|||||||
if (params) {
|
if (params) {
|
||||||
Object.entries(params).forEach(([key, value]) => {
|
Object.entries(params).forEach(([key, value]) => {
|
||||||
if (value !== undefined) {
|
if (value !== undefined) {
|
||||||
|
// 处理数组参数:使用逗号分隔
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
url.searchParams.append(key, value.join(','));
|
||||||
|
} else {
|
||||||
url.searchParams.append(key, String(value));
|
url.searchParams.append(key, String(value));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,14 +320,17 @@ export async function apiRequest<T>(
|
|||||||
// 构建 URL
|
// 构建 URL
|
||||||
const url = buildUrl(endpoint, params);
|
const url = buildUrl(endpoint, params);
|
||||||
|
|
||||||
// 设置默认请求头
|
// 只有在 options.headers 存在时才处理,否则让拦截器处理
|
||||||
const headers = options.headers || {};
|
let headers = options.headers;
|
||||||
|
if (headers) {
|
||||||
|
// 设置默认请求头(仅当 headers 已存在时)
|
||||||
if (!headers['Content-Type'] && options.method !== 'GET') {
|
if (!headers['Content-Type'] && options.method !== 'GET') {
|
||||||
headers['Content-Type'] = 'application/json';
|
headers['Content-Type'] = 'application/json';
|
||||||
}
|
}
|
||||||
if (!headers['Accept']) {
|
if (!headers['Accept']) {
|
||||||
headers['Accept'] = 'application/json';
|
headers['Accept'] = 'application/json';
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 针对 PostgREST 的额外处理
|
// 针对 PostgREST 的额外处理
|
||||||
if (endpoint.includes('evaluation_point_groups') && (options.method === 'POST' || options.method === 'PATCH')) {
|
if (endpoint.includes('evaluation_point_groups') && (options.method === 'POST' || options.method === 'PATCH')) {
|
||||||
@@ -327,21 +357,26 @@ export async function apiRequest<T>(
|
|||||||
// console.log(`axios-client.ts->请求体: \n${typeof options.data === 'string' ? options.data : JSON.stringify(options.data)}`);
|
// console.log(`axios-client.ts->请求体: \n${typeof options.data === 'string' ? options.data : JSON.stringify(options.data)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 发送请求
|
// 构建请求配置
|
||||||
|
// 如果没有传入 headers,就不设置 headers,让拦截器自动添加
|
||||||
const config: AxiosRequestConfig = {
|
const config: AxiosRequestConfig = {
|
||||||
...options,
|
...options,
|
||||||
url,
|
url,
|
||||||
headers,
|
|
||||||
// 确保使用默认超时时间
|
// 确保使用默认超时时间
|
||||||
timeout: options.timeout || DEFAULT_TIMEOUT
|
timeout: options.timeout || DEFAULT_TIMEOUT
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 只有在 headers 存在时才设置
|
||||||
|
if (headers) {
|
||||||
|
config.headers = headers;
|
||||||
|
|
||||||
// 🔍 调试:打印 Authorization 头
|
// 🔍 调试:打印 Authorization 头
|
||||||
if (headers['Authorization']) {
|
if (headers['Authorization']) {
|
||||||
// console.log('🔑 [apiRequest] 请求包含 Authorization 头:', headers['Authorization'].substring(0, 20) + '...');
|
// console.log('🔑 [apiRequest] 请求包含 Authorization 头:', headers['Authorization'].substring(0, 20) + '...');
|
||||||
} else {
|
} else {
|
||||||
console.warn('⚠️ [apiRequest] 请求缺少 Authorization 头!headers:', Object.keys(headers));
|
console.warn('⚠️ [apiRequest] 请求缺少 Authorization 头!headers:', Object.keys(headers));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
// console.log(`📦 axios-client.ts->请求配置: \n${JSON.stringify(config)}`);
|
// console.log(`📦 axios-client.ts->请求配置: \n${JSON.stringify(config)}`);
|
||||||
|
|
||||||
// 删除body属性,避免axios警告
|
// 删除body属性,避免axios警告
|
||||||
|
|||||||
@@ -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 (dateFrom) params.start_time = dateFrom;
|
||||||
if (dateTo) params.end_time = dateTo;
|
if (dateTo) params.end_time = dateTo;
|
||||||
|
|
||||||
// 处理文档类型ID数组 - 传递为数组或单个值
|
// 处理文档类型ID数组 - 转换为逗号分隔的字符串
|
||||||
if (documentTypeIds && documentTypeIds.length > 0) {
|
if (documentTypeIds && documentTypeIds.length > 0) {
|
||||||
params.type_id = documentTypeIds;
|
params.type_id = documentTypeIds.join(',');
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('📤 [getDocumentsListFromAPI] 请求参数:', params);
|
// console.log('📤 [getDocumentsListFromAPI] 请求参数:', params);
|
||||||
|
|
||||||
// 调用后端API
|
// 调用后端API
|
||||||
const axios = await import('axios').then(m => m.default);
|
const axios = await import('axios').then(m => m.default);
|
||||||
@@ -529,7 +529,7 @@ export async function getDocumentsListFromAPI(searchParams: {
|
|||||||
const totalCount = data.total || 0;
|
const totalCount = data.total || 0;
|
||||||
const totalPages = data.total_pages || 0;
|
const totalPages = data.total_pages || 0;
|
||||||
|
|
||||||
console.log(`📥 [getDocumentsListFromAPI] 获取到 ${backendDocuments.length} 个文档,总数: ${totalCount}`);
|
// console.log(`📥 [getDocumentsListFromAPI] 获取到 ${backendDocuments.length} 个文档,总数: ${totalCount}`);
|
||||||
|
|
||||||
// 转换后端数据为前端 DocumentUI 格式
|
// 转换后端数据为前端 DocumentUI 格式
|
||||||
const convertedDocuments: DocumentUI[] = backendDocuments.map((doc: any) => {
|
const convertedDocuments: DocumentUI[] = backendDocuments.map((doc: any) => {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { postgrestGet, postgrestPost, type PostgrestParams } from "../postgrest-client";
|
import { postgrestGet, postgrestPost, type PostgrestParams } from "../postgrest-client";
|
||||||
|
import { apiRequest } from "../axios-client";
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -607,3 +608,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: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -140,8 +140,6 @@ export function BasicInfo({ onChange, initialData, evaluationPointGroups = [], r
|
|||||||
|
|
||||||
// 处理条款号输入框失去焦点
|
// 处理条款号输入框失去焦点
|
||||||
const handleLawArticlesBlur = () => {
|
const handleLawArticlesBlur = () => {
|
||||||
if (!lawArticlesText) return;
|
|
||||||
|
|
||||||
// 将输入的文本转换为数组
|
// 将输入的文本转换为数组
|
||||||
const articles = lawArticlesText
|
const articles = lawArticlesText
|
||||||
.split(',')
|
.split(',')
|
||||||
@@ -151,7 +149,7 @@ export function BasicInfo({ onChange, initialData, evaluationPointGroups = [], r
|
|||||||
// 创建一个新的引用法律对象,保留现有字段
|
// 创建一个新的引用法律对象,保留现有字段
|
||||||
const referencesLaws = {
|
const referencesLaws = {
|
||||||
...(formData.references_laws || {}),
|
...(formData.references_laws || {}),
|
||||||
articles: articles.length > 0 ? articles : []
|
articles: articles // ✅ 清空时会是空数组
|
||||||
};
|
};
|
||||||
|
|
||||||
// 更新表单数据
|
// 更新表单数据
|
||||||
@@ -171,6 +169,9 @@ export function BasicInfo({ onChange, initialData, evaluationPointGroups = [], r
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (formData.references_laws?.articles && formData.references_laws.articles.length > 0) {
|
if (formData.references_laws?.articles && formData.references_laws.articles.length > 0) {
|
||||||
setLawArticlesText(formData.references_laws.articles.join(','));
|
setLawArticlesText(formData.references_laws.articles.join(','));
|
||||||
|
} else {
|
||||||
|
// ✅ 当 articles 为空时,也清空输入框
|
||||||
|
setLawArticlesText('');
|
||||||
}
|
}
|
||||||
}, [formData.references_laws?.articles]);
|
}, [formData.references_laws?.articles]);
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ export function Table<T extends Record<string, any>>({
|
|||||||
className = '',
|
className = '',
|
||||||
onRow,
|
onRow,
|
||||||
}: TableProps<T>) {
|
}: TableProps<T>) {
|
||||||
|
// 防御性检查:确保 dataSource 始终是数组
|
||||||
|
const safeDataSource = dataSource || [];
|
||||||
|
|
||||||
const getRowKey = (record: T, index: number): string => {
|
const getRowKey = (record: T, index: number): string => {
|
||||||
if (typeof rowKey === 'function') {
|
if (typeof rowKey === 'function') {
|
||||||
return rowKey(record);
|
return rowKey(record);
|
||||||
@@ -58,8 +61,8 @@ export function Table<T extends Record<string, any>>({
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{dataSource.length > 0 ? (
|
{safeDataSource.length > 0 ? (
|
||||||
dataSource.map((record, index) => (
|
safeDataSource.map((record, index) => (
|
||||||
<tr
|
<tr
|
||||||
key={getRowKey(record, index)}
|
key={getRowKey(record, index)}
|
||||||
{...(onRow ? onRow(record, index) : {})}
|
{...(onRow ? onRow(record, index) : {})}
|
||||||
|
|||||||
@@ -0,0 +1,430 @@
|
|||||||
|
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: "管理入口模块,包括查看、编辑和删除入口模块" },
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// 定义加载器返回的数据类型
|
||||||
|
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,405 @@
|
|||||||
|
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: "新建/编辑入口模块"
|
||||||
|
};
|
||||||
|
|
||||||
|
// 定义加载器返回的数据类型
|
||||||
|
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 { FileTypeTag, links as fileTypeTagLinks } from "~/components/ui/FileTypeTag";
|
||||||
import { Tag } from "~/components/ui/Tag";
|
import { Tag } from "~/components/ui/Tag";
|
||||||
import homeStyles from "~/styles/pages/sys_overview.css?url";
|
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 { 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 dayjs from 'dayjs';
|
||||||
// import type { UserRole } from '~/api/login/auth.server';
|
// import type { UserRole } from '~/api/login/auth.server';
|
||||||
import { type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/node";
|
import { type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/node";
|
||||||
@@ -69,7 +69,6 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
|||||||
issuesGrowth: { value: 0, isUp: true }
|
issuesGrowth: { value: 0, isUp: true }
|
||||||
},
|
},
|
||||||
recentFiles: [],
|
recentFiles: [],
|
||||||
reviewType: null,
|
|
||||||
userRole: userRole,
|
userRole: userRole,
|
||||||
userInfo,
|
userInfo,
|
||||||
frontendJWT
|
frontendJWT
|
||||||
@@ -105,17 +104,18 @@ export default function Home() {
|
|||||||
date: '',
|
date: '',
|
||||||
time: ''
|
time: ''
|
||||||
});
|
});
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
// const userRole = serverUserRole as UserRole;
|
|
||||||
|
|
||||||
// 🔑 防御性检查:如果 userInfo 不存在,重定向到登录页(理论上不应该发生,因为 loader 已经检查了)
|
// 独立的loading状态管理
|
||||||
if (!userInfo) {
|
const [loadingStates, setLoadingStates] = useState({
|
||||||
console.error("❌ [Home] userInfo 不存在,重定向到登录页");
|
stats: true, // 统计信息
|
||||||
if (typeof window !== 'undefined') {
|
recentFiles: true, // 最近文档
|
||||||
window.location.href = '/login';
|
errorPoints: true, // 高频错误评查点
|
||||||
}
|
riskUsers: true // 高风险用户
|
||||||
return null;
|
});
|
||||||
}
|
|
||||||
|
// 统计数据状态(初始时标记为不可用,加载后根据API响应更新)
|
||||||
|
const [topErrorPoints, setTopErrorPoints] = useState<TopErrorPointsResponse>({ available: false, total: 0, items: [] });
|
||||||
|
const [topRiskUsers, setTopRiskUsers] = useState<TopRiskUsersResponse>({ available: false, total: 0, items: [] });
|
||||||
|
|
||||||
// 打印服务器端传递的用户角色
|
// 打印服务器端传递的用户角色
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -148,8 +148,7 @@ export default function Home() {
|
|||||||
// 清除sessionStorage中的所有数据
|
// 清除sessionStorage中的所有数据
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
sessionStorage.removeItem('userRole');
|
sessionStorage.removeItem('userRole');
|
||||||
sessionStorage.removeItem('reviewType');
|
sessionStorage.removeItem('documentTypeIds');
|
||||||
sessionStorage.removeItem('previousReviewType');
|
|
||||||
sessionStorage.removeItem('frontendJWT');
|
sessionStorage.removeItem('frontendJWT');
|
||||||
sessionStorage.removeItem('userInfo');
|
sessionStorage.removeItem('userInfo');
|
||||||
sessionStorage.removeItem('accessToken');
|
sessionStorage.removeItem('accessToken');
|
||||||
@@ -168,82 +167,122 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 在客户端挂载时,根据 sessionStorage 中的 reviewType 加载正确的数据
|
// 在客户端挂载时,根据 sessionStorage 中的 documentTypeIds 加载正确的数据
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
// 从 sessionStorage 获取 documentTypeIds
|
||||||
// 从 sessionStorage 获取 reviewType
|
const documentTypeIdsStr = sessionStorage.getItem('documentTypeIds');
|
||||||
const reviewType = sessionStorage.getItem('reviewType');
|
const documentTypeIds = documentTypeIdsStr ? JSON.parse(documentTypeIdsStr) : [];
|
||||||
|
|
||||||
// 加载主页数据
|
// 从 documentTypeIds 推断 reviewType(用于 getHomeData)
|
||||||
const newHomeData = await getHomeData(reviewType || undefined,userInfo.user_id, frontendJWT);
|
const reviewType = inferReviewType(documentTypeIds);
|
||||||
|
|
||||||
|
// 并行加载所有数据,每个数据加载完成后立即更新对应的loading状态
|
||||||
|
await Promise.all([
|
||||||
|
// 加载统计信息
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const newHomeData = await getHomeData(reviewType, userInfo.user_id, frontendJWT);
|
||||||
setHomeData(newHomeData);
|
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);
|
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) {
|
} catch (error) {
|
||||||
console.error('加载数据失败:', error);
|
console.error('加载数据失败:', error);
|
||||||
setIsLoading(false);
|
// 确保所有loading状态都被重置
|
||||||
|
setLoadingStates({
|
||||||
|
stats: false,
|
||||||
|
recentFiles: false,
|
||||||
|
errorPoints: false,
|
||||||
|
riskUsers: false
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
loadData();
|
loadData();
|
||||||
}, []); // 仅在组件挂载时执行一次
|
}, []); // 仅在组件挂载时执行一次
|
||||||
|
|
||||||
// 加载文档数据的函数
|
// 从 documentTypeIds 推断 reviewType(用于 getHomeData API)
|
||||||
const loadDocuments = async (reviewType: string | null) => {
|
const inferReviewType = (documentTypeIds: number[]): string | null => {
|
||||||
try {
|
if (!documentTypeIds || documentTypeIds.length === 0) return null;
|
||||||
const documentSearchParams: DocumentSearchParams = {
|
if (documentTypeIds.includes(1)) return 'contract';
|
||||||
page: 1,
|
if (documentTypeIds.includes(2) || documentTypeIds.includes(3)) return 'record';
|
||||||
pageSize: 10,
|
return null;
|
||||||
userId: userInfo.user_id,
|
|
||||||
token: frontendJWT || undefined
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 根据 reviewType 添加过滤条件
|
// 加载文档数据的函数
|
||||||
if (reviewType === 'contract') {
|
const loadDocuments = async (documentTypeIds: number[]) => {
|
||||||
documentSearchParams.documentType = '1';
|
try {
|
||||||
|
if (!frontendJWT) {
|
||||||
const response = await getDocuments(documentSearchParams);
|
console.error('缺少 JWT token');
|
||||||
if (!response.error && response.data) {
|
return [];
|
||||||
// console.log('合同文档数据',response.data.documents);
|
|
||||||
return response.data.documents;
|
|
||||||
}
|
}
|
||||||
} else if (reviewType === 'record') {
|
|
||||||
// 获取类型 2 的文档
|
const baseParams = {
|
||||||
const response1 = await getDocuments({
|
page: 1,
|
||||||
...documentSearchParams,
|
pageSize: 10,
|
||||||
documentType: '2'
|
token: frontendJWT
|
||||||
|
};
|
||||||
|
|
||||||
|
// 直接使用 documentTypeIds 查询
|
||||||
|
if (documentTypeIds && documentTypeIds.length > 0) {
|
||||||
|
const response = await getDocumentsListFromAPI({
|
||||||
|
...baseParams,
|
||||||
|
documentTypeIds: documentTypeIds
|
||||||
});
|
});
|
||||||
|
|
||||||
// 获取类型 3 的文档
|
if (!response.error && response.data) {
|
||||||
const response2 = await getDocuments({
|
return response.data.documents;
|
||||||
...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);
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 没有特定类型,获取所有文档
|
// 没有指定类型,获取所有文档
|
||||||
const response = await getDocuments(documentSearchParams);
|
const response = await getDocumentsListFromAPI(baseParams);
|
||||||
if (!response.error && response.data) {
|
if (!response.error && response.data) {
|
||||||
return response.data.documents;
|
return response.data.documents;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return []; // 默认返回空数组
|
return []; // 默认返回空数组
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('加载文档数据失败:', 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定时器,每10秒自动获取最近文档数据
|
||||||
// 按照定时器更新最近文档
|
// 按照定时器更新最近文档
|
||||||
useEffect(() => {
|
// useEffect(() => {
|
||||||
// 避免在加载状态下进行自动更新
|
// // 避免在加载状态下进行自动更新
|
||||||
if (isLoading) return;
|
// if (loadingStates.recentFiles) return;
|
||||||
|
|
||||||
const fetchLatestDocuments = async () => {
|
// const fetchLatestDocuments = async () => {
|
||||||
const reviewType = sessionStorage.getItem('reviewType');
|
// const documentTypeIdsStr = sessionStorage.getItem('documentTypeIds');
|
||||||
const docs = await loadDocuments(reviewType);
|
// const documentTypeIds = documentTypeIdsStr ? JSON.parse(documentTypeIdsStr) : [];
|
||||||
setRecentFiles(docs);
|
// const docs = await loadDocuments(documentTypeIds);
|
||||||
};
|
// setRecentFiles(docs);
|
||||||
|
// };
|
||||||
|
|
||||||
// 设置10秒的定时器
|
// // 设置10秒的定时器
|
||||||
const timerID = setInterval(fetchLatestDocuments, 10000);
|
// const timerID = setInterval(fetchLatestDocuments, 10000);
|
||||||
|
|
||||||
// 组件卸载时清除定时器
|
// // 组件卸载时清除定时器
|
||||||
return () => {
|
// return () => {
|
||||||
clearInterval(timerID);
|
// clearInterval(timerID);
|
||||||
};
|
// };
|
||||||
}, [isLoading]); // 仅依赖 isLoading 状态
|
// }, [loadingStates.recentFiles]); // 仅依赖最近文档的loading状态
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="dashboard-container">
|
<div className="dashboard-container">
|
||||||
@@ -349,7 +353,10 @@ export default function Home() {
|
|||||||
</div>
|
</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">
|
<div className="stat-grid">
|
||||||
<StatCard
|
<StatCard
|
||||||
title="今日待审文件"
|
title="今日待审文件"
|
||||||
@@ -375,27 +382,143 @@ export default function Home() {
|
|||||||
trend={{ value: homeData.issuesGrowth.value, isUp: homeData.issuesGrowth.isUp }}
|
trend={{ value: homeData.issuesGrowth.value, isUp: homeData.issuesGrowth.isUp }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</Card>
|
</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">
|
<div className="shortcut-grid">
|
||||||
<ShortcutItem icon="ri-upload-cloud-line" label="上传文件" to="/files/upload" />
|
<ShortcutItem icon="ri-upload-cloud-line" label="上传文件" to="/files/upload" />
|
||||||
<ShortcutItem icon="ri-file-list-3-line" label="文档列表" to="/documents/list" />
|
<ShortcutItem icon="ri-file-list-3-line" label="文档列表" to="/documents/list" />
|
||||||
<ShortcutItem icon="ri-list-check-3" label="评查点列表" to="/rules" />
|
<ShortcutItem icon="ri-list-check-3" label="评查点列表" to="/rules" />
|
||||||
<ShortcutItem icon="ri-folder-open-line" label="评查点分组" to="/rule-groups" />
|
<ShortcutItem icon="ri-folder-open-line" label="评查点分组" to="/rule-groups" />
|
||||||
</div>
|
</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>
|
</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
|
<Card
|
||||||
title="最近文档"
|
title="最近文档"
|
||||||
icon="ri-file-list-3-line"
|
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"
|
className="mt-6"
|
||||||
>
|
>
|
||||||
|
{loadingStates.recentFiles ? (
|
||||||
|
<LoadingSkeleton type="list" rows={5} />
|
||||||
|
) : (
|
||||||
<div className="doc-list">
|
<div className="doc-list">
|
||||||
{recentFiles.map((file: DocumentUI) => (
|
{recentFiles.length > 0 ? (
|
||||||
|
recentFiles.map((file: DocumentUI) => (
|
||||||
<div key={file.id} className="doc-item">
|
<div key={file.id} className="doc-item">
|
||||||
<div className="doc-info">
|
<div className="doc-info">
|
||||||
<FileTag
|
<FileTag
|
||||||
@@ -432,9 +555,18 @@ export default function Home() {
|
|||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -486,3 +618,62 @@ function ShortcutItem({ icon, label, to }: ShortcutItemProps) {
|
|||||||
</Button>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { MetaFunction, type LoaderFunctionArgs, type ActionFunctionArgs } from "@remix-run/node";
|
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 { useState, useEffect } from "react";
|
||||||
import indexStyles from "~/styles/pages/prompts_index.css?url";
|
import indexStyles from "~/styles/pages/prompts_index.css?url";
|
||||||
import { Card } from "~/components/ui/Card";
|
import { Card } from "~/components/ui/Card";
|
||||||
@@ -138,6 +138,17 @@ export default function PromptsIndex() {
|
|||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const fetcher = useFetcher<ActionData>();
|
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 handleNameSearch = (value: string) => {
|
||||||
const newParams = new URLSearchParams(searchParams);
|
const newParams = new URLSearchParams(searchParams);
|
||||||
@@ -247,7 +258,7 @@ export default function PromptsIndex() {
|
|||||||
{
|
{
|
||||||
title: "模板名称",
|
title: "模板名称",
|
||||||
key: "template_name",
|
key: "template_name",
|
||||||
width: "300px",
|
width: "25%",
|
||||||
render: (_: unknown, record: PromptTemplateUI) => (
|
render: (_: unknown, record: PromptTemplateUI) => (
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<i className="ri-file-list-line text-primary mr-2"></i>
|
<i className="ri-file-list-line text-primary mr-2"></i>
|
||||||
@@ -258,7 +269,7 @@ export default function PromptsIndex() {
|
|||||||
{
|
{
|
||||||
title: "类型",
|
title: "类型",
|
||||||
key: "template_type",
|
key: "template_type",
|
||||||
width: "120px",
|
width: "100px",
|
||||||
render: (_: unknown, record: PromptTemplateUI) => {
|
render: (_: unknown, record: PromptTemplateUI) => {
|
||||||
let typeText = '';
|
let typeText = '';
|
||||||
let typeClass = '';
|
let typeClass = '';
|
||||||
@@ -292,8 +303,9 @@ export default function PromptsIndex() {
|
|||||||
{
|
{
|
||||||
title: "描述",
|
title: "描述",
|
||||||
key: "description",
|
key: "description",
|
||||||
|
width: "30%",
|
||||||
render: (_: unknown, record: PromptTemplateUI) => (
|
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}
|
{record.description}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -301,13 +313,13 @@ export default function PromptsIndex() {
|
|||||||
{
|
{
|
||||||
title: "版本",
|
title: "版本",
|
||||||
key: "version",
|
key: "version",
|
||||||
width: "80px",
|
width: "70px",
|
||||||
render: (_: unknown, record: PromptTemplateUI) => record.version
|
render: (_: unknown, record: PromptTemplateUI) => record.version
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "状态",
|
title: "状态",
|
||||||
key: "status",
|
key: "status",
|
||||||
width: "100px",
|
width: "110px",
|
||||||
render: (_: unknown, record: PromptTemplateUI) => {
|
render: (_: unknown, record: PromptTemplateUI) => {
|
||||||
let statusText = '';
|
let statusText = '';
|
||||||
let statusClass = '';
|
let statusClass = '';
|
||||||
@@ -333,7 +345,7 @@ export default function PromptsIndex() {
|
|||||||
{
|
{
|
||||||
title: "创建者",
|
title: "创建者",
|
||||||
key: "created_by",
|
key: "created_by",
|
||||||
width: "100px",
|
width: "120px",
|
||||||
render: (_: unknown, record: PromptTemplateUI) => (
|
render: (_: unknown, record: PromptTemplateUI) => (
|
||||||
// <span className="text-secondary">用户 {record.created_by}</span>
|
// <span className="text-secondary">用户 {record.created_by}</span>
|
||||||
<span className="text-secondary">{record.created_by_username}</span>
|
<span className="text-secondary">{record.created_by_username}</span>
|
||||||
@@ -342,7 +354,7 @@ export default function PromptsIndex() {
|
|||||||
{
|
{
|
||||||
title: "操作",
|
title: "操作",
|
||||||
key: "operation",
|
key: "operation",
|
||||||
width: "150px",
|
width: "160px",
|
||||||
render: (_: unknown, record: PromptTemplateUI) => (
|
render: (_: unknown, record: PromptTemplateUI) => (
|
||||||
<div>
|
<div>
|
||||||
{record.status === 'system' ? (
|
{record.status === 'system' ? (
|
||||||
@@ -353,21 +365,24 @@ export default function PromptsIndex() {
|
|||||||
>
|
>
|
||||||
<i className="ri-eye-line"></i> 查看
|
<i className="ri-eye-line"></i> 查看
|
||||||
</button>
|
</button>
|
||||||
|
{hasEditPermission && (
|
||||||
<button
|
<button
|
||||||
className="operation-btn text-primary"
|
className="operation-btn text-primary"
|
||||||
onClick={() => handleCloneTemplate(record.id)}
|
onClick={() => handleCloneTemplate(record.id)}
|
||||||
>
|
>
|
||||||
<i className="ri-file-copy-line"></i> 复制
|
<i className="ri-file-copy-line"></i> 复制
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
className="operation-btn text-primary"
|
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>
|
</button>
|
||||||
|
{hasEditPermission && (
|
||||||
<button
|
<button
|
||||||
className="operation-btn text-error"
|
className="operation-btn text-error"
|
||||||
onClick={() => handleDeleteTemplate(record.id)}
|
onClick={() => handleDeleteTemplate(record.id)}
|
||||||
@@ -375,6 +390,7 @@ export default function PromptsIndex() {
|
|||||||
>
|
>
|
||||||
<i className="ri-delete-bin-line"></i> 删除
|
<i className="ri-delete-bin-line"></i> 删除
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -388,6 +404,7 @@ export default function PromptsIndex() {
|
|||||||
<div className="page-header">
|
<div className="page-header">
|
||||||
<h2 className="page-title">提示词模板管理</h2>
|
<h2 className="page-title">提示词模板管理</h2>
|
||||||
<div>
|
<div>
|
||||||
|
{hasEditPermission && (
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
icon="ri-add-line"
|
icon="ri-add-line"
|
||||||
@@ -395,6 +412,7 @@ export default function PromptsIndex() {
|
|||||||
>
|
>
|
||||||
新增提示词模板
|
新增提示词模板
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -317,9 +317,9 @@ export default function PromptsNew() {
|
|||||||
|
|
||||||
const newFormData = {
|
const newFormData = {
|
||||||
...template,
|
...template,
|
||||||
id: mode === "clone" ? "" : template.id,
|
id: template.id,
|
||||||
template_name: mode === "clone" ? `${template.template_name} (副本)` : template.template_name,
|
template_name: template.template_name,
|
||||||
version: mode === "clone" ? "v1.0" : template.version,
|
version: template.version,
|
||||||
variables: variablesJson
|
variables: variablesJson
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -348,8 +348,6 @@ export default function PromptsNew() {
|
|||||||
setPageTitle("查看提示词模板");
|
setPageTitle("查看提示词模板");
|
||||||
} else if (mode === "edit") {
|
} else if (mode === "edit") {
|
||||||
setPageTitle("编辑提示词模板");
|
setPageTitle("编辑提示词模板");
|
||||||
} else if (mode === "clone") {
|
|
||||||
setPageTitle("复制创建提示词模板");
|
|
||||||
} else {
|
} else {
|
||||||
setPageTitle("新增提示词模板");
|
setPageTitle("新增提示词模板");
|
||||||
}
|
}
|
||||||
@@ -485,7 +483,7 @@ export default function PromptsNew() {
|
|||||||
<div className="alert alert-info">
|
<div className="alert alert-info">
|
||||||
<i className="ri-information-line"></i>
|
<i className="ri-information-line"></i>
|
||||||
<div>
|
<div>
|
||||||
<div>您正在查看系统预设模板,此模板不可修改。如需基于此模板创建新模板,请点击"复制创建"按钮。</div>
|
<div>您正在查看系统预设模板,此模板不可修改。</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -780,17 +778,7 @@ export default function PromptsNew() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 底部按钮区域 */}
|
{/* 底部按钮区域 */}
|
||||||
<div className="flex justify-between mt-6">
|
<div className="flex justify-end 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>
|
|
||||||
<Link to="/prompts" className="mr-2">
|
<Link to="/prompts" className="mr-2">
|
||||||
<button type="button" className="ant-btn ant-btn-default">
|
<button type="button" className="ant-btn ant-btn-default">
|
||||||
<i className="ri-close-line"></i> 取消
|
<i className="ri-close-line"></i> 取消
|
||||||
@@ -807,7 +795,6 @@ export default function PromptsNew() {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</Form>
|
</Form>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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%;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user