feat: 1.修改提示词模板的不用角色的操作权限。
2. 对接数据看板的数据。 3. 添加入口模块管理的页面。
This commit is contained in:
+56
-21
@@ -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 头
|
||||
*/
|
||||
@@ -104,10 +118,18 @@ axiosInstance.interceptors.response.use(
|
||||
},
|
||||
(error) => {
|
||||
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,7 +230,12 @@ function buildUrl(endpoint: string, params?: QueryParams): string {
|
||||
if (params) {
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined) {
|
||||
url.searchParams.append(key, String(value));
|
||||
// 处理数组参数:使用逗号分隔
|
||||
if (Array.isArray(value)) {
|
||||
url.searchParams.append(key, value.join(','));
|
||||
} else {
|
||||
url.searchParams.append(key, String(value));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -292,16 +319,19 @@ export async function apiRequest<T>(
|
||||
try {
|
||||
// 构建 URL
|
||||
const url = buildUrl(endpoint, params);
|
||||
|
||||
// 设置默认请求头
|
||||
const headers = options.headers || {};
|
||||
if (!headers['Content-Type'] && options.method !== 'GET') {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
|
||||
// 只有在 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';
|
||||
}
|
||||
}
|
||||
if (!headers['Accept']) {
|
||||
headers['Accept'] = 'application/json';
|
||||
}
|
||||
|
||||
|
||||
// 针对 PostgREST 的额外处理
|
||||
if (endpoint.includes('evaluation_point_groups') && (options.method === 'POST' || options.method === 'PATCH')) {
|
||||
// console.log('使用 PostgREST 特定配置处理请求');
|
||||
@@ -315,10 +345,10 @@ export async function apiRequest<T>(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// console.log(`📦 axios-client.ts->请求URL: ${url}`);
|
||||
// console.log(`axios-client.ts->发送 ${options.method || 'GET'} 请求到: ${url}`);
|
||||
|
||||
|
||||
// 处理body参数,转换为data
|
||||
if (options.body) {
|
||||
// console.log(`axios-client.ts->请求体: \n${options.body}`);
|
||||
@@ -327,20 +357,25 @@ 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
|
||||
};
|
||||
|
||||
// 🔍 调试:打印 Authorization 头
|
||||
if (headers['Authorization']) {
|
||||
// console.log('🔑 [apiRequest] 请求包含 Authorization 头:', headers['Authorization'].substring(0, 20) + '...');
|
||||
} else {
|
||||
console.warn('⚠️ [apiRequest] 请求缺少 Authorization 头!headers:', Object.keys(headers));
|
||||
// 只有在 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)}`);
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { postgrestGet, postgrestPost, type PostgrestParams } from "../postgrest-client";
|
||||
import { apiRequest } from "../axios-client";
|
||||
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,28 +140,26 @@ export function BasicInfo({ onChange, initialData, evaluationPointGroups = [], r
|
||||
|
||||
// 处理条款号输入框失去焦点
|
||||
const handleLawArticlesBlur = () => {
|
||||
if (!lawArticlesText) return;
|
||||
|
||||
// 将输入的文本转换为数组
|
||||
const articles = lawArticlesText
|
||||
.split(',')
|
||||
.map(article => article.trim())
|
||||
.filter(article => article !== '');
|
||||
|
||||
|
||||
// 创建一个新的引用法律对象,保留现有字段
|
||||
const referencesLaws = {
|
||||
...(formData.references_laws || {}),
|
||||
articles: articles.length > 0 ? articles : []
|
||||
articles: articles // ✅ 清空时会是空数组
|
||||
};
|
||||
|
||||
|
||||
// 更新表单数据
|
||||
const newData = {
|
||||
...formData,
|
||||
references_laws: referencesLaws
|
||||
};
|
||||
|
||||
|
||||
setFormData(newData);
|
||||
|
||||
|
||||
if (onChange) {
|
||||
onChange(newData);
|
||||
}
|
||||
@@ -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,20 +31,23 @@ 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);
|
||||
}
|
||||
return String(record[rowKey]);
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<div className={`ant-table-wrapper ${className} ${loading ? 'ant-table-loading' : ''}`}>
|
||||
<table className={`ant-table ${bordered ? 'ant-table-bordered' : ''}`}>
|
||||
<thead>
|
||||
<tr>
|
||||
{columns.map((column, index) => (
|
||||
<th
|
||||
<th
|
||||
key={column.key || column.dataIndex?.toString() || index}
|
||||
className={column.className}
|
||||
style={{
|
||||
@@ -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) : {})}
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
+383
-192
@@ -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,18 +104,19 @@ 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(() => {
|
||||
console.log('服务器返回的用户角色:', serverUserRole);
|
||||
@@ -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');
|
||||
@@ -157,7 +156,7 @@ export default function Home() {
|
||||
// 可以根据需要清除其他会话数据
|
||||
sessionStorage.clear();
|
||||
}
|
||||
|
||||
|
||||
// 使用Form组件提交登出请求
|
||||
const form = document.getElementById('logout-form') as HTMLFormElement;
|
||||
if (form) {
|
||||
@@ -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);
|
||||
setHomeData(newHomeData);
|
||||
|
||||
// 加载文档数据
|
||||
const docs = await loadDocuments(reviewType);
|
||||
setRecentFiles(docs);
|
||||
|
||||
setIsLoading(false);
|
||||
// 从 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 }));
|
||||
}
|
||||
})(),
|
||||
|
||||
// 加载最近文档
|
||||
(async () => {
|
||||
try {
|
||||
const docs = await loadDocuments(documentTypeIds);
|
||||
setRecentFiles(docs);
|
||||
setLoadingStates(prev => ({ ...prev, recentFiles: false }));
|
||||
} catch (error) {
|
||||
console.error('加载最近文档失败:', error);
|
||||
setLoadingStates(prev => ({ ...prev, recentFiles: 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();
|
||||
}, []); // 仅在组件挂载时执行一次
|
||||
|
||||
// 从 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;
|
||||
};
|
||||
|
||||
// 加载文档数据的函数
|
||||
const loadDocuments = async (reviewType: string | null) => {
|
||||
const loadDocuments = async (documentTypeIds: number[]) => {
|
||||
try {
|
||||
const documentSearchParams: DocumentSearchParams = {
|
||||
if (!frontendJWT) {
|
||||
console.error('缺少 JWT token');
|
||||
return [];
|
||||
}
|
||||
|
||||
const baseParams = {
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
userId: userInfo.user_id,
|
||||
token: frontendJWT || undefined
|
||||
token: frontendJWT
|
||||
};
|
||||
|
||||
// 根据 reviewType 添加过滤条件
|
||||
if (reviewType === 'contract') {
|
||||
documentSearchParams.documentType = '1';
|
||||
|
||||
const response = await getDocuments(documentSearchParams);
|
||||
|
||||
// 直接使用 documentTypeIds 查询
|
||||
if (documentTypeIds && documentTypeIds.length > 0) {
|
||||
const response = await getDocumentsListFromAPI({
|
||||
...baseParams,
|
||||
documentTypeIds: documentTypeIds
|
||||
});
|
||||
|
||||
if (!response.error && response.data) {
|
||||
// console.log('合同文档数据',response.data.documents);
|
||||
return response.data.documents;
|
||||
}
|
||||
} else if (reviewType === 'record') {
|
||||
// 获取类型 2 的文档
|
||||
const response1 = await getDocuments({
|
||||
...documentSearchParams,
|
||||
documentType: '2'
|
||||
});
|
||||
|
||||
// 获取类型 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);
|
||||
}
|
||||
} 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;
|
||||
|
||||
const fetchLatestDocuments = async () => {
|
||||
const reviewType = sessionStorage.getItem('reviewType');
|
||||
const docs = await loadDocuments(reviewType);
|
||||
setRecentFiles(docs);
|
||||
};
|
||||
|
||||
// 设置10秒的定时器
|
||||
const timerID = setInterval(fetchLatestDocuments, 10000);
|
||||
|
||||
// 组件卸载时清除定时器
|
||||
return () => {
|
||||
clearInterval(timerID);
|
||||
};
|
||||
}, [isLoading]); // 仅依赖 isLoading 状态
|
||||
// useEffect(() => {
|
||||
// // 避免在加载状态下进行自动更新
|
||||
// if (loadingStates.recentFiles) return;
|
||||
|
||||
// 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);
|
||||
|
||||
// // 组件卸载时清除定时器
|
||||
// return () => {
|
||||
// clearInterval(timerID);
|
||||
// };
|
||||
// }, [loadingStates.recentFiles]); // 仅依赖最近文档的loading状态
|
||||
|
||||
return (
|
||||
<div className="dashboard-container">
|
||||
@@ -349,92 +353,220 @@ export default function Home() {
|
||||
</div>
|
||||
|
||||
{/* 统计卡片区域 */}
|
||||
<Card title="统计信息" icon="ri-bar-chart-line" className="mt-6 transition-all duration-200 hover:shadow-[0_4px_15px_rgba(0,0,0,0.1)]">
|
||||
<div className="stat-grid ">
|
||||
<StatCard
|
||||
title="今日待审文件"
|
||||
value={homeData.todayPendingFiles}
|
||||
icon="ri-inbox-line"
|
||||
/>
|
||||
<StatCard
|
||||
title="本月已审核文件"
|
||||
value={homeData.monthlyReviewedFiles}
|
||||
icon="ri-file-search-line"
|
||||
trend={{ value: homeData.monthlyReviewGrowth.value, isUp: homeData.monthlyReviewGrowth.isUp }}
|
||||
/>
|
||||
<StatCard
|
||||
title="本月审核通过率"
|
||||
value={homeData.monthlyPassRate}
|
||||
icon="ri-percent-line"
|
||||
trend={{ value: homeData.passRateGrowth.value, isUp: homeData.passRateGrowth.isUp }}
|
||||
/>
|
||||
<StatCard
|
||||
title="本月问题检出数"
|
||||
value={homeData.issuesDetected}
|
||||
icon="ri-error-warning-line"
|
||||
trend={{ value: homeData.issuesGrowth.value, isUp: homeData.issuesGrowth.isUp }}
|
||||
/>
|
||||
</div>
|
||||
<Card title="统计信息" icon="ri-bar-chart-line" className="transition-all duration-200 hover:shadow-[0_4px_15px_rgba(0,0,0,0.1)]">
|
||||
{loadingStates.stats ? (
|
||||
<LoadingSkeleton type="stats" />
|
||||
) : (
|
||||
<div className="stat-grid">
|
||||
<StatCard
|
||||
title="今日待审文件"
|
||||
value={homeData.todayPendingFiles}
|
||||
icon="ri-inbox-line"
|
||||
/>
|
||||
<StatCard
|
||||
title="本月已审核文件"
|
||||
value={homeData.monthlyReviewedFiles}
|
||||
icon="ri-file-search-line"
|
||||
trend={{ value: homeData.monthlyReviewGrowth.value, isUp: homeData.monthlyReviewGrowth.isUp }}
|
||||
/>
|
||||
<StatCard
|
||||
title="本月审核通过率"
|
||||
value={homeData.monthlyPassRate}
|
||||
icon="ri-percent-line"
|
||||
trend={{ value: homeData.passRateGrowth.value, isUp: homeData.passRateGrowth.isUp }}
|
||||
/>
|
||||
<StatCard
|
||||
title="本月问题检出数"
|
||||
value={homeData.issuesDetected}
|
||||
icon="ri-error-warning-line"
|
||||
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>
|
||||
</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"
|
||||
>
|
||||
<div className="doc-list">
|
||||
{recentFiles.map((file: DocumentUI) => (
|
||||
<div key={file.id} className="doc-item">
|
||||
<div className="doc-info">
|
||||
<FileTag
|
||||
extension={file.name.endsWith('.pdf') ? 'pdf' : 'docx'}
|
||||
showIcon={true}
|
||||
showText={false}
|
||||
showBackground={false}
|
||||
size="lg"
|
||||
className="mr-2"
|
||||
/>
|
||||
<div>
|
||||
<div className="doc-name">{file.name}</div>
|
||||
<div className="doc-meta">
|
||||
<Tag size="sm" className="mr-2">
|
||||
{file.typeName}
|
||||
</Tag>
|
||||
<span className="text-gray-500">·</span>
|
||||
<span className="ml-2 text-gray-500">{file.updatedAt}</span>
|
||||
{loadingStates.recentFiles ? (
|
||||
<LoadingSkeleton type="list" rows={5} />
|
||||
) : (
|
||||
<div className="doc-list">
|
||||
{recentFiles.length > 0 ? (
|
||||
recentFiles.map((file: DocumentUI) => (
|
||||
<div key={file.id} className="doc-item">
|
||||
<div className="doc-info">
|
||||
<FileTag
|
||||
extension={file.name.endsWith('.pdf') ? 'pdf' : 'docx'}
|
||||
showIcon={true}
|
||||
showText={false}
|
||||
showBackground={false}
|
||||
size="lg"
|
||||
className="mr-2"
|
||||
/>
|
||||
<div>
|
||||
<div className="doc-name">{file.name}</div>
|
||||
<div className="doc-meta">
|
||||
<Tag size="sm" className="mr-2">
|
||||
{file.typeName}
|
||||
</Tag>
|
||||
<span className="text-gray-500">·</span>
|
||||
<span className="ml-2 text-gray-500">{file.updatedAt}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="doc-status">
|
||||
{(() => {
|
||||
const fileStatus = file.fileStatus || "-";
|
||||
const status = fileProcessingStatusOptions.find(s => s.value === fileStatus) ||
|
||||
fileProcessingStatusOptions[0];
|
||||
const isSpinning = fileStatus !== "Processed";
|
||||
return (
|
||||
<div className={`inline-flex items-center px-2 py-1 rounded-full text-xs bg-${status.color}-100 text-${status.color}-800`}>
|
||||
<i className={`${status.icon} ${isSpinning ? "animate-spin" : ""} mr-1`}></i>
|
||||
<span>{status.label}</span>
|
||||
</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 className="doc-status">
|
||||
{(() => {
|
||||
const fileStatus = file.fileStatus || "-";
|
||||
const status = fileProcessingStatusOptions.find(s => s.value === fileStatus) ||
|
||||
fileProcessingStatusOptions[0];
|
||||
const isSpinning = fileStatus !== "Processed";
|
||||
return (
|
||||
<div className={`inline-flex items-center px-2 py-1 rounded-full text-xs bg-${status.color}-100 text-${status.color}-800`}>
|
||||
<i className={`${status.icon} ${isSpinning ? "animate-spin" : ""} mr-1`}></i>
|
||||
<span>{status.label}</span>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -476,9 +608,9 @@ interface ShortcutItemProps {
|
||||
|
||||
function ShortcutItem({ icon, label, to }: ShortcutItemProps) {
|
||||
return (
|
||||
<Button
|
||||
to={to}
|
||||
type="default"
|
||||
<Button
|
||||
to={to}
|
||||
type="default"
|
||||
className="shortcut-item"
|
||||
>
|
||||
<i className={`${icon} shortcut-icon text-2xl`}></i>
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
@@ -137,7 +137,18 @@ export default function PromptsIndex() {
|
||||
const { templates, total, currentPage, pageSize, error } = useLoaderData<typeof loader>();
|
||||
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,11 +269,11 @@ export default function PromptsIndex() {
|
||||
{
|
||||
title: "类型",
|
||||
key: "template_type",
|
||||
width: "120px",
|
||||
width: "100px",
|
||||
render: (_: unknown, record: PromptTemplateUI) => {
|
||||
let typeText = '';
|
||||
let typeClass = '';
|
||||
|
||||
|
||||
switch (record.template_type) {
|
||||
case 'LLM_Extraction':
|
||||
typeText = '抽取';
|
||||
@@ -285,15 +296,16 @@ export default function PromptsIndex() {
|
||||
typeClass = 'type-common';
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
return <span className={`type-badge ${typeClass}`}>{typeText}</span>;
|
||||
}
|
||||
},
|
||||
{
|
||||
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,17 +313,17 @@ 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 = '';
|
||||
|
||||
|
||||
switch (record.status) {
|
||||
case 'active':
|
||||
statusText = '启用';
|
||||
@@ -326,14 +338,14 @@ export default function PromptsIndex() {
|
||||
statusClass = 'status-system';
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
return <span className={`status-badge ${statusClass}`}>{statusText}</span>;
|
||||
}
|
||||
},
|
||||
{
|
||||
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,28 +365,32 @@ export default function PromptsIndex() {
|
||||
>
|
||||
<i className="ri-eye-line"></i> 查看
|
||||
</button>
|
||||
<button
|
||||
className="operation-btn text-primary"
|
||||
onClick={() => handleCloneTemplate(record.id)}
|
||||
>
|
||||
<i className="ri-file-copy-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> 编辑
|
||||
</button>
|
||||
<button
|
||||
className="operation-btn text-error"
|
||||
onClick={() => handleDeleteTemplate(record.id)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<i className="ri-delete-bin-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)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<i className="ri-delete-bin-line"></i> 删除
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -388,13 +404,15 @@ export default function PromptsIndex() {
|
||||
<div className="page-header">
|
||||
<h2 className="page-title">提示词模板管理</h2>
|
||||
<div>
|
||||
<Button
|
||||
type="primary"
|
||||
icon="ri-add-line"
|
||||
onClick={() => navigate("/prompts/new")}
|
||||
>
|
||||
新增提示词模板
|
||||
</Button>
|
||||
{hasEditPermission && (
|
||||
<Button
|
||||
type="primary"
|
||||
icon="ri-add-line"
|
||||
onClick={() => navigate("/prompts/new")}
|
||||
>
|
||||
新增提示词模板
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
+21
-34
@@ -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
|
||||
};
|
||||
|
||||
@@ -343,13 +343,11 @@ export default function PromptsNew() {
|
||||
}
|
||||
|
||||
setIsViewMode(mode === "view");
|
||||
|
||||
|
||||
if (mode === "view") {
|
||||
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,33 +778,22 @@ 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>
|
||||
<Link to="/prompts" className="mr-2">
|
||||
<button type="button" className="ant-btn ant-btn-default">
|
||||
<i className="ri-close-line"></i> 取消
|
||||
</button>
|
||||
</Link>
|
||||
{!isViewMode && (
|
||||
<button
|
||||
form="template-form"
|
||||
className="ant-btn ant-btn-primary"
|
||||
disabled={isSubmitting}
|
||||
id="save-btn-bottom"
|
||||
>
|
||||
<i className="ri-save-line"></i> {isSubmitting ? "保存中..." : "保存"}
|
||||
</button>
|
||||
)}
|
||||
</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> 取消
|
||||
</button>
|
||||
</Link>
|
||||
{!isViewMode && (
|
||||
<button
|
||||
form="template-form"
|
||||
className="ant-btn ant-btn-primary"
|
||||
disabled={isSubmitting}
|
||||
id="save-btn-bottom"
|
||||
>
|
||||
<i className="ri-save-line"></i> {isSubmitting ? "保存中..." : "保存"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</Form>
|
||||
</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