feat: 1.修改提示词模板的不用角色的操作权限。

2. 对接数据看板的数据。
3. 添加入口模块管理的页面。
This commit is contained in:
2025-11-21 17:16:07 +08:00
parent 3850d05bdd
commit dab0835605
13 changed files with 1877 additions and 297 deletions
+52 -17
View File
@@ -12,7 +12,7 @@ export type ApiResponse<T> = {
headers?: Record<string, string>;
};
export type QueryParams = Record<string, string | number | boolean | undefined>;
export type QueryParams = Record<string, string | number | boolean | undefined | number[] | string[]>;
// 获取 API 基础 URL (从配置文件导入)
// const API_BASE_URL = 'http://172.16.0.58:8008';
@@ -52,6 +52,12 @@ const AUTH_WHITELIST = [
'/oauth/userinfo'
];
// 错误容忍白名单 - 这些接口即使返回 401/403 也不触发强制登出
const ERROR_TOLERANT_WHITELIST = [
'/admin/statistics/top-error-points',
'/admin/statistics/top-risk-users'
];
/**
* 检查请求URL是否在白名单中
*/
@@ -60,6 +66,14 @@ function isInAuthWhitelist(url?: string): boolean {
return AUTH_WHITELIST.some(path => url.includes(path));
}
/**
* 检查请求URL是否在错误容忍白名单中
*/
function isInErrorTolerantWhitelist(url?: string): boolean {
if (!url) return false;
return ERROR_TOLERANT_WHITELIST.some(path => url.includes(path));
}
/**
* 请求拦截器 - 自动添加 Authorization 头
*/
@@ -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));
}
}
});
}
@@ -293,13 +320,16 @@ export async function apiRequest<T>(
// 构建 URL
const url = buildUrl(endpoint, params);
// 设置默认请求头
const headers = options.headers || {};
if (!headers['Content-Type'] && options.method !== 'GET') {
headers['Content-Type'] = 'application/json';
}
if (!headers['Accept']) {
headers['Accept'] = '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';
}
}
// 针对 PostgREST 的额外处理
@@ -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)}`);
+242
View File
@@ -0,0 +1,242 @@
/**
* 入口模块管理 API 客户端
* 提供入口模块的增删改查功能
*/
import { postgrestGet, postgrestPost, postgrestPut, postgrestDelete } from "../postgrest-client";
/**
* 入口模块数据接口
*/
export interface EntryModule {
id?: number;
name: string;
description?: string;
path?: string; // logo图片路径
areas?: string[]; // 地区数组
created_at?: string;
updated_at?: string;
}
/**
* 入口模块搜索参数
*/
export interface EntryModuleSearchParams {
name?: string;
area?: string;
page?: number;
pageSize?: number;
}
/**
* 入口模块列表响应
*/
export interface EntryModulesResponse {
modules: EntryModule[];
total: number;
}
/**
* 获取入口模块列表
* @param searchParams 搜索参数
* @param jwtToken JWT令牌
* @returns 入口模块列表和总数
*/
export async function getEntryModules(
searchParams: EntryModuleSearchParams = {},
jwtToken?: string | null
): Promise<{ data?: EntryModulesResponse; error?: string }> {
try {
const { name, area, page = 1, pageSize = 10 } = searchParams;
// 构建过滤条件
const filter: Record<string, string> = {};
if (name) {
filter.name = `ilike.*${name}*`;
}
// 如果有地区筛选,使用 JSONB 查询
if (area) {
filter.areas = `cs.{"${area}"}`; // cs = contains (JSONB数组包含)
}
// 计算分页
const offset = (page - 1) * pageSize;
// 构建查询参数(一次请求获取数据和总数)
const queryParams: any = {
select: "*",
order: "created_at.desc",
limit: pageSize,
offset: offset,
headers: {
'Prefer': 'count=exact'
},
token: jwtToken
};
// 只在有过滤条件时添加 filter
if (Object.keys(filter).length > 0) {
queryParams.filter = filter;
}
// 获取分页数据
const result = await postgrestGet<EntryModule[]>("entry_modules", queryParams);
if (result.error) {
return { error: result.error };
}
// 从 Content-Range 头获取总数
let totalCount = 0;
const responseWithHeaders = result as {
data: unknown;
headers?: Record<string, string>;
};
if (responseWithHeaders.headers) {
const rangeHeader = responseWithHeaders.headers['content-range'];
if (rangeHeader) {
const total = rangeHeader.split('/')[1];
if (total !== '*') {
totalCount = parseInt(total, 10);
}
}
}
return {
data: {
modules: result.data || [],
total: totalCount || (result.data?.length || 0)
}
};
} catch (error) {
console.error("获取入口模块列表失败:", error);
return { error: error instanceof Error ? error.message : "获取入口模块列表失败" };
}
}
/**
* 根据ID获取入口模块
* @param id 入口模块ID
* @param jwtToken JWT令牌
* @returns 入口模块数据
*/
export async function getEntryModuleById(
id: number,
jwtToken?: string | null
): Promise<{ data?: EntryModule; error?: string }> {
try {
const result = await postgrestGet<EntryModule[]>("entry_modules", {
filter: { id: `eq.${id}` },
token: jwtToken
});
if (result.error) {
return { error: result.error };
}
const module = result.data?.[0];
if (!module) {
return { error: "入口模块不存在" };
}
return { data: module };
} catch (error) {
console.error("获取入口模块失败:", error);
return { error: error instanceof Error ? error.message : "获取入口模块失败" };
}
}
/**
* 创建入口模块
* @param module 入口模块数据
* @param jwtToken JWT令牌
* @returns 创建的入口模块
*/
export async function createEntryModule(
module: Omit<EntryModule, "id" | "created_at" | "updated_at">,
jwtToken?: string | null
): Promise<{ data?: EntryModule; error?: string }> {
try {
const result = await postgrestPost<EntryModule[], EntryModule>(
"entry_modules",
module as EntryModule,
jwtToken
);
if (result.error) {
return { error: result.error };
}
const createdModule = Array.isArray(result.data) ? result.data[0] : result.data;
return { data: createdModule as EntryModule };
} catch (error) {
console.error("创建入口模块失败:", error);
return { error: error instanceof Error ? error.message : "创建入口模块失败" };
}
}
/**
* 更新入口模块
* @param id 入口模块ID
* @param module 更新的入口模块数据
* @param jwtToken JWT令牌
* @returns 更新的入口模块
*/
export async function updateEntryModule(
id: number,
module: Partial<Omit<EntryModule, "id" | "created_at" | "updated_at">>,
jwtToken?: string | null
): Promise<{ data?: EntryModule; error?: string }> {
try {
const result = await postgrestPut<EntryModule[], Partial<EntryModule>>(
"entry_modules",
module,
{ id: `eq.${id}` },
jwtToken
);
if (result.error) {
return { error: result.error };
}
const updatedModule = Array.isArray(result.data) ? result.data[0] : result.data;
return { data: updatedModule as EntryModule };
} catch (error) {
console.error("更新入口模块失败:", error);
return { error: error instanceof Error ? error.message : "更新入口模块失败" };
}
}
/**
* 删除入口模块
* @param id 入口模块ID
* @param jwtToken JWT令牌
* @returns 是否成功
*/
export async function deleteEntryModule(
id: number,
jwtToken?: string | null
): Promise<{ success: boolean; error?: string }> {
try {
const result = await postgrestDelete(
"entry_modules",
{ id: `eq.${id}` },
jwtToken
);
if (result.error) {
return { success: false, error: result.error };
}
return { success: true };
} catch (error) {
console.error("删除入口模块失败:", error);
return {
success: false,
error: error instanceof Error ? error.message : "删除入口模块失败"
};
}
}
+4 -4
View File
@@ -507,12 +507,12 @@ export async function getDocumentsListFromAPI(searchParams: {
if (dateFrom) params.start_time = dateFrom;
if (dateTo) params.end_time = dateTo;
// 处理文档类型ID数组 - 传递为数组或单个值
// 处理文档类型ID数组 - 转换为逗号分隔的字符串
if (documentTypeIds && documentTypeIds.length > 0) {
params.type_id = documentTypeIds;
params.type_id = documentTypeIds.join(',');
}
console.log('📤 [getDocumentsListFromAPI] 请求参数:', params);
// console.log('📤 [getDocumentsListFromAPI] 请求参数:', params);
// 调用后端API
const axios = await import('axios').then(m => m.default);
@@ -529,7 +529,7 @@ export async function getDocumentsListFromAPI(searchParams: {
const totalCount = data.total || 0;
const totalPages = data.total_pages || 0;
console.log(`📥 [getDocumentsListFromAPI] 获取到 ${backendDocuments.length} 个文档,总数: ${totalCount}`);
// console.log(`📥 [getDocumentsListFromAPI] 获取到 ${backendDocuments.length} 个文档,总数: ${totalCount}`);
// 转换后端数据为前端 DocumentUI 格式
const convertedDocuments: DocumentUI[] = backendDocuments.map((doc: any) => {
+183
View File
@@ -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: [] };
}
}
+4 -3
View File
@@ -140,8 +140,6 @@ export function BasicInfo({ onChange, initialData, evaluationPointGroups = [], r
// 处理条款号输入框失去焦点
const handleLawArticlesBlur = () => {
if (!lawArticlesText) return;
// 将输入的文本转换为数组
const articles = lawArticlesText
.split(',')
@@ -151,7 +149,7 @@ export function BasicInfo({ onChange, initialData, evaluationPointGroups = [], r
// 创建一个新的引用法律对象,保留现有字段
const referencesLaws = {
...(formData.references_laws || {}),
articles: articles.length > 0 ? articles : []
articles: articles // ✅ 清空时会是空数组
};
// 更新表单数据
@@ -171,6 +169,9 @@ export function BasicInfo({ onChange, initialData, evaluationPointGroups = [], r
useEffect(() => {
if (formData.references_laws?.articles && formData.references_laws.articles.length > 0) {
setLawArticlesText(formData.references_laws.articles.join(','));
} else {
// ✅ 当 articles 为空时,也清空输入框
setLawArticlesText('');
}
}, [formData.references_laws?.articles]);
+5 -2
View File
@@ -31,6 +31,9 @@ export function Table<T extends Record<string, any>>({
className = '',
onRow,
}: TableProps<T>) {
// 防御性检查:确保 dataSource 始终是数组
const safeDataSource = dataSource || [];
const getRowKey = (record: T, index: number): string => {
if (typeof rowKey === 'function') {
return rowKey(record);
@@ -58,8 +61,8 @@ export function Table<T extends Record<string, any>>({
</tr>
</thead>
<tbody>
{dataSource.length > 0 ? (
dataSource.map((record, index) => (
{safeDataSource.length > 0 ? (
safeDataSource.map((record, index) => (
<tr
key={getRowKey(record, index)}
{...(onRow ? onRow(record, index) : {})}
+430
View File
@@ -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>
);
}
+405
View File
@@ -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">
JPGPNGGIF 5MB
</span>
</div>
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleLogoChange}
className="hidden"
/>
{logoPreview && (
<div className="mt-3">
<div className="inline-block border border-gray-300 rounded p-2">
<img
src={logoPreview}
alt="Logo预览"
className="h-24 w-24 object-contain"
/>
</div>
</div>
)}
</div>
</div>
{/* 适用地区 */}
<div className="form-item">
<label className="form-label">
<span className="text-red-500 mr-1">*</span>
</label>
<div className="flex flex-wrap gap-3">
{AREA_OPTIONS.map(option => (
<label
key={option.value}
className="flex items-center space-x-2 cursor-pointer"
>
<input
type="checkbox"
checked={selectedAreas.includes(option.value)}
onChange={() => handleAreaToggle(option.value)}
className="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
/>
<span className="text-sm text-gray-700">{option.label}</span>
</label>
))}
</div>
</div>
</div>
{/* 操作按钮 */}
<div className="form-actions mt-8 flex justify-end space-x-3">
<Button
type="default"
onClick={handleCancel}
disabled={isSubmitting}
>
</Button>
<Button
type="primary"
onClick={handleSubmit}
loading={isSubmitting}
disabled={isSubmitting}
>
{isSubmitting ? '提交中...' : (isEditMode ? '保存' : '创建')}
</Button>
</div>
</Card>
{/* 取消确认模态框 */}
<Modal
isOpen={showConfirmModal}
onClose={() => setShowConfirmModal(false)}
title="确认取消"
size="small"
footer={
<div className="flex justify-end space-x-3">
<Button
type="default"
onClick={() => setShowConfirmModal(false)}
>
</Button>
<Button
type="primary"
danger
onClick={confirmCancel}
>
</Button>
</div>
}
>
<p className="text-gray-700"></p>
</Modal>
</div>
);
}
+9
View File
@@ -0,0 +1,9 @@
import { Outlet } from "@remix-run/react";
/**
* 入口模块管理父路由
* 用于包裹子路由(列表页和新建/编辑页)
*/
export default function EntryModulesLayout() {
return <Outlet />;
}
+370 -179
View File
@@ -7,9 +7,9 @@ import { FileTag, links as fileTagLinks } from "~/components/ui/FileTag";
// import { FileTypeTag, links as fileTypeTagLinks } from "~/components/ui/FileTypeTag";
import { Tag } from "~/components/ui/Tag";
import homeStyles from "~/styles/pages/sys_overview.css?url";
import { getDocuments, type DocumentUI, type DocumentSearchParams } from "~/api/files/documents";
import { getDocumentsListFromAPI, type DocumentUI } from "~/api/files/documents";
import { useState, useEffect } from "react";
import { getHomeData } from "~/api/home/home";
import { getHomeData, getTopErrorPoints, getTopRiskUsers, type TopErrorPointsResponse, type TopRiskUsersResponse } from "~/api/home/home";
import dayjs from 'dayjs';
// import type { UserRole } from '~/api/login/auth.server';
import { type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/node";
@@ -69,7 +69,6 @@ export async function loader({ request }: LoaderFunctionArgs) {
issuesGrowth: { value: 0, isUp: true }
},
recentFiles: [],
reviewType: null,
userRole: userRole,
userInfo,
frontendJWT
@@ -105,17 +104,18 @@ export default function Home() {
date: '',
time: ''
});
const [isLoading, setIsLoading] = useState(true);
// const userRole = serverUserRole as UserRole;
// 🔑 防御性检查:如果 userInfo 不存在,重定向到登录页(理论上不应该发生,因为 loader 已经检查了)
if (!userInfo) {
console.error("❌ [Home] userInfo 不存在,重定向到登录页");
if (typeof window !== 'undefined') {
window.location.href = '/login';
}
return null;
}
// 独立的loading状态管理
const [loadingStates, setLoadingStates] = useState({
stats: true, // 统计信息
recentFiles: true, // 最近文档
errorPoints: true, // 高频错误评查点
riskUsers: true // 高风险用户
});
// 统计数据状态(初始时标记为不可用,加载后根据API响应更新)
const [topErrorPoints, setTopErrorPoints] = useState<TopErrorPointsResponse>({ available: false, total: 0, items: [] });
const [topRiskUsers, setTopRiskUsers] = useState<TopRiskUsersResponse>({ available: false, total: 0, items: [] });
// 打印服务器端传递的用户角色
useEffect(() => {
@@ -148,8 +148,7 @@ export default function Home() {
// 清除sessionStorage中的所有数据
if (typeof window !== 'undefined') {
sessionStorage.removeItem('userRole');
sessionStorage.removeItem('reviewType');
sessionStorage.removeItem('previousReviewType');
sessionStorage.removeItem('documentTypeIds');
sessionStorage.removeItem('frontendJWT');
sessionStorage.removeItem('userInfo');
sessionStorage.removeItem('accessToken');
@@ -168,82 +167,122 @@ export default function Home() {
}
};
// 在客户端挂载时,根据 sessionStorage 中的 reviewType 加载正确的数据
// 在客户端挂载时,根据 sessionStorage 中的 documentTypeIds 加载正确的数据
useEffect(() => {
const loadData = async () => {
try {
setIsLoading(true);
// 从 sessionStorage 获取 reviewType
const reviewType = sessionStorage.getItem('reviewType');
// 从 sessionStorage 获取 documentTypeIds
const documentTypeIdsStr = sessionStorage.getItem('documentTypeIds');
const documentTypeIds = documentTypeIdsStr ? JSON.parse(documentTypeIdsStr) : [];
// 加载主页数据
const newHomeData = await getHomeData(reviewType || undefined,userInfo.user_id, frontendJWT);
setHomeData(newHomeData);
// 从 documentTypeIds 推断 reviewType(用于 getHomeData
const reviewType = inferReviewType(documentTypeIds);
// 加载文档数据
const docs = await loadDocuments(reviewType);
setRecentFiles(docs);
// 并行加载所有数据,每个数据加载完成后立即更新对应的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 }));
}
})(),
setIsLoading(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';
// 直接使用 documentTypeIds 查询
if (documentTypeIds && documentTypeIds.length > 0) {
const response = await getDocumentsListFromAPI({
...baseParams,
documentTypeIds: documentTypeIds
});
const response = await getDocuments(documentSearchParams);
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;
// useEffect(() => {
// // 避免在加载状态下进行自动更新
// if (loadingStates.recentFiles) return;
const fetchLatestDocuments = async () => {
const reviewType = sessionStorage.getItem('reviewType');
const docs = await loadDocuments(reviewType);
setRecentFiles(docs);
};
// const fetchLatestDocuments = async () => {
// const documentTypeIdsStr = sessionStorage.getItem('documentTypeIds');
// const documentTypeIds = documentTypeIdsStr ? JSON.parse(documentTypeIdsStr) : [];
// const docs = await loadDocuments(documentTypeIds);
// setRecentFiles(docs);
// };
// 设置10秒的定时器
const timerID = setInterval(fetchLatestDocuments, 10000);
// // 设置10秒的定时器
// const timerID = setInterval(fetchLatestDocuments, 10000);
// 组件卸载时清除定时器
return () => {
clearInterval(timerID);
};
}, [isLoading]); // 仅依赖 isLoading 状态
// // 组件卸载时清除定时器
// return () => {
// clearInterval(timerID);
// };
// }, [loadingStates.recentFiles]); // 仅依赖最近文档的loading状态
return (
<div className="dashboard-container">
@@ -349,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>
);
}
@@ -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>
);
}
+48 -30
View File
@@ -1,5 +1,5 @@
import { MetaFunction, type LoaderFunctionArgs, type ActionFunctionArgs } from "@remix-run/node";
import { useSearchParams, useNavigate, useLoaderData, useFetcher } from "@remix-run/react";
import { useSearchParams, useNavigate, useLoaderData, useFetcher, useRouteLoaderData } from "@remix-run/react";
import { useState, useEffect } from "react";
import indexStyles from "~/styles/pages/prompts_index.css?url";
import { Card } from "~/components/ui/Card";
@@ -138,6 +138,17 @@ export default function PromptsIndex() {
const [isLoading, setIsLoading] = useState(false);
const fetcher = useFetcher<ActionData>();
// 获取用户角色并判断权限
const rootData = useRouteLoaderData("root") as { userRole: string };
const userRole = rootData?.userRole || 'common';
const hasEditPermission = userRole.toLowerCase().includes('provin');
// 调试信息
useEffect(() => {
console.log('📋 [Prompts] 用户角色:', userRole);
console.log('📋 [Prompts] 是否有编辑权限:', hasEditPermission);
}, [userRole, hasEditPermission]);
// 处理搜索名称
const handleNameSearch = (value: string) => {
const newParams = new URLSearchParams(searchParams);
@@ -247,7 +258,7 @@ export default function PromptsIndex() {
{
title: "模板名称",
key: "template_name",
width: "300px",
width: "25%",
render: (_: unknown, record: PromptTemplateUI) => (
<div className="flex items-center">
<i className="ri-file-list-line text-primary mr-2"></i>
@@ -258,7 +269,7 @@ export default function PromptsIndex() {
{
title: "类型",
key: "template_type",
width: "120px",
width: "100px",
render: (_: unknown, record: PromptTemplateUI) => {
let typeText = '';
let typeClass = '';
@@ -292,8 +303,9 @@ export default function PromptsIndex() {
{
title: "描述",
key: "description",
width: "30%",
render: (_: unknown, record: PromptTemplateUI) => (
<div className="text-secondary text-sm max-w-xs text-wrap" title={record.description}>
<div className="text-secondary text-sm text-wrap" title={record.description}>
{record.description}
</div>
)
@@ -301,13 +313,13 @@ export default function PromptsIndex() {
{
title: "版本",
key: "version",
width: "80px",
width: "70px",
render: (_: unknown, record: PromptTemplateUI) => record.version
},
{
title: "状态",
key: "status",
width: "100px",
width: "110px",
render: (_: unknown, record: PromptTemplateUI) => {
let statusText = '';
let statusClass = '';
@@ -333,7 +345,7 @@ export default function PromptsIndex() {
{
title: "创建者",
key: "created_by",
width: "100px",
width: "120px",
render: (_: unknown, record: PromptTemplateUI) => (
// <span className="text-secondary">用户 {record.created_by}</span>
<span className="text-secondary">{record.created_by_username}</span>
@@ -342,7 +354,7 @@ export default function PromptsIndex() {
{
title: "操作",
key: "operation",
width: "150px",
width: "160px",
render: (_: unknown, record: PromptTemplateUI) => (
<div>
{record.status === 'system' ? (
@@ -353,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>
+20 -33
View File
@@ -317,9 +317,9 @@ export default function PromptsNew() {
const newFormData = {
...template,
id: mode === "clone" ? "" : template.id,
template_name: mode === "clone" ? `${template.template_name} (副本)` : template.template_name,
version: mode === "clone" ? "v1.0" : template.version,
id: template.id,
template_name: template.template_name,
version: template.version,
variables: variablesJson
};
@@ -348,8 +348,6 @@ export default function PromptsNew() {
setPageTitle("查看提示词模板");
} else if (mode === "edit") {
setPageTitle("编辑提示词模板");
} else if (mode === "clone") {
setPageTitle("复制创建提示词模板");
} else {
setPageTitle("新增提示词模板");
}
@@ -485,7 +483,7 @@ export default function PromptsNew() {
<div className="alert alert-info">
<i className="ri-information-line"></i>
<div>
<div>&quot;&quot;</div>
<div></div>
</div>
</div>
)}
@@ -780,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>
+76
View File
@@ -0,0 +1,76 @@
/* 入口模块管理页面样式 */
.entry-modules-page {
padding: 24px;
min-height: 100vh;
}
.entry-modules-new-page {
padding: 24px;
min-height: 100vh;
}
/* 页面头部 */
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
/* 表单内容 */
.form-content {
max-width: 800px;
}
.form-item {
margin-bottom: 20px;
}
.form-label {
display: block;
font-size: 14px;
font-weight: 500;
color: #374151;
margin-bottom: 8px;
}
.form-actions {
padding-top: 24px;
border-top: 1px solid #e5e7eb;
}
/* Logo预览样式 */
.logo-preview {
display: inline-block;
border: 1px solid #e5e7eb;
border-radius: 4px;
padding: 8px;
background-color: #f9fafb;
}
/* 响应式设计 */
@media (max-width: 768px) {
.entry-modules-page,
.entry-modules-new-page {
padding: 16px;
}
.page-header {
flex-direction: column;
align-items: flex-start;
gap: 16px;
}
.form-content {
max-width: 100%;
}
.form-actions {
flex-direction: column-reverse;
}
.form-actions button {
width: 100%;
}
}