From dab0835605f428787405ce46a73f2514d1f9a89e Mon Sep 17 00:00:00 2001 From: yorn <1057707203@qq.com> Date: Fri, 21 Nov 2025 17:16:07 +0800 Subject: [PATCH] =?UTF-8?q?feat:=201.=E4=BF=AE=E6=94=B9=E6=8F=90=E7=A4=BA?= =?UTF-8?q?=E8=AF=8D=E6=A8=A1=E6=9D=BF=E7=9A=84=E4=B8=8D=E7=94=A8=E8=A7=92?= =?UTF-8?q?=E8=89=B2=E7=9A=84=E6=93=8D=E4=BD=9C=E6=9D=83=E9=99=90=E3=80=82?= =?UTF-8?q?=202.=20=E5=AF=B9=E6=8E=A5=E6=95=B0=E6=8D=AE=E7=9C=8B=E6=9D=BF?= =?UTF-8?q?=E7=9A=84=E6=95=B0=E6=8D=AE=E3=80=82=203.=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E5=85=A5=E5=8F=A3=E6=A8=A1=E5=9D=97=E7=AE=A1=E7=90=86=E7=9A=84?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/axios-client.ts | 77 +++- app/api/entry-modules/entry-modules.ts | 242 +++++++++++ app/api/files/documents.ts | 8 +- app/api/home/home.ts | 183 ++++++++ app/components/rules/new/BasicInfo.tsx | 15 +- app/components/ui/Table.tsx | 11 +- app/routes/entry-modules._index.tsx | 430 ++++++++++++++++++ app/routes/entry-modules.new.tsx | 405 +++++++++++++++++ app/routes/entry-modules.tsx | 9 + app/routes/home.tsx | 575 ++++++++++++++++--------- app/routes/prompts._index.tsx | 88 ++-- app/routes/prompts.new.tsx | 55 +-- app/styles/pages/entry-modules.css | 76 ++++ 13 files changed, 1877 insertions(+), 297 deletions(-) create mode 100644 app/api/entry-modules/entry-modules.ts create mode 100644 app/routes/entry-modules._index.tsx create mode 100644 app/routes/entry-modules.new.tsx create mode 100644 app/routes/entry-modules.tsx create mode 100644 app/styles/pages/entry-modules.css diff --git a/app/api/axios-client.ts b/app/api/axios-client.ts index 0aade96..9c1c916 100644 --- a/app/api/axios-client.ts +++ b/app/api/axios-client.ts @@ -12,7 +12,7 @@ export type ApiResponse = { headers?: Record; }; -export type QueryParams = Record; +export type QueryParams = Record; // 获取 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( 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( } } } - + // 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( // 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)}`); diff --git a/app/api/entry-modules/entry-modules.ts b/app/api/entry-modules/entry-modules.ts new file mode 100644 index 0000000..d5e6508 --- /dev/null +++ b/app/api/entry-modules/entry-modules.ts @@ -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 = {}; + + 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("entry_modules", queryParams); + + if (result.error) { + return { error: result.error }; + } + + // 从 Content-Range 头获取总数 + let totalCount = 0; + const responseWithHeaders = result as { + data: unknown; + headers?: Record; + }; + + 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("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, + jwtToken?: string | null +): Promise<{ data?: EntryModule; error?: string }> { + try { + const result = await postgrestPost( + "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>, + jwtToken?: string | null +): Promise<{ data?: EntryModule; error?: string }> { + try { + const result = await postgrestPut>( + "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 : "删除入口模块失败" + }; + } +} diff --git a/app/api/files/documents.ts b/app/api/files/documents.ts index 29462f4..8590f5f 100644 --- a/app/api/files/documents.ts +++ b/app/api/files/documents.ts @@ -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) => { diff --git a/app/api/home/home.ts b/app/api/home/home.ts index 92306a6..aae9537 100644 --- a/app/api/home/home.ts +++ b/app/api/home/home.ts @@ -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 { + try { + console.log('🔍 [getTopErrorPoints] 请求参数:', { limit, startDate, endDate, typeId, hasToken: !!token }); + + // 构建查询参数 + const params: Record = { + 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 } = { + method: 'GET' + }; + + // 只有在显式传入 token 时才添加 Authorization header + // 否则让 axios 拦截器自动处理(从 localStorage 获取) + if (token) { + requestOptions.headers = { + 'Authorization': `Bearer ${token}` + }; + } + + // 调用 API + const response = await apiRequest( + '/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 { + try { + console.log('🔍 [getTopRiskUsers] 请求参数:', { limit, startDate, endDate, typeId, hasToken: !!token }); + + // 构建查询参数 + const params: Record = { + 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 } = { + method: 'GET' + }; + + // 只有在显式传入 token 时才添加 Authorization header + // 否则让 axios 拦截器自动处理(从 localStorage 获取) + if (token) { + requestOptions.headers = { + 'Authorization': `Bearer ${token}` + }; + } + + // 调用 API + const response = await apiRequest( + '/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: [] }; + } +} + diff --git a/app/components/rules/new/BasicInfo.tsx b/app/components/rules/new/BasicInfo.tsx index a4da014..91e8f36 100644 --- a/app/components/rules/new/BasicInfo.tsx +++ b/app/components/rules/new/BasicInfo.tsx @@ -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]); diff --git a/app/components/ui/Table.tsx b/app/components/ui/Table.tsx index 6d53da4..1a97354 100644 --- a/app/components/ui/Table.tsx +++ b/app/components/ui/Table.tsx @@ -31,20 +31,23 @@ export function Table>({ className = '', onRow, }: TableProps) { + // 防御性检查:确保 dataSource 始终是数组 + const safeDataSource = dataSource || []; + const getRowKey = (record: T, index: number): string => { if (typeof rowKey === 'function') { return rowKey(record); } return String(record[rowKey]); }; - + return (
{columns.map((column, index) => ( - - {dataSource.length > 0 ? ( - dataSource.map((record, index) => ( + {safeDataSource.length > 0 ? ( + safeDataSource.map((record, index) => ( { + 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(); + + // 获取用户角色并判断权限 + 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) => { + 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) => ( + {row.name} + ) + }, + { + key: 'description', + title: '描述', + width: '250px', + render: (row: EntryModule) => ( + {row.description || '-'} + ) + }, + { + key: 'logo', + title: 'Logo图片', + width: '150px', + render: (row: EntryModule) => { + if (!row.path) { + return 未上传; + } + const logoUrl = `${DOCUMENT_URL}${row.path}`; + return ( +
+ {row.name} { + (e.target as HTMLImageElement).style.display = 'none'; + (e.target as HTMLImageElement).parentElement!.innerHTML = '加载失败'; + }} + /> + + 查看 + +
+ ); + } + }, + { + key: 'areas', + title: '适用地区', + width: '200px', + render: (row: EntryModule) => ( +
+ {row.areas && row.areas.length > 0 ? ( + row.areas.map((area, index) => ( + + {area} + + )) + ) : ( + 未设置 + )} +
+ ) + }, + { + 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) => ( +
+ + +
+ ) + } + ]; + + return ( +
+ + {/* 页面头部 */} +
+
+

入口模块管理

+

管理系统入口模块,包括Logo图片和适用地区设置

+
+ {hasEditPermission && ( + + )} +
+ + {/* 筛选面板 */} + + + + + + {/* 表格 */} +
>({
+ + {/* 分页 */} + {total > 0 && ( + + )} + + + ); +} diff --git a/app/routes/entry-modules.new.tsx b/app/routes/entry-modules.new.tsx new file mode 100644 index 0000000..120df4a --- /dev/null +++ b/app/routes/entry-modules.new.tsx @@ -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(); + + const id = searchParams.get('id'); + const isEditMode = !!id; + + // 表单状态 + const [name, setName] = useState(module?.name || ''); + const [description, setDescription] = useState(module?.description || ''); + const [selectedAreas, setSelectedAreas] = useState(module?.areas || []); + const [logoFile, setLogoFile] = useState(null); + const [logoPreview, setLogoPreview] = useState( + module?.path ? `${DOCUMENT_URL}${module.path}` : null + ); + const [isSubmitting, setIsSubmitting] = useState(false); + const [showConfirmModal, setShowConfirmModal] = useState(false); + + const fileInputRef = useRef(null); + + // 处理loader加载数据的时候的错误 + useEffect(() => { + if (error) { + toastService.error(error); + } + }, [error]); + + // 处理logo文件选择 + const handleLogoChange = (e: React.ChangeEvent) => { + 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 => { + 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 ( +
+ + {/* 页面头部 */} +
+
+

+ {isEditMode ? '编辑入口模块' : '新建入口模块'} +

+

+ {isEditMode ? '修改入口模块信息' : '创建新的入口模块'} +

+
+
+ + {/* 表单内容 */} +
+ {/* 模块名称 */} +
+ + 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" + /> +
+ + {/* 描述 */} +
+ +