feat: 1. 完善全局路由的访问权限的验证。 2. 完善接口返回的树形路由结构 3.优化评查点列表的查询,改用表连接的方式,废弃使用数据库的rpc函数,同时进行地区隔离和权限隔离。

4. 删除冗余的评查文件列表。      5.完善上传文档 页面初始化查询数据的时候 查询文件类型(改成动态指定)  6. 添加获取入口模块的查询接口。    7.完善服务端中判断token的有效性,失效则跳转到登录页。
8. 重构layout和sidebar的页面,改成由动态权限路由来渲染对应的菜单栏。       9.重构入口页面,通过动态查询根据不同地区的人返回不同的入口。
This commit is contained in:
2025-11-20 01:35:30 +08:00
parent adfb84a31d
commit 2edde8a8ab
23 changed files with 1201 additions and 2154 deletions
+102 -51
View File
@@ -4,6 +4,7 @@ import { type MetaFunction, type ActionFunctionArgs, LoaderFunctionArgs, redirec
import styles from "~/styles/pages/home.css?url";
import dayjs from 'dayjs';
import { getUserSession, logout } from "~/api/login/auth.server";
import { toastService } from '~/components/ui';
export const links = () => [
{ rel: "stylesheet", href: styles }
@@ -28,15 +29,28 @@ export async function action({ request }: ActionFunctionArgs) {
return null;
}
// 获取用户信息(不再检查服务端认证)
// 获取用户信息
export async function loader({ request }: LoaderFunctionArgs) {
// ⚠️ 不再检查服务端 session 认证
// 认证检查由 ClientAuthGuard 在客户端进行
// 🔒 认证检查已在 getUserSession() 中统一处理
// 如果未认证,会自动重定向到登录页,不会执行到这里
const { userRole, userInfo, frontendJWT } = await getUserSession(request);
const { userRole, userInfo } = await getUserSession(request);
// 🔑 获取用户地区并查询入口模块
const userArea = userInfo?.area || null;
// console.log('🔍 [Index Loader] 用户地区:', userArea);
// console.log('🔍 [Index Loader] 用户角色:', userRole);
// 返回用户信息给客户端(可能为空)
return Response.json({ userRole, userInfo });
let entryModules = [];
if (userRole && frontendJWT) {
const { getEntryModules } = await import('~/api/home/home');
entryModules = await getEntryModules(userRole,userArea, frontendJWT);
console.log(`📦 [Index Loader] 获取到 ${entryModules.length} 个入口模块`);
} else {
console.warn('⚠️ [Index Loader] 用户角色为空,返回空模块列表');
}
// 返回用户信息和入口模块给客户端
return Response.json({ userRole, userInfo, entryModules });
}
export default function Index() {
@@ -102,21 +116,73 @@ export default function Index() {
}, []);
// 处理模块点击
const handleModuleClick = (path: string, reviewType: string) => {
// 将reviewType存入sessionStorage
if (typeof window !== 'undefined') {
sessionStorage.setItem('reviewType', reviewType);
const handleModuleClick = (module: typeof loaderData.entryModules[0]) => {
// 提取文档类型 IDs
const typeIds = module.document_types?.map(dt => dt.id) || [];
// 🔑 验证文档类型(智慧法务大模型除外)
if (module.name !== '智慧法务大模型' && typeIds.length === 0) {
toastService.error('该入口尚未关联文档类型,无法进入');
console.warn('⚠️ [Index] 模块未关联文档类型:', module.name);
return; // 阻止进入
}
navigate(path);
if (typeof window !== 'undefined') {
// 🔑 存储到 sessionStorage(用于客户端请求)
if (typeIds.length > 0) {
sessionStorage.setItem('documentTypeIds', JSON.stringify(typeIds));
// console.log('📝 [Index] 存储到客户端 sessionStorage:', typeIds);
} else {
// 清空文档类型数据
sessionStorage.removeItem('documentTypeIds');
}
// 存储模块信息
sessionStorage.setItem('selectedModuleId', String(module.id));
sessionStorage.setItem('selectedModuleName', module.name);
sessionStorage.setItem('selectedModulePicPath', module.path)
}
// 🔑 根据模块名称决定跳转路径
let targetPath = '/home'; // 默认跳转到首页
if (module.name.includes('合同')) {
// 合同相关模块 → 跳转到合同模板搜索
targetPath = '/contract-template/search';
// console.log('📌 [Index] 合同模块,跳转到:', targetPath);
} else if (module.name === '智慧法务大模型') {
// 智慧法务大模型 → 跳转到 AI 对话
targetPath = '/chat-with-llm';
// console.log('📌 [Index] 智慧法务大模型,跳转到:', targetPath);
} else {
// console.log('📌 [Index] 其他模块,跳转到:', targetPath);
}
navigate(targetPath);
};
// 处理键盘事件
const handleKeyDown = (path: string, reviewType: string, e: React.KeyboardEvent<HTMLDivElement>) => {
const handleKeyDown = (module: typeof loaderData.entryModules[0], e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key === 'Enter' || e.key === ' ') {
handleModuleClick(path, reviewType);
handleModuleClick(module);
}
};
// 获取模块图标(根据模块 path 或 id)
const getModuleIcon = (module: typeof loaderData.entryModules[0]) => {
// 根据 path 判断图标
if (module.path?.includes('ht')) {
return '/images/icon_hetong.png';
} else if (module.path?.includes('aj')) {
return '/images/icon_anjuan.png';
} else if (module.path?.includes('nw')) {
return '/images/icon_assistant.png';
}
// 默认图标
return '/images/icon_assistant.png';
};
// 处理登出
const handleLogout = () => {
// 清除sessionStorage中的所有数据
@@ -182,46 +248,31 @@ export default function Index() {
<h1 className="welcome-text">- -</h1>
<div className="modules-container">
{/* 合同管理模块 - 51708端口时隐藏 */}
{!isPort51707 && (
<div
className="module-card"
onClick={() => handleModuleClick('/contract-template/search', 'contract')}
onKeyDown={(e) => handleKeyDown('/contract-template/search', 'contract', e)}
role="button"
tabIndex={0}
aria-label="合同管理"
>
<img src="/images/icon_hetong.png" alt="合同管理" className="w-12 h-12 mx-1" />
<span className="module-name"></span>
{/* 动态渲染入口模块 */}
{loaderData.entryModules && loaderData.entryModules.length > 0 ? (
loaderData.entryModules.map((module) => (
<div
key={module.id}
className="module-card"
onClick={() => handleModuleClick(module)}
onKeyDown={(e) => handleKeyDown(module, e)}
role="button"
tabIndex={0}
aria-label={module.name}
>
<img
src={getModuleIcon(module)}
alt={module.name}
className="w-12 h-12 mx-1"
/>
<span className="module-name">{module.name}</span>
</div>
))
) : (
<div className="text-center text-gray-500 py-8">
</div>
)}
{/* 案卷智能评查模块 */}
<div
className="module-card"
onClick={() => handleModuleClick('/home', 'record')}
onKeyDown={(e) => handleKeyDown('/home', 'record', e)}
role="button"
tabIndex={0}
aria-label="案卷智能评查"
>
<img src="/images/icon_anjuan.png" alt="案卷智能评查" className="w-12 h-12" />
<span className="module-name"></span>
</div>
{/* 智慧法务大模型模块 */}
<div
className="module-card"
onClick={() => handleModuleClick('/chat-with-llm', 'model')}
onKeyDown={(e) => handleKeyDown('/chat-with-llm', 'model', e)}
role="button"
tabIndex={0}
aria-label="智慧法务大模型"
>
<img src="/images/icon_assistant.png" alt="智慧法务大模型" className="w-12 h-12" />
<span className="module-name"></span>
</div>
</div>
</div>
</main>
+2
View File
@@ -205,6 +205,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
is_leader: savedUserInfo.is_leader,
user_id: savedUserInfo.user_id,
user_role: savedUserInfo.user_role, // 使用后端返回的角色
area: savedUserInfo.area, // 🔑 用户所属地区
frontend_jwt: frontendJWT
};
@@ -222,6 +223,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
ou_name: savedUserInfo.ou_name,
is_leader: savedUserInfo.is_leader,
user_role: savedUserInfo.user_role,
area: savedUserInfo.area, // 🔑 用户所属地区
sub: userInfo.data.sub
})));
callbackUrl.searchParams.set('redirectTo', redirectTo);
+12 -4
View File
@@ -56,7 +56,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
label: type.name
}));
// 初始返回空数据,将在客户端根据 sessionStorage 中的 reviewType 加载实际数据
// 初始返回空数据,将在客户端根据 sessionStorage 中的 documentTypeIds 加载实际数据
return Response.json({
documents: [],
total: 0,
@@ -64,6 +64,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
pageSize,
documentTypeOptions,
userInfo, // 传递用户信息到客户端
frontendJWT, // 传递 JWT 到客户端
initialLoad: true // 标记这是初始加载
});
};
@@ -295,16 +296,22 @@ export default function DocumentsIndex() {
throw new Error(documentsResponse.error);
}
// 🔑 从 sessionStorage 读取文档类型 IDs
const typeIdsStr = sessionStorage.getItem('documentTypeIds');
const documentTypeIds = typeIdsStr ? JSON.parse(typeIdsStr) : undefined;
// 获取经过过滤的文档类型列表(token 由 axios 拦截器自动获取)
const filteredTypesResponse = await getDocumentTypes({
pageSize: 500,
reviewType: storedReviewType || undefined
documentTypeIds: documentTypeIds // 使用动态的文档类型 IDs
});
const filteredDocumentTypes = filteredTypesResponse.data?.types || [];
const filteredOptions = filteredDocumentTypes.map(type => ({
value: type.id,
label: type.name
}));
// console.log('文档列表',documentsResponse)
// 更新状态
setDocuments(documentsResponse.data?.documents || []);
@@ -824,7 +831,8 @@ export default function DocumentsIndex() {
attachmentFiles,
attachmentMergeMode,
true, // isReprocess
attachmentRemark || undefined
attachmentRemark || undefined,
loaderData.frontendJWT
);
if (result.error) {
@@ -1153,7 +1161,7 @@ export default function DocumentsIndex() {
{record.historyCount !== undefined && record.historyCount > 0 ?
<span className="version-badge">
<i className="ri-history-line"></i>
v{record.historyCount + 1} {record.historyCount !== undefined && `(${record.historyCount}个历史版本)`}
v{record.historyCount + 1} {record.historyCount !== undefined && `(${record.historyCount}个历史版本)`}
</span> : ""
}
</div>
+66 -72
View File
@@ -259,11 +259,11 @@ export async function loader({ request }: LoaderFunctionArgs) {
const url = new URL(request.url);
const mode = url.searchParams.get("mode") || "create";
// 我们不能在服务器端访问 sessionStorage,所以在客户端组件中处理 reviewType 过滤
// 我们不能在服务器端访问 sessionStorage,所以在客户端组件中处理 documentTypeIds 过滤
// 并行加载文档和文档类型
const [documentsResponse, typesResponse] = await Promise.all([
getTodayDocuments(userInfo, undefined, frontendJWT),
getDocumentTypes(undefined, frontendJWT)
getTodayDocuments(userInfo, frontendJWT),
getDocumentTypes(frontendJWT)
]);
// console.log('loader: 文档加载结果:', documentsResponse);
@@ -308,9 +308,9 @@ export default function FilesUpload() {
const [isNavigating, setIsNavigating] = useState(false)
const revalidator = useRevalidator()
// 获取 sessionStorage 中的 reviewType 值
// 获取 sessionStorage 中的 documentTypeIds
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [reviewType, setReviewType] = useState<string | null>(null);
const [documentTypeIds, setDocumentTypeIds] = useState<number[] | null>(null);
// 使用 useLoaderData 获取初始数据
const loaderData = useLoaderData<LoaderData>();
@@ -361,19 +361,20 @@ export default function FilesUpload() {
const [queueFiles, setQueueFiles] = useState<Document[]>([]);
const [documentTypesState, setDocumentTypesState] = useState<DocumentType[]>([]);
// 在组件挂载时从 sessionStorage 获取 reviewType
// 在组件挂载时从 sessionStorage 获取 documentTypeIds
useEffect(() => {
try {
// 在客户端环境中执行
if (typeof window !== 'undefined') {
const storedReviewType = sessionStorage.getItem('reviewType');
setReviewType(storedReviewType);
// 根据 reviewType 过滤文档类型和文档列表
filterDocumentTypes(storedReviewType, loaderData.documentTypes);
filterDocuments(storedReviewType);
// 如果reviewType是contract,自动选择合同文档类型
if (storedReviewType === 'contract') {
const typeIdsStr = sessionStorage.getItem('documentTypeIds');
const typeIds = typeIdsStr ? JSON.parse(typeIdsStr) : null;
setDocumentTypeIds(typeIds);
// 根据 documentTypeIds 过滤文档类型和文档列表
filterDocumentTypes(typeIds, loaderData.documentTypes);
filterDocuments(typeIds);
// 如果包含合同类型(ID=1),自动选择合同文档类型
if (typeIds && typeIds.includes(1)) {
setIsContractType(true);
// 查找ID为1的合同文档类型
const contractType = loaderData.documentTypes.find(type => type.id === 1);
@@ -385,49 +386,39 @@ export default function FilesUpload() {
}
}
} catch (error) {
console.error('获取 sessionStorage 中的 reviewType 失败:', error);
console.error('获取 sessionStorage 中的 documentTypeIds 失败:', error);
}
}, [loaderData]);
// 过滤文档类型列表
const filterDocumentTypes = (reviewType: string | null, types: DocumentType[]) => {
if (!reviewType) {
// 如果没有特定的 reviewType,使用原始数据
const filterDocumentTypes = (documentTypeIds: number[] | null, types: DocumentType[]) => {
if (!documentTypeIds || documentTypeIds.length === 0) {
// 如果没有特定的 documentTypeIds,使用原始数据
setDocumentTypesState(types);
return;
}
let filteredTypes: DocumentType[] = [];
if (reviewType === 'contract') {
// 只保留 id=1 的选项
filteredTypes = types.filter(type => type.id === 1);
} else if (reviewType === 'record') {
// 只保留 id=2 和 id=3 的选项
filteredTypes = types.filter(type => type.id === 2 || type.id === 3 || type.id === 155);
} else {
// 如果reviewType不匹配任何条件,使用原始数据
filteredTypes = types;
}
// 根据 documentTypeIds 过滤文档类型
const filteredTypes = types.filter(type => documentTypeIds.includes(type.id));
setDocumentTypesState(filteredTypes);
};
// 过滤文档列表
const filterDocuments = async (reviewType: string | null) => {
if (!reviewType) {
// 如果没有特定的 reviewType,使用原始数据
const filterDocuments = async (documentTypeIds: number[] | null) => {
if (!documentTypeIds || documentTypeIds.length === 0) {
// 如果没有特定的 documentTypeIds,使用原始数据
const documents = loaderData.documents;
setQueueFiles(documents);
// 启动状态检查定时器
startStatusChecker(documents);
return;
}
try {
// 使用 reviewType 获取过滤后的文档列表
const response = await getTodayDocuments(loaderData.userInfo || undefined, reviewType, loaderData.frontendJWT || undefined);
// 使用 documentTypeIds 获取过滤后的文档列表
const response = await getTodayDocuments(loaderData.userInfo || undefined, loaderData.frontendJWT || undefined, documentTypeIds);
if (response.error) {
console.error('过滤文档列表失败:', response.error);
@@ -559,45 +550,48 @@ export default function FilesUpload() {
const checkQueueStatusWithFiles = async (files: Document[]) => {
try {
// console.log('开始检查队列状态,当前队列文件:', files);
// 直接从sessionStorage读取reviewType,避免异步状态更新问题
const currentReviewType = typeof window !== 'undefined' ? sessionStorage.getItem('reviewType') : null;
// console.log('从sessionStorage读取的reviewType:', currentReviewType);
// 直接从sessionStorage读取documentTypeIds,避免异步状态更新问题
const typeIdsStr = typeof window !== 'undefined' ? sessionStorage.getItem('documentTypeIds') : null;
const currentDocumentTypeIds = typeIdsStr ? JSON.parse(typeIdsStr) : null;
// console.log('从sessionStorage读取的documentTypeIds:', currentDocumentTypeIds);
// 获取所有未完成的文档
const incompleteFiles = files.filter(file =>
const incompleteFiles = files.filter(file =>
file.status !== DocumentStatus.PROCESSED && file.id
);
if (incompleteFiles.length === 0) {
console.log('没有未完成的文档,跳过状态检查');
return;
}
let statusResponse;
// 如果是合同类型,需要分类处理
console.log('当前reviewType:', currentReviewType);
if (currentReviewType === 'contract') {
// 分类文档ID
const mainDocumentIds: number[] = [];
const attachmentIds: number[] = [];
// 如果是合同类型(ID=1),需要分类处理
// console.log('当前documentTypeIds:', currentDocumentTypeIds);
// if (currentDocumentTypeIds && currentDocumentTypeIds.includes(1)) {
// // 分类文档ID
// const mainDocumentIds: number[] = [];
// const attachmentIds: number[] = [];
incompleteFiles.forEach(file => {
// 检查是否存在template_contract_path属性来判断是否为合同附件
if ('template_contract_path' in file && file.template_contract_path) {
attachmentIds.push(file.id);
} else {
mainDocumentIds.push(file.id);
}
});
// incompleteFiles.forEach(file => {
// // 检查是否存在template_contract_path属性来判断是否为合同附件
// if ('template_contract_path' in file && file.template_contract_path) {
// attachmentIds.push(file.id);
// } else {
// mainDocumentIds.push(file.id);
// }
// });
// console.log('合同主文件ID:', mainDocumentIds);
// console.log('合同附件ID:', attachmentIds);
// // console.log('合同主文件ID:', mainDocumentIds);
// // console.log('合同附件ID:', attachmentIds);
// 分别查询状态
statusResponse = await getDocumentsStatus(mainDocumentIds, attachmentIds, loaderData.frontendJWT || undefined);
} else {
// // 分别查询状态
// statusResponse = await getDocumentsStatus(mainDocumentIds, attachmentIds, loaderData.frontendJWT || undefined);
// }
// else
{
// 非合同类型,使用原有逻辑
const incompleteIds = incompleteFiles.map(file => file.id);
// console.log('未完成的文档ID:', incompleteIds);
@@ -959,9 +953,9 @@ export default function FilesUpload() {
setAttachmentRemark("");
setShowAttachmentUpload(false);
setSelectedDocumentId(null);
// 刷新文档列表
await filterDocuments(reviewType);
await filterDocuments(documentTypeIds);
} catch (error) {
console.error('【附件追加】上传失败:', error);
@@ -1028,9 +1022,9 @@ export default function FilesUpload() {
setTemplateFile(null);
setShowTemplateUpload(false);
setSelectedDocumentId(null);
// 刷新文档列表
await filterDocuments(reviewType);
await filterDocuments(documentTypeIds);
} catch (error) {
console.error('【合同模板上传】上传失败:', error);
@@ -1163,7 +1157,7 @@ export default function FilesUpload() {
setCompletedFiles(uploadedFiles);
startProcessing(uploadedFiles);
// 刷新队列
await filterDocuments(reviewType);
await filterDocuments(documentTypeIds);
} catch (error) {
console.error('合同首传上传失败:', error);
messageService.error(`合同上传失败:${error instanceof Error ? error.message : '未知错误'}`);
+43 -6
View File
@@ -24,21 +24,41 @@ export async function loader({ request }: LoaderFunctionArgs) {
// ⚠️ 不再检查服务端 session 认证
// 认证检查改为在客户端通过 localStorage 进行
// 获取重定向URL
// 获取重定向URL和错误参数
const url = new URL(request.url);
const redirectTo = url.searchParams.get("redirect") || "/";
const urlError = url.searchParams.get("error");
const session = await getSession(request);
// 读取 flash 消息(来自 callback 的错误)
const loginError = session.get("loginError");
// 将URL错误参数转换为友好的错误消息
let urlErrorMessage: string | null = null;
if (urlError) {
switch (urlError) {
case 'no_role':
urlErrorMessage = '用户角色信息缺失,请重新登录';
break;
case 'no_token':
urlErrorMessage = '认证令牌缺失,请重新登录';
break;
case 'session_expired':
urlErrorMessage = '会话已过期,请重新登录';
break;
default:
urlErrorMessage = '登录状态异常,请重新登录';
}
}
// 提交 session 以清除 flash 消息
if (loginError) {
const { sessionStorage } = await import("~/api/login/auth.server");
return Response.json({
redirectTo,
flashError: loginError
flashError: loginError,
urlError: urlErrorMessage
}, {
headers: {
"Set-Cookie": await sessionStorage.commitSession(session)
@@ -48,7 +68,8 @@ export async function loader({ request }: LoaderFunctionArgs) {
return Response.json({
redirectTo,
flashError: null
flashError: null,
urlError: urlErrorMessage
});
}
@@ -107,6 +128,11 @@ export async function action({ request }: ActionFunctionArgs) {
}, { status: 500 });
}
// 测试,管理员账密返回的时候默认给没有area信息
// if(!user_info.area){
// user_info.area = '梅州'
// }
// 🔑 将后端返回的 issued_time 转换为时间戳(毫秒)
let tokenIssuedAt = Date.now(); // 默认使用当前时间
if (issued_time) {
@@ -141,6 +167,7 @@ export async function action({ request }: ActionFunctionArgs) {
ou_name: user_info.ou_name,
is_leader: user_info.is_leader,
user_role: user_info.user_role,
area: user_info.area, // 🔑 用户所属地区
sub: user_info.sub
})));
callbackUrl.searchParams.set('redirectTo', redirectTo);
@@ -163,6 +190,7 @@ export async function action({ request }: ActionFunctionArgs) {
ou_name: user_info.ou_name,
is_leader: user_info.is_leader,
user_role: user_info.user_role,
area: user_info.area, // 🔑 用户所属地区
sub: user_info.sub
}
});
@@ -185,11 +213,12 @@ export default function Login() {
const [password, setPassword] = useState("");
const [passwordLoginError, setPasswordLoginError] = useState<string | null>(null);
// 从 loaderData 中获取 OAuth 回调的错误信息
// 从 loaderData 中获取错误信息
const oauthError = loaderData?.flashError;
const urlError = loaderData?.urlError;
// 显示的错误信息:密码登录错误优先,其次是 OAuth 错误
const error = passwordLoginError || oauthError;
// 显示的错误信息:密码登录错误优先,其次是 URL 错误,最后是 OAuth 错误
const error = passwordLoginError || urlError || oauthError;
const isLocked = false; // 可以从后端响应中获取
const retryCount = 0;
const remainingAttempts = 5;
@@ -283,6 +312,14 @@ export default function Login() {
}
}, [fetcher.data]);
// 显示URL错误参数的Toast提示
useEffect(() => {
if (urlError) {
console.warn("⚠️ [Login] 检测到URL错误参数:", urlError);
toastService.error(urlError);
}
}, [urlError]);
useEffect(() => {
// 🔑 只在 token 过期时清理客户端存储
// 检查 URL 参数中是否有 expired=true 标识
-985
View File
@@ -1,985 +0,0 @@
import { type MetaFunction, type LoaderFunctionArgs } from "@remix-run/node";
import { useLoaderData, useSearchParams, useNavigate } from "@remix-run/react";
import { useEffect, useState, useCallback } from "react";
import { Button } from "~/components/ui/Button";
import { Card } from "~/components/ui/Card";
import { FileIcon } from "~/components/ui/FileIcon";
import { FilterPanel, FilterSelect, SearchFilter, DateRangeFilter } from "~/components/ui/FilterPanel";
import { Pagination } from "~/components/ui/Pagination";
import { Table } from "~/components/ui/Table";
import { StatusBadge } from "~/components/ui/StatusBadge";
import { FileTypeTag, links as fileTypeTagLinks } from "~/components/ui/FileTypeTag";
import { NumberSkeleton, TableRowSkeleton, LoadingIndicator } from "~/components/ui/SkeletonScreen";
import rulesFilesStyles from "~/styles/pages/rules-files.css?url";
import {
getReviewFiles,
type ReviewFileUI,
updateDocumentAuditStatus,
type DocumentSearchParams
} from "~/api/evaluation_points/rules-files";
import { getDocumentTypes } from "~/api/document-types/document-types";
import { toastService } from "~/components/ui/Toast";
// 导入axios下载文件方法
import { downloadFile } from "~/api/axios-client";
import { appendContractAttachments } from "~/api/files/files-upload";
import { messageService } from "~/components/ui/MessageModal";
export const links = () => [
{ rel: "stylesheet", href: rulesFilesStyles },
...fileTypeTagLinks()
];
export const handle = {
breadcrumb: "评查文件列表"
};
export const meta: MetaFunction = () => {
return [
{ title: "评查文件列表 - 中国烟草AI合同及卷宗审核系统" },
{ name: "description", content: "管理系统中所有上传的评查文件,支持按文件类型、评查状态进行筛选" },
{ name: "keywords", content: "评查文件,合同审核,中国烟草,文件管理" }
];
};
// 日期范围枚举
export enum DateRange {
ALL = 'all',
TODAY = 'today',
WEEK = 'week',
MONTH = 'month',
CUSTOM = 'custom'
}
// 评查状态标签映射
export const REVIEW_STATUS_LABELS: Record<string, string> = {
'pass': '通过',
'warning': '警告',
'fail': '不通过',
'pending': '待人工确认'
};
// 加载评查文件列表
export async function loader({ request }: LoaderFunctionArgs) {
// 获取用户会话信息
const { getUserSession } = await import("~/api/login/auth.server");
const { userInfo, frontendJWT } = await getUserSession(request);
// 获取分页参数
const url = new URL(request.url);
const currentPage = parseInt(url.searchParams.get("page") || "1", 10);
const pageSize = parseInt(url.searchParams.get("pageSize") || "10", 10);
try {
// 获取文档类型列表(服务端需要显式传递 token,客户端依赖 axios 拦截器)
const typesResponse = await getDocumentTypes({pageSize:500}, frontendJWT);
const documentTypes = typesResponse.data?.types || [];
// 返回初始空数据,客户端将根据 sessionStorage 中的 reviewType 加载实际数据
return Response.json({
files: [],
documentTypes,
totalCount: 0,
currentPage,
pageSize,
userInfo, // 传递用户信息到客户端
initialLoad: true
});
} catch (error) {
console.error('加载评查文件列表失败:', error);
return Response.json({ result: false, message: error instanceof Error ? error.message : '加载评查文件列表失败' }, { status: 500 });
}
}
export default function RulesFiles() {
const navigate = useNavigate();
const { files: initialFiles, documentTypes: allDocumentTypes, totalCount: initialTotal, currentPage, pageSize, userInfo, result, message } = useLoaderData<typeof loader>();
const [searchParams, setSearchParams] = useSearchParams();
const dateFrom = searchParams.get('dateFrom') || '';
const dateTo = searchParams.get('dateTo') || '';
// 添加状态管理
const [files, setFiles] = useState<ReviewFileUI[]>(initialFiles);
const [documentTypes, setDocumentTypes] = useState(allDocumentTypes);
const [totalCount, setTotalCount] = useState(initialTotal);
const [isLoading, setIsLoading] = useState(true);
const [reviewType, setReviewType] = useState<string | null>(null);
// 附件追加相关状态
const [showAttachmentUpload, setShowAttachmentUpload] = useState<boolean>(false);
const [selectedDocumentId, setSelectedDocumentId] = useState<string | null>(null);
const [attachmentFiles, setAttachmentFiles] = useState<File[]>([]);
const [attachmentMergeMode, setAttachmentMergeMode] = useState<'overwrite' | 'new'>('overwrite');
const [attachmentRemark, setAttachmentRemark] = useState<string>("");
const [attachmentUploading, setAttachmentUploading] = useState<boolean>(false);
// 保存/恢复 查询参数 的 sessionStorage key
const SEARCH_PARAMS_STORAGE_KEY = 'rulesFiles.searchParams';
const persistSearchParams = useCallback((params: URLSearchParams) => {
if (typeof window !== 'undefined') {
sessionStorage.setItem(SEARCH_PARAMS_STORAGE_KEY, params.toString());
}
}, []);
// 首次进入列表页且 URL 无查询参数时,尝试恢复上次保存的参数
useEffect(() => {
if (typeof window === 'undefined') return;
const hasAnyParam = Array.from(searchParams.keys()).length > 0;
const stored = sessionStorage.getItem(SEARCH_PARAMS_STORAGE_KEY);
if (!hasAnyParam && stored) {
setSearchParams(new URLSearchParams(stored));
}
// 仅在初始渲染时检查
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// 处理初始加载数据loader的错误
useEffect(() => {
if(result === false && message) {
toastService.error(message);
}
}, [result, message]);
// 辅助函数:从 localStorage 获取用户ID
const getUserId = useCallback((): string | undefined => {
if (typeof window === 'undefined') return undefined;
const userInfoStr = localStorage.getItem('user_info');
if (!userInfoStr) return undefined;
try {
const userInfoData = JSON.parse(userInfoStr);
return userInfoData.user_id?.toString();
} catch (error) {
console.error('解析 localStorage 用户信息失败:', error);
return undefined;
}
}, []);
// 客户端数据请求
const fetchData = useCallback(async (params: Record<string, string>) => {
setIsLoading(true);
try {
// 构建搜索参数(token 由 axios 拦截器自动从 localStorage 获取)
const searchParams: DocumentSearchParams = {
fileType: params.fileType || undefined,
reviewStatus: params.reviewStatus || undefined,
dateFrom: params.dateFrom || undefined,
dateTo: params.dateTo || undefined,
keyword: params.keyword || undefined,
sortOrder: params.sortOrder || 'upload_time_desc',
page: parseInt(params.page || "1", 10),
pageSize: parseInt(params.pageSize || "10", 10)
};
// 根据 reviewType 添加类型过滤
if (reviewType === 'contract') {
searchParams.fileType = 'contract';
} else if (reviewType === 'record') {
// 在 API 层处理 type_id 为 2 或 3 的过滤
searchParams.fileType = 'record';
}
// 如果用户手动选择了文件类型,优先使用用户选择的
if (params.fileType) {
searchParams.fileType = params.fileType;
}
// 从 localStorage 获取用户ID(与 token 管理保持一致)
const userId = getUserId();
if (!userId) {
throw new Error('用户身份验证失败,无法获取评查文件列表');
}
// 获取文件列表(token 由 axios 拦截器自动添加)
const filesResponse = await getReviewFiles(searchParams, null, userId);
if (filesResponse.error) {
throw new Error(filesResponse.error);
}
setFiles(filesResponse.data?.files || []);
setTotalCount(filesResponse.data?.total || 0);
} catch (error) {
console.error('获取评查文件列表失败:', error);
toastService.error('获取评查文件列表失败: ' + (error instanceof Error ? error.message : '未知错误'));
} finally {
setIsLoading(false);
}
}, [reviewType, getUserId]); // 使用 getUserId 辅助函数
// 在组件挂载时从 sessionStorage 获取 reviewType 并加载数据
useEffect(() => {
try {
if (typeof window !== 'undefined') {
const storedReviewType = sessionStorage.getItem('reviewType');
// 根据 reviewType 过滤文档类型选项
if (storedReviewType) {
setReviewType(storedReviewType);
if (storedReviewType === 'contract') {
// 只保留 id=1 的选项
const filteredTypes = allDocumentTypes.filter((type: {id: number}) => type.id === 1);
setDocumentTypes(filteredTypes);
} else if (storedReviewType === 'record') {
// 只保留 id=2 和 id=3 的选项
const filteredTypes = allDocumentTypes.filter((type: {id: number}) => type.id === 2 || type.id === 3 || type.id === 155);
setDocumentTypes(filteredTypes);
}
// 直接使用 storedReviewType 构建搜索参数
const currentParams = Object.fromEntries(searchParams.entries());
const apiSearchParams: DocumentSearchParams = {
fileType: currentParams.fileType || undefined,
reviewStatus: currentParams.reviewStatus || undefined,
dateFrom: currentParams.dateFrom || undefined,
dateTo: currentParams.dateTo || undefined,
keyword: currentParams.keyword || undefined,
sortOrder: currentParams.sortOrder || 'upload_time_desc',
page: parseInt(currentParams.page || "1", 10),
pageSize: parseInt(currentParams.pageSize || "10", 10)
};
// 根据 storedReviewType 添加类型过滤
if (storedReviewType === 'contract') {
apiSearchParams.fileType = '1';
} else if (storedReviewType === 'record') {
apiSearchParams.fileType = 'record';
}
// 如果用户手动选择了文件类型,优先使用用户选择的
if (currentParams.fileType) {
apiSearchParams.fileType = currentParams.fileType;
}
// 设置加载状态
setIsLoading(true);
// 从 localStorage 获取用户ID
const userId = getUserId();
if (!userId) {
toastService.error('用户身份验证失败,无法获取评查文件列表');
setIsLoading(false);
return;
}
// 获取文件列表(token 由 axios 拦截器自动添加)
getReviewFiles(apiSearchParams, null, userId)
.then(filesResponse => {
if (filesResponse.error) {
throw new Error(filesResponse.error);
}
setFiles(filesResponse.data?.files || []);
setTotalCount(filesResponse.data?.total || 0);
})
.catch(error => {
console.error('获取评查文件列表失败:', error);
toastService.error('获取评查文件列表失败: ' + (error instanceof Error ? error.message : '未知错误'));
})
.finally(() => {
setIsLoading(false);
});
}
}
} catch (error) {
console.error('获取 sessionStorage 中的 reviewType 失败:', error);
}
}, [allDocumentTypes, searchParams]);
// 监听 URL 参数变化,重新获取数据
useEffect(() => {
if (reviewType) {
fetchData(Object.fromEntries(searchParams.entries()));
}
}, [searchParams, fetchData, reviewType]);
// 处理筛选条件变更
const handleFilterChange = (e: React.ChangeEvent<HTMLSelectElement | HTMLInputElement>) => {
const { name, value } = e.target;
const newParams = new URLSearchParams(searchParams);
if (value) {
newParams.set(name, value);
} else {
newParams.delete(name);
}
// 切换筛选条件时,重置到第一页
newParams.set('page', '1');
persistSearchParams(newParams);
setSearchParams(newParams);
};
// 处理搜索操作
const handleSearch = (keyword: string) => {
const newParams = new URLSearchParams(searchParams);
if (keyword) {
newParams.set('keyword', keyword);
} else {
newParams.delete('keyword');
}
// 搜索时,重置到第一页
newParams.set('page', '1');
persistSearchParams(newParams);
setSearchParams(newParams);
};
// 处理页码变更
const handlePageChange = (page: number) => {
const newParams = new URLSearchParams(searchParams);
newParams.set('page', page.toString());
persistSearchParams(newParams);
setSearchParams(newParams);
};
// 处理每页条数变更
const handlePageSizeChange = (size: number) => {
const newParams = new URLSearchParams(searchParams);
newParams.set('pageSize', size.toString());
newParams.set('page', '1'); // 改变每页条数时重置为第一页
persistSearchParams(newParams);
setSearchParams(newParams);
};
// 查看评查文件
const handleReviewFileClick = async (fileId: string, auditStatus: number | null) => {
// 检查audit_status是否为0,如果是则更新为2
if (auditStatus === 0 || auditStatus === null) {
try {
// 从 localStorage 获取用户ID
const userId = getUserId();
if (!userId) {
toastService.error('用户身份验证失败');
return;
}
// token 由 axios 拦截器自动添加
const response = await updateDocumentAuditStatus(fileId, 2, userId);
if (response.error) {
throw new Error(response.error);
}
} catch (error) {
console.error('更新文件审核状态时出错:', error);
toastService.error(`更新文件审核状态时出错:${error instanceof Error ? error.message : '未知错误'}`);
return;
}
}
// 导航到评查详情页
// 在离开当前页前保存当前查询参数,返回时可恢复
if (typeof window !== 'undefined') {
sessionStorage.setItem(SEARCH_PARAMS_STORAGE_KEY, searchParams.toString());
}
navigate(`/reviews?id=${fileId}&previousRoute=rulesFiles`);
};
// 渲染问题摘要
const renderIssues = (file: ReviewFileUI) => {
// 如果文件状态为完成
if (file.status === 'Processed') {
// 如果没有问题,显示"所有评查点均通过"
if (file.warningCount <= 0 && file.failCount <= 0) {
return (
<div className="text-sm text-success">
<i className="ri-check-double-line mr-1"></i>
</div>
);
}
// 如果评查状态为不通过,显示"统计分数为:{file.score || 0}。分数低于80分。"
// if (file.reviewStatus === 'fail') {
// return (
// <div className="text-sm text-error">
// <i className="ri-error-warning-line mr-1"></i>统计分数为:{file.score || 0}。分数低于80分。
// </div>
// );
// }
// 显示问题列表
if (file.issues && file.issues.length > 0) {
// 最多显示2个问题
const displayIssues = file.issues.slice(0, 2);
return (
<div className="text-sm">
{displayIssues.map((issue, index) => (
<div key={index} className="mb-1">
<i className="ri-circle-fill mr-1 text-warning"></i>
{issue.message}
</div>
))}
{file.issues.length > 2 && (
<div className="text-secondary mt-1">
{file.issues.length - 2} ...
</div>
)}
</div>
);
}
}
// 其他状态显示占位符
return <div className="text-sm text-secondary">-</div>;
};
// 下载文件
const handleDownload = async (path: string) => {
try {
// 使用axios封装的下载方法
const blob = await downloadFile(path);
// 创建Blob URL
const blobUrl = URL.createObjectURL(blob);
// 创建一个隐藏的a标签并点击它
const a = document.createElement('a');
a.style.display = 'none';
a.href = blobUrl;
// 从路径中获取文件名
const fileName = path.split('/').pop() || 'document';
a.download = decodeURIComponent(fileName);
document.body.appendChild(a);
a.click();
// 清理
setTimeout(() => {
document.body.removeChild(a);
URL.revokeObjectURL(blobUrl);
}, 100);
} catch (error) {
console.error('下载文件失败:', error);
toastService.error(`下载文件失败: ${error instanceof Error ? error.message : '未知错误'}`);
}
};
// 处理时间范围变更
const handleDateChange = (field: 'dateFrom' | 'dateTo', value: string) => {
const newParams = new URLSearchParams(searchParams);
if(value) {
newParams.set(field, value);
} else {
newParams.delete(field);
}
newParams.set('page', '1');
setSearchParams(newParams);
};
const handleReset = () => {
const newParams = new URLSearchParams();
const searchInput = document.querySelector('input[name="keyword"]');
if(searchInput) {
(searchInput as HTMLInputElement).value = '';
}
if (typeof window !== 'undefined') {
sessionStorage.removeItem(SEARCH_PARAMS_STORAGE_KEY);
}
setSearchParams(newParams);
};
// 辅助:格式化文件大小
const formatFileSize = (bytes: number) => {
if (bytes === 0) return "0 Bytes";
const k = 1024;
const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
};
// 选择附件文件
const handleAttachmentFilesSelected = (files: FileList) => {
try {
if (files.length > 0) {
const validFiles: File[] = [];
let hasInvalidFiles = false;
Array.from(files).forEach(file => {
const fileName = file.name.toLowerCase();
const isValidType =
file.type === 'application/pdf' || fileName.endsWith('.pdf') ||
file.type === 'application/msword' || fileName.endsWith('.doc') ||
file.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' || fileName.endsWith('.docx') ||
file.type === 'application/zip' || fileName.endsWith('.zip') ||
file.type === 'application/x-rar-compressed' || fileName.endsWith('.rar');
if (isValidType) {
validFiles.push(file);
} else {
hasInvalidFiles = true;
}
});
if (hasInvalidFiles) {
messageService.error('只支持PDF、Word、ZIP、RAR格式的文件', {
title: '文件类型错误',
confirmText: '确定',
cancelText: '',
});
}
if (validFiles.length > 0) {
setAttachmentFiles(validFiles);
}
}
} catch (error) {
console.error('【附件追加】处理文件选择时发生错误:', error);
}
};
// 执行附件追加
const handleAttachmentUpload = async () => {
if (!selectedDocumentId || attachmentFiles.length === 0) {
toastService.error('请选择文档和附件文件');
return;
}
try {
setAttachmentUploading(true);
const docId = parseInt(selectedDocumentId, 10);
const result = await appendContractAttachments(
docId,
attachmentFiles,
attachmentMergeMode,
true,
attachmentRemark || undefined
);
if (result.error) {
throw new Error(result.error);
}
toastService.success('附件追加成功!');
// 重置并关闭
setAttachmentFiles([]);
setAttachmentRemark("");
setShowAttachmentUpload(false);
setSelectedDocumentId(null);
// 刷新列表
fetchData(Object.fromEntries(searchParams.entries()));
} catch (error) {
console.error('【附件追加】上传失败:', error);
toastService.error(error instanceof Error ? error.message : '附件追加失败');
} finally {
setAttachmentUploading(false);
}
};
// 文件类型选项
const fileTypeOptions = documentTypes.map((type: {id: number, name: string}) => ({
value: type.id.toString(),
label: type.name
}));
// 定义表格列配置
const columns = [
{
title: "文件名称",
key: "fileName",
width: "30%",
render: (_: unknown, file: ReviewFileUI) => (
<div className="flex">
<div className="flex-shrink-0 flex items-center self-center">
<FileIcon fileName={file.fileName} className="text-lg w-10 h-10" />
</div>
<div className="min-w-0 flex-1 flex flex-col py-2 ml-2">
<div className="font-normal text-base break-words whitespace-normal leading-normal" title={file.fileName}>{file.fileName}</div>
<div className="text-xs text-secondary mt-2">
{file.fileCode}
</div>
</div>
</div>
)
},
{
title: "文件类型",
key: "fileType",
width: "12%",
render: (_: unknown, file: ReviewFileUI) => (
<FileTypeTag
type="other"
typeName={file.fileType}
text={file.fileType}
size="sm"
showIcon={false}
colorMode="light"
/>
)
},
{
title: "上传时间",
key: "uploadTime",
width: "12%",
render: (_: unknown, file: ReviewFileUI) => {
const [date, time] = file.uploadTime.split(' ');
return (
<div>
<span className="text-base">{date}</span>
<br />
<span className="text-xs text-secondary">{time}</span>
</div>
);
}
},
{
title: "评查统计",
key: "reviewStatus",
width: "12%",
render: (_: unknown, file: ReviewFileUI) =>
// 要文件切分处理完之后,再显示评查统计
file.status === 'Processed' ? (
<div>
{file.passCount > 0 && (
<StatusBadge
status="pass"
text={`通过(${file.passCount})`}
showIcon={true}
className="my-2"
/>
)}
{file.warningCount > 0 && (
<StatusBadge
status="warning"
text={`警告(${file.warningCount})`}
showIcon={true}
className="my-2"
/>
)}
{file.failCount > 0 && (
<StatusBadge
status="fail"
text={`不通过(${file.failCount})`}
showIcon={true}
className="my-2"
/>
)}
{file.manualCount > 0 && (
<StatusBadge
status="pending"
text={`需人工(${file.manualCount})`}
showIcon={true}
className="my-2"
/>
)}
</div>
) : (
<div className="text-sm">
-
</div>
)
},
{
title: "问题摘要",
key: "issues",
width: "20%",
render: (_: unknown, file: ReviewFileUI) => renderIssues(file)
},
{
title: "操作",
key: "operation",
width: "20%",
render: (_: unknown, file: ReviewFileUI) => (
<div className="flex flex-wrap gap-1">
<button
type="button"
onClick={() => handleReviewFileClick(file.id, file.auditStatus)}
disabled={file.status !== 'Processed'}
className={`text-xs px-2 py-1 h-7 mr-1 ${file.status === 'Processed' ? 'hover:underline hover:text-primary' : 'opacity-60 cursor-not-allowed pointer-events-none'}`}
>
<i className="ri-eye-line"></i>
</button>
{file.fileTypeId === 1 && file.status === 'Processed' && (
<button
type="button"
className="text-xs px-2 py-1 h-7 mr-1 hover:underline hover:text-primary"
onClick={() => {
setSelectedDocumentId(file.id);
setShowAttachmentUpload(true);
}}
>
<i className="ri-attachment-line"></i>
</button>
)}
<button
type="button"
className="text-xs px-2 py-1 h-7 mr-1 text-gray-500 hover:underline hover:text-gray-700"
onClick={() => handleDownload(file.path)}
>
<i className="ri-download-2-line"></i>
</button>
</div>
)
}
];
return (
<div className="review-files-page">
{/* 页面头部 */}
<div className="flex justify-between items-center mb-4">
<div className="flex items-center">
<h2 className="text-xl font-normal"></h2>
<div className="flex items-center ml-4 bg-white px-3 py-1 rounded-md">
<i className="ri-file-list-3-line text-primary text-lg mr-1"></i>
<span className="text-sm text-secondary"></span>
{isLoading ? (
<NumberSkeleton className="ml-1" />
) : (
<span className="text-base font-normal text-primary ml-1">{totalCount}</span>
)}
</div>
</div>
<Button type="primary" icon="ri-file-upload-line" to="/files/upload">
</Button>
</div>
{/* 筛选区域 */}
<FilterPanel className="px-3 py-3" noActionDivider={true}
actions={
<>
<Button type="default" icon="ri-refresh-line" onClick={handleReset} className="mr-2 hover:!border-gray-300">
</Button>
</>
}
>
<FilterSelect
label="文件类型"
name="fileType"
value={searchParams.get('fileType') || ''}
options={fileTypeOptions}
onChange={handleFilterChange}
className="mr-2 w-40"
/>
{/* <FilterSelect
label="评查状态"
name="reviewStatus"
value={searchParams.get('reviewStatus') || ''}
options={reviewStatusOptions}
onChange={handleFilterChange}
className="mr-2 w-40"
/> */}
{/* <FilterSelect
label="时间范围"
name="dateRange"
value={searchParams.get('dateRange') || ''}
options={dateRangeOptions}
onChange={handleFilterChange}
className="mr-2 w-40"
/> */}
<DateRangeFilter
label="时间范围"
startDate={dateFrom}
endDate={dateTo}
onStartDateChange={(value) => handleDateChange('dateFrom', value)}
onEndDateChange={(value) => handleDateChange('dateTo', value)}
simple={true}
colorMode="light"
/>
<FilterSelect
label="排序方式"
name="sortOrder"
value={searchParams.get('sortOrder') || 'upload_time_desc'}
onChange={handleFilterChange}
className="w-32"
options={[
{ value: "upload_time_desc", label: "上传时间 ↓" },
{ value: "upload_time_asc", label: "上传时间 ↑" },
// { value: "issue_count_desc", label: "问题数量 ↓" },
// { value: "issue_count_asc", label: "问题数量 ↑" }
]}
/>
<SearchFilter
label="搜索"
placeholder="搜索文件名、合同编号"
value={searchParams.get('keyword') || ''}
onSearch={handleSearch}
buttonText=""
className="mr-2 flex-1"
/>
</FilterPanel>
{/* 文件列表 */}
<Card>
<div className={isLoading ? "opacity-70 pointer-events-none transition-opacity" : ""}>
{isLoading && <LoadingIndicator />}
{isLoading && files.length === 0 ? (
<TableRowSkeleton count={5} />
) : (
<Table
columns={columns}
dataSource={files}
rowKey="id"
emptyText={isLoading ? "加载中..." : "暂无文件数据"}
className="files-table table-auto-height"
/>
)}
{/* 分页组件 */}
{totalCount > 0 && (
<Pagination
currentPage={currentPage}
total={totalCount}
pageSize={pageSize}
onChange={handlePageChange}
onPageSizeChange={handlePageSizeChange}
showTotal={true}
showPageSizeChanger={true}
pageSizeOptions={[10, 20, 30, 50]}
/>
)}
</div>
{/* 附件追加模态框 */}
{showAttachmentUpload && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-semibold"></h3>
<button
onClick={() => {
setShowAttachmentUpload(false);
setSelectedDocumentId(null);
setAttachmentFiles([]);
setAttachmentRemark("");
}}
className="text-gray-400 hover:text-gray-600"
>
<i className="ri-close-line text-xl"></i>
</button>
</div>
<div className="space-y-4">
<div className="bg-gray-50 p-3 rounded">
<p className="text-sm text-gray-600">
ID: <span className="font-medium">{selectedDocumentId}</span>
</p>
<p className="text-xs text-gray-500 mt-1">
PDFWordZIPRAR格式ZIP/RAR内仅合并其中的PDF文件
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<span className="text-red-500">*</span>
</label>
<div className="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center hover:border-gray-400 transition-colors">
<input
type="file"
multiple
accept=".pdf,.doc,.docx,.zip,.rar"
onChange={(e) => e.target.files && handleAttachmentFilesSelected(e.target.files)}
className="hidden"
id="attachment-file-input"
/>
<label htmlFor="attachment-file-input" className="cursor-pointer">
<i className="ri-attachment-line text-3xl text-gray-400 mb-2 block"></i>
<p className="text-sm text-gray-600"></p>
<p className="text-xs text-gray-500 mt-1">PDFWordZIPRAR格式</p>
</label>
</div>
{attachmentFiles.length > 0 && (
<div className="mt-2">
<p className="text-sm text-green-600 mb-2">
<i className="ri-checkbox-circle-line"></i> {attachmentFiles.length}
</p>
<div className="space-y-1 max-h-32 overflow-y-auto">
{attachmentFiles.map((file, index) => (
<div key={index} className="text-xs text-gray-600 bg-gray-50 p-2 rounded">
<i className="ri-file-line mr-1"></i>
{file.name} ({formatFileSize(file.size)})
</div>
))}
</div>
</div>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
</label>
<div className="space-y-2">
<label className="flex items-center">
<input
type="radio"
name="mergeMode"
value="overwrite"
checked={attachmentMergeMode === 'overwrite'}
onChange={(e) => setAttachmentMergeMode(e.target.value as 'overwrite' | 'new')}
className="mr-2"
/>
<span className="text-sm"></span>
</label>
<label className="flex items-center">
<input
type="radio"
name="mergeMode"
value="new"
checked={attachmentMergeMode === 'new'}
onChange={(e) => setAttachmentMergeMode(e.target.value as 'overwrite' | 'new')}
className="mr-2"
/>
<span className="text-sm"></span>
</label>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
</label>
<textarea
value={attachmentRemark}
onChange={(e) => setAttachmentRemark(e.target.value)}
className="w-full p-2 border border-gray-300 rounded-md text-sm"
rows={3}
placeholder="请输入备注信息..."
/>
</div>
<div className="flex justify-end gap-3 pt-4 border-t">
<button
className="px-4 py-2 text-sm border border-gray-300 rounded-md hover:bg-gray-50"
onClick={() => {
setShowAttachmentUpload(false);
setSelectedDocumentId(null);
setAttachmentFiles([]);
setAttachmentRemark("");
}}
disabled={attachmentUploading}
>
</button>
<button
className="px-4 py-2 text-sm bg-primary text-white rounded-md hover:bg-primary-dark disabled:opacity-50"
onClick={handleAttachmentUpload}
disabled={attachmentFiles.length === 0 || attachmentUploading}
>
{attachmentUploading ? '上传中...' : '开始追加'}
</button>
</div>
</div>
</div>
</div>
)}
</Card>
</div>
);
}
// 错误边界
export function ErrorBoundary() {
return (
<div className="error-container p-6">
<h1 className="text-xl font-normal text-red-500 mb-4"></h1>
<p className="mb-4"></p>
<Button type="primary" to="/"></Button>
</div>
);
}
+113 -79
View File
@@ -1,4 +1,4 @@
import React, { useState, useEffect, useCallback } from 'react';
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { type MetaFunction, type LoaderFunctionArgs } from "@remix-run/node";
import { useLoaderData, useSearchParams, Link, useNavigate, useFetcher, useRouteLoaderData, useLocation } from "@remix-run/react";
import { Button } from '~/components/ui/Button';
@@ -38,6 +38,10 @@ export const meta: MetaFunction = () => {
];
};
export const handle = {
breadcrumb: "评查点列表"
};
// 声明loader返回的数据类型
export type LoaderData = {
rules: Rule[];
@@ -70,9 +74,17 @@ interface ActionResponse {
}
function mapApiRuleToModel(apiRule: ApiRule): Rule {
// 🔑 清洗评查点编码:移除最后一个 '--' 及其后面的字符
// 例如:'code-mis--mz' --> 'code-mis', 'code-mbs--alsi--gz' --> 'code-mbs--alsi'
let cleanedCode = apiRule.code;
const lastDoubleHyphenIndex = cleanedCode.lastIndexOf('--');
if (lastDoubleHyphenIndex !== -1) {
cleanedCode = cleanedCode.substring(0, lastDoubleHyphenIndex);
}
return {
id: apiRule.id,
code: apiRule.code,
code: cleanedCode,
name: apiRule.name,
ruleType: apiRule.ruleType as RuleType, // 类型转换
ruleGroupId: apiRule.groupId,
@@ -105,22 +117,13 @@ export async function loader({ request }: LoaderFunctionArgs) {
const { getUserSession } = await import("~/api/login/auth.server");
const { frontendJWT } = await getUserSession(request);
// 获取评查点类型列表,供前端筛选使用
const typeResponse = await getRuleTypes(undefined, frontendJWT);
if (typeResponse.error) {
console.error('获取评查点类型失败:', typeResponse.error);
}
const ruleTypes = typeResponse.error ? [] : typeResponse.data;
// 返回初始空数据,客户端将根据 sessionStorage 中的 reviewType 加载实际数据
// 返回初始空数据,客户端将根据 sessionStorage 中的 documentTypeIds 加载实际数据
return Response.json({
rules: [],
totalCount: 0,
currentPage: params.page,
pageSize: params.pageSize,
ruleTypes,
ruleTypes: [], // 服务端无法访问 sessionStorage,客户端加载
initialLoad: true,
frontendJWT
}, {
@@ -198,16 +201,16 @@ export default function RulesIndex() {
// 状态管理
const [ruleGroups, setRuleGroups] = useState<RuleGroup[]>([]);
const [loadingGroups, setLoadingGroups] = useState(false);
const [reviewType, setReviewType] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [filteredRules, setFilteredRules] = useState<Rule[]>(initialRules);
const [filteredTotalCount, setFilteredTotalCount] = useState<number>(initialTotalCount);
const [ruleTypes, setRuleTypes] = useState<ApiRuleType[]>(initialRuleTypes);
// 添加一个路由变化计数器
const [routeChangeCount, setRouteChangeCount] = useState(0);
// 添加一个状态来跟踪是否执行了删除操作
const [isDeleting, setIsDeleting] = useState(false);
// 使用 ref 跟踪是否正在加载数据,避免重复加载
const isLoadingRef = useRef(false);
// 查询参数记忆 key 与保存/恢复
const SEARCH_PARAMS_STORAGE_KEY = 'rules.searchParams';
@@ -231,19 +234,13 @@ export default function RulesIndex() {
// 获取当前的ruleType值
const ruleTypeParam = searchParams.get('ruleType');
// 追踪路由变化
useEffect(() => {
// console.log("路由变化:", location.key);
setRouteChangeCount(prev => prev + 1);
}, [location]);
// 判断是否禁用规则组选择
const isRuleGroupSelectDisabled = loadingGroups || !ruleTypeParam || ruleGroups.length === 0;
// 检查用户是否为开发者角色
const userRole = rootData?.userRole || 'common';
const isDeveloper = userRole === 'admin';
const isDeveloper = userRole.includes('admin');
// 调试日志
// console.log("🔑 [Rules List] rootData:", rootData);
@@ -261,56 +258,93 @@ export default function RulesIndex() {
useEffect(() => {
if(loaderData.error) {
toastService.error(loaderData.error);
}else if(loaderData.ruleTypes.length === 0){
toastService.error("评查点类型数据为空");
}
}, [loaderData.error,loaderData.ruleTypes]);
// ❌ 不再检查 loaderData.ruleTypes,因为服务端永远返回空数组
// 如果需要检查评查点类型数据,应该在 fetchData 完成后检查状态 ruleTypes
}, [loaderData.error]);
// 客户端数据加载函数
const fetchData = useCallback(async () => {
try {
// 从sessionStorage获取reviewType
const storedReviewType = typeof window !== 'undefined' ? sessionStorage.getItem('reviewType') : null;
const typeToUse = reviewType || storedReviewType;
if (!typeToUse) {
console.warn('无法加载评查点数据:未找到reviewType');
// 🔑 如果正在加载,避免重复调用
if (isLoadingRef.current) {
console.log('📋 [fetchData] 正在加载中,跳过重复调用');
return;
}
// console.log("fetchData被调用,加载评查点数据", typeToUse);
// 🔑 从 sessionStorage 获取 documentTypeIds
const typeIdsStr = typeof window !== 'undefined' ? sessionStorage.getItem('documentTypeIds') : null;
const documentTypeIds = typeIdsStr ? JSON.parse(typeIdsStr) : null;
if (!documentTypeIds || documentTypeIds.length === 0) {
console.warn('无法加载评查点数据:未找到 documentTypeIds');
return;
}
isLoadingRef.current = true;
// 🔑 从 localStorage 获取 user_info 中的 area
let userArea: string | undefined = undefined;
if (typeof window !== 'undefined') {
try {
const userInfoStr = localStorage.getItem('user_info');
if (userInfoStr) {
const userInfo = JSON.parse(userInfoStr);
userArea = userInfo.area;
console.log("📋 [fetchData] 从 localStorage 获取到用户地区:", userArea);
}
} catch (error) {
console.error('解析 user_info 失败:', error);
}
}
console.log("📋 [fetchData] 开始加载评查点数据, documentTypeIds:", documentTypeIds, "area:", userArea);
setLoading(true);
// 获取评查点类型
// 🔑 获取评查点类型(通过 documentTypeIds
let loadedRuleTypes: ApiRuleType[] = [];
try {
const typeResponse = await getRuleTypes(typeToUse, loaderData.frontendJWT);
const typeResponse = await getRuleTypes(documentTypeIds, loaderData.frontendJWT);
if (typeResponse.data) {
setRuleTypes(typeResponse.data);
loadedRuleTypes = typeResponse.data;
setRuleTypes(loadedRuleTypes);
console.log("📋 [fetchData] 获取到评查点类型:", loadedRuleTypes);
}
} catch (error) {
console.error('加载评查点类型失败:', error);
}
// 构建查询参数
// 🔑 当选择"全部"或未选择评查点类型时,使用下拉框中所有评查点类型的 id 组合
let finalRuleType: string | undefined = undefined;
if (ruleTypeParam && ruleTypeParam !== 'all') {
// 选择了具体的评查点类型
finalRuleType = ruleTypeParam;
} else if (loadedRuleTypes && loadedRuleTypes.length > 0) {
// 选择"全部"或未选择,使用刚加载的评查点类型的 id
finalRuleType = loadedRuleTypes.map(type => type.id).join(',');
console.log("📋 [fetchData] 选择全部类型,使用 loadedRuleTypes 的 id 组合:", finalRuleType);
}
const queryParams = {
ruleType: ruleTypeParam || undefined,
ruleType: finalRuleType,
groupId: searchParams.get('groupId') || undefined,
isActive: searchParams.get('isActive') ? searchParams.get('isActive') === 'true' : undefined,
keyword: searchParams.get('keyword') || undefined,
area: userArea, // 添加地区过滤
page: currentPage,
pageSize,
reviewType: typeToUse,
token: loaderData.frontendJWT
};
// 调用 API 获取数据
const response = await getRulesList(queryParams);
if (response.data) {
const apiRules = response.data.rules || [];
const total = response.data.totalCount || 0;
const mappedRules = apiRules.map((apiRule: ApiRule) => mapApiRuleToModel(apiRule));
setFilteredRules(mappedRules);
setFilteredTotalCount(total);
}
@@ -319,8 +353,9 @@ export default function RulesIndex() {
toastService.error('加载评查点列表失败');
} finally {
setLoading(false);
isLoadingRef.current = false;
}
}, [reviewType, ruleTypeParam, searchParams, currentPage, pageSize]);
}, [ruleTypeParam, searchParams, currentPage, pageSize, loaderData.frontendJWT]);
// 当评查点类型变化时,加载对应的规则组
useEffect(() => {
@@ -380,55 +415,54 @@ export default function RulesIndex() {
}
}, [fetcher.data, fetcher.state, fetchData, isDeleting]);
// 在组件挂载时从 sessionStorage 获取 reviewType 并加载数据
// 在组件挂载时从 sessionStorage 获取 documentTypeIds 并加载数据
useEffect(() => {
try {
if (typeof window !== 'undefined') {
const storedReviewType = sessionStorage.getItem('reviewType');
// console.log("组件挂载,从sessionStorage获取reviewType:", storedReviewType);
if (storedReviewType !== reviewType) {
setReviewType(storedReviewType);
}
// 无论如何,都加载数据,不依赖于reviewType的变化
if (storedReviewType) {
// 使用setTimeout确保该操作在其他状态更新之后执行
const typeIdsStr = sessionStorage.getItem('documentTypeIds');
const documentTypeIds = typeIdsStr ? JSON.parse(typeIdsStr) : null;
console.log("📋 组件挂载,从 sessionStorage 获取 documentTypeIds:", documentTypeIds);
// 如果有 documentTypeIds,加载数据
if (documentTypeIds && documentTypeIds.length > 0) {
// 使用 setTimeout 确保该操作在其他状态更新之后执行
setTimeout(() => {
fetchData();
}, 0);
}
}
} catch (error) {
console.error('获取 sessionStorage 中的 reviewType 失败:', error);
console.error('获取 sessionStorage 中的 documentTypeIds 失败:', error);
}
}, [initialLoad, fetchData]);
// 监听路由变化,每次路由到此页面时刷新数据
useEffect(() => {
if (routeChangeCount > 0) {
// console.log("路由变化触发数据刷新,计数:", routeChangeCount);
const storedReviewType = typeof window !== 'undefined' ? sessionStorage.getItem('reviewType') : null;
// console.log("storedReviewType:", storedReviewType);
if (storedReviewType) {
if (storedReviewType !== reviewType) {
setReviewType(storedReviewType);
}
// 使用setTimeout确保该操作在其他状态更新之后执行
setTimeout(() => {
fetchData();
}, 0);
}
}
}, [routeChangeCount, fetchData]);
// 注释掉重复的路由监听逻辑,避免与searchParams监听重复触发
// useEffect(() => {
// if (routeChangeCount > 0) {
// console.log("📋 路由变化触发数据刷新,计数:", routeChangeCount);
// const typeIdsStr = typeof window !== 'undefined' ? sessionStorage.getItem('documentTypeIds') : null;
// const documentTypeIds = typeIdsStr ? JSON.parse(typeIdsStr) : null;
// console.log("📋 documentTypeIds:", documentTypeIds);
// if (documentTypeIds && documentTypeIds.length > 0) {
// // 使用 setTimeout 确保该操作在其他状态更新之后执行
// setTimeout(() => {
// fetchData();
// }, 0);
// }
// }
// }, [routeChangeCount, fetchData]);
// 监听 URL 参数变化,重新获取数据
useEffect(() => {
if (reviewType) {
// 检查是否有 documentTypeIds
const typeIdsStr = typeof window !== 'undefined' ? sessionStorage.getItem('documentTypeIds') : null;
const documentTypeIds = typeIdsStr ? JSON.parse(typeIdsStr) : null;
if (documentTypeIds && documentTypeIds.length > 0) {
fetchData();
}
}, [searchParams, fetchData, reviewType]);
}, [searchParams, fetchData]);
// 筛选评查点
const handleFilterChange = (e: React.ChangeEvent<HTMLSelectElement | HTMLInputElement>) => {
+110 -26
View File
@@ -26,7 +26,7 @@
*/
import { type MetaFunction } from "@remix-run/node";
import { useState, useEffect, useCallback } from "react";
import { useState, useEffect, useCallback, useRef } from "react";
import { BasicInfo } from "~/components/rules/new/BasicInfo";
import { ExtractionSettings } from "~/components/rules/new/ExtractionSettings";
import { ReviewSettings } from "~/components/rules/new/ReviewSettings";
@@ -138,13 +138,17 @@ export default function RuleNew() {
const navigate = useNavigate();
const location = useLocation();
const [isEditMode, setIsEditMode] = useState(false);
const [isCopyMode, setIsCopyMode] = useState(false); // 添加复制模式状态
const [isLoading, setIsLoading] = useState(false);
const [instanceKey, setInstanceKey] = useState<string>('new');
// 从root路由获取用户角色和JWT token
const rootData = useRouteLoaderData("root") as { userRole: UserRole; frontendJWT?: string };
const userRole = rootData?.userRole || 'common';
const frontendJWT = rootData?.frontendJWT;
// 使用 ref 跟踪当前加载的 URL,避免重复加载
const loadedUrlRef = useRef<string>('');
const [formData, setFormData] = useState<EvaluationPoint>({});
const [evaluationPointGroups, setEvaluationPointGroups] = useState<EvaluationPointGroup[]>([]);
@@ -266,11 +270,12 @@ export default function RuleNew() {
* 获取评查点数据
* 编辑模式下从API获取指定ID的评查点数据
* @param id 评查点ID
* @param isCopy 是否为复制模式
*/
const fetchEvaluationPoint = useCallback(async (id: number) => {
const fetchEvaluationPoint = useCallback(async (id: number, isCopy = false) => {
try {
setIsLoading(true);
// console.log(`获取评查点数据,ID: ${id}`);
// console.log(`获取评查点数据,ID: ${id}, 复制模式: ${isCopy}`);
// 使用 postgrestGet 替代直接调用 fetch
const postgrestParams = {
filter: {
@@ -283,23 +288,43 @@ export default function RuleNew() {
if (response.data) {
// 使用extractApiData从响应中提取数据
const evaluationPoints = extractApiData<EvaluationPoint[]>(response.data);
if (evaluationPoints && Array.isArray(evaluationPoints) && evaluationPoints.length > 0) {
try {
// 使用JSON序列化和反序列化来进行深拷贝,避免浏览器差异
const originalData = evaluationPoints[0];
const jsonString = JSON.stringify(originalData);
const data = JSON.parse(jsonString);
// 🔄 复制模式:删除不应该复制的字段
if (isCopy) {
delete data.id;
delete data.created_at;
delete data.updated_at;
delete data.usage_count;
// console.log('📋 复制模式:已清除不应复制的字段(id, created_at, updated_at, usage_count');
}
// 🔑 清洗评查点编码:移除最后一个 '--' 及其后面的字符
// 例如:'code-mis--mz' --> 'code-mis', 'code-mbs--alsi--gz' --> 'code-mbs--alsi'
if (data.code) {
const lastDoubleHyphenIndex = data.code.lastIndexOf('--');
if (lastDoubleHyphenIndex !== -1) {
data.code = data.code.substring(0, lastDoubleHyphenIndex);
// console.log('🔑 已清洗评查点编码:', data.code);
}
}
// 设置表单数据
setFormData(data);
// 初始化extractionFields
const extractedFields = extractFieldsFromFormData(data);
setExtractionFields(extractedFields);
// 设置编辑模式的实例键
setInstanceKey(`edit_${id}_${Date.now()}`);
// 设置实例键
setInstanceKey(isCopy ? `copy_${id}_${Date.now()}` : `edit_${id}_${Date.now()}`);
} catch (jsonError) {
console.error('JSON处理错误:', jsonError);
toastService.error(`数据处理错误: ${jsonError instanceof Error ? jsonError.message : '未知错误'}`);
@@ -332,14 +357,30 @@ export default function RuleNew() {
*/
const fetchEvaluationPointGroups = useCallback(async () => {
try {
// console.log("获取评查点组数据");
// console.log("🔍 [fetchEvaluationPointGroups] 开始获取评查点组数据");
const response = await postgrestGet('evaluation_point_groups', { token: frontendJWT });
if (response.data && Array.isArray(response.data) && response.data.length > 0) {
setEvaluationPointGroups(response.data);
// console.log("🔍 [fetchEvaluationPointGroups] API响应:", response);
if (response.data) {
// 使用 extractApiData 提取数据(处理可能的包装格式)
const extractedData = extractApiData<EvaluationPointGroup[]>(response.data);
// console.log("🔍 [fetchEvaluationPointGroups] 提取后的数据:", extractedData);
if (extractedData && Array.isArray(extractedData) && extractedData.length > 0) {
setEvaluationPointGroups(extractedData);
// console.log(`✅ [fetchEvaluationPointGroups] 成功加载 ${extractedData.length} 个评查点组`);
} else {
console.warn("⚠️ [fetchEvaluationPointGroups] 提取的数据为空或格式不正确");
setEvaluationPointGroups([]);
}
} else if (response.error) {
console.error('❌ [fetchEvaluationPointGroups] API返回错误:', response.error);
setEvaluationPointGroups([]);
}
} catch (error) {
console.error('获取评查点组数据失败:', error);
console.error('❌ [fetchEvaluationPointGroups] 获取评查点组数据失败:', error);
setEvaluationPointGroups([]);
// 显示错误提示但不影响应用继续使用
toastService.error(`获取评查点组数据失败: ${error instanceof Error ? error.message : '未知错误'}\n将使用默认数据`);
}
@@ -761,6 +802,7 @@ export default function RuleNew() {
let response;
if (isEditMode) {
response = await postgrestPut('evaluation_points', finalData, {id: formData.id!}, frontendJWT);
// console.log("最终提交的数据", finalData)
} else {
response = await postgrestPost('evaluation_points', finalData, frontendJWT);
}
@@ -909,46 +951,85 @@ export default function RuleNew() {
* 3. 获取评查点组数据(用于表单选择项)
*/
useEffect(() => {
const searchParams = new URLSearchParams(location.search);
const id = searchParams.get('id');
const currentUrl = location.search;
const currentPathname = location.pathname;
const fullUrl = `${currentPathname}${currentUrl}`;
// 🔑 如果 URL 没有变化,不重复加载(避免无限循环)
// 使用完整路径(pathname + search)进行比较,避免新增页面被拦截
if (loadedUrlRef.current === fullUrl) {
console.log('🔄 [useEffect] URL未变化,跳过重复加载:', fullUrl);
return;
}
// console.log('🔄 [useEffect] URL变化,开始加载数据:', { previous: loadedUrlRef.current, current: fullUrl });
const searchParams = new URLSearchParams(currentUrl);
const id = searchParams.get('id');
const mode = searchParams.get('mode');
// 判断是否为复制模式
const isCopy = mode === 'copy';
// 编辑或复制模式下设置加载状态
if (id || mode === 'copy') {
if (id || isCopy) {
setIsLoading(true);
}
// 设置编辑模式
if (mode && mode === 'copy') {
// 设置复制模式状态
setIsCopyMode(isCopy);
// 设置编辑模式(复制模式不是编辑模式)
if (isCopy) {
setIsEditMode(false);
} else {
setIsEditMode(!!id);
}
if (id) {
// 编辑模式:获取数据
fetchEvaluationPoint(parseInt(id));
// 编辑或复制模式:获取数据(传入复制模式标志)
console.log('📝 [useEffect] 编辑/复制模式,加载评查点数据,ID:', id);
fetchEvaluationPoint(parseInt(id), isCopy);
} else {
// 新建模式:重置表单数据
console.log('📝 [useEffect] 新建模式,重置表单数据');
resetFormData();
}
// 获取评查点组数据
// console.log('📝 [useEffect] 获取评查点组数据');
fetchEvaluationPointGroups();
// 获取VLM字段类型选项
// console.log('📝 [useEffect] 获取VLM字段类型选项');
fetchVlmFieldTypeOptions();
}, [location.search, fetchEvaluationPoint, fetchEvaluationPointGroups, fetchVlmFieldTypeOptions, resetFormData]);
// 记录已加载的 URL(使用完整路径)
loadedUrlRef.current = fullUrl;
}, [location.search, location.pathname, fetchEvaluationPoint, fetchEvaluationPointGroups, fetchVlmFieldTypeOptions, resetFormData]);
// 渲染页面内容
return (
<div className="container">
{/* 页面标题和右上角保存按钮 */}
<PageHeader
title={isEditMode ? (isReadOnly ? "查看评查点" : "编辑评查点") : "新增评查点"}
title={isCopyMode ? "复制评查点" : (isEditMode ? (isReadOnly ? "查看评查点" : "编辑评查点") : "新增评查点")}
onSave={handleSave}
showSaveButton={!isReadOnly}
/>
{/* 复制模式提示 */}
{isCopyMode && !isLoading && (
<div className="mb-4 p-1 bg-blue-50 border border-blue-200 rounded-md">
<div className="flex items-center">
<i className="ri-information-line text-blue-500 text-xl mr-2"></i>
<div className="text-sm text-blue-800">
<span className="font-medium"></span>
</div>
</div>
</div>
)}
{/* 加载状态显示 */}
{isLoading ? (
<div className="flex justify-center items-center p-12">
@@ -965,6 +1046,7 @@ export default function RuleNew() {
{/* 评查点基本信息设置 */}
<div className="mb-8">
<BasicInfo
key={instanceKey}
onChange={handleBasicInfoChange}
initialData={formData}
evaluationPointGroups={evaluationPointGroups}
@@ -975,6 +1057,7 @@ export default function RuleNew() {
{/* 抽取设置 - 配置从文档中提取的字段 */}
<div className="mb-8">
<ExtractionSettings
key={instanceKey}
onChange={handleExtractionSettingsChange}
initialData={formData}
promptTypeOptions={EVALUATION_OPTIONS.llmPromptTypeOptions}
@@ -985,6 +1068,7 @@ export default function RuleNew() {
{/* 评查设置 - 配置评查规则、消息等 */}
<div className="mb-8">
<ReviewSettings
key={instanceKey}
onChange={handleReviewSettingsChange}
initialData={{
rules: formData.evaluation_config?.rules || [],