diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..ef702b5 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,53 @@ +# 项目级记忆 + +## 规则库改造约定 + +- 新版规则维护默认入口使用 `/rulesTest/list`,旧版 `/rules/list` 仅作为页面内“查看旧版本”对比入口保留。 +- 规则库入口按当前模块上下文传参: + - 案卷模块保留二级菜单:行政处罚、行政许可。 + - 合同、内部公文等模块不在页面内切换文档类型或主类型,只保留子类型、规则组和搜索筛选。 +- 文档类型、主类型、子类型必须隔离加载,不能混合展示不同模块规则。 +- 子类型对应原“属性类型”,页面命名统一为“子类型/子分类”。 +- 列表中“子分类”只展示子类型标签,不展示大分类或主类型。 +- 规则组筛选为单选下拉,候选值按当前文档类型、主类型、子类型从 YAML 规则组派生。 + +## YAML 数据流程 + +- 当前阶段使用 `mock-data/leaudit-rules` 中复制的 `leaudit/rules` 作为 mock 数据源,避免提交代码时丢失样例规则。 +- 生产流程约定: + - 数据库只记录各类 YAML 的 OSS 路径。 + - 前端通过后端读取 OSS YAML 后渲染结构化页面。 + - 前端维护后提交结构化配置给后端。 + - 后端重组 YAML 并更新 OSS 对应文件。 +- 评查规则 YAML 不写入数据库正文。 +- 新页面代码中保留数据源切换注释,后续从 mock 切到后端接口时优先复用现有结构化消费模型。 + +## YAML 内容规范 + +- 规则组名称要使用明确中文描述,不使用 code、规则编号、章节符号、法条符号或“来源”前缀。 +- 列表依赖字段不能为空;若 YAML 规则没有显式字段引用,需要按规则语义补充 `dependencies`。 +- 前端 mock 解析器需要支持显式 `dependencies`,以及 `field`、`target`、`left_field`、`right_field`、`element`、`seal_id`、`signature_id` 等语义字段。 +- 字段抽取配置中,字段组和字段类型使用当前 YAML 汇总枚举;`multi_entity` 不作为字段类型下拉项,而是“多实体”独立开关。 +- `verbatim` 表示按原文摘录,不等同于评查规则里的完全匹配。 +- 案卷文书字段属于案卷文书内部配置,不作为独立业务类型;点击文书名称在表内展开字段列表。 +- 评查规则表格需要展示“检查方式”,规则类型以 YAML 实际值为准: + - `deterministic`:确定性检查。 + - `ai_rule`:智能语义检查。 + - `rule_group`:规则组合。 +- 智能语义检查需要维护提示词,提示词来源和回写位置是 `stages` 中 `check: ai` 下的 `prompt`。 +- 规则编辑中,规则组使用当前 YAML 规则组下拉;依赖字段默认只展示已添加项,需要通过“追加字段”弹窗按文书/字段组分组、模糊搜索、多选追加。 +- 规则组合不做可视化编排器,只展示子规则 ID 和内容,并提供一个逻辑运算式输入框。 + - 如果 YAML 有 `stages`,子规则来自 `stages[*].id/check/...`,逻辑运算式通常形如 `1 AND 2`。 + - 如果 YAML 是 `type: rule_group` 且带 `rules: [JK-002, ...]` 的引用式结构,页面兼容展示引用规则 ID 和名称,逻辑运算式可形如 `JK-002 AND JK-005`。 + - 注意不要把页面规则组 `group` 和单条规则内部的子规则混淆。 +- 合同 `draft/executed` 是业务阶段,不等同于评查规则 `stages`;页面文案统一为“业务阶段”和“规则步骤/检查步骤”。 +- YAML 源内容缺漏修正暂不直接修改 mock 源文件,先在页面中暴露问题,后续集中安排补全: + - 规则步骤存在但未被 `logic` 引用时,需要在详情页标识“未参与逻辑”。 + - 依赖字段无法匹配当前字段库/文书库/视觉要素/派生字段时,保留 YAML 原始引用并标识来源。 + - 已知样例:`JZ-LA-001` 有 6 个 `stages`,但原始 `logic: (1 AND 4) OR 6` 只引用步骤 1、4、6,步骤 2、3、5 未参与最终逻辑;步骤 3 是个人信息对身份证,步骤 5 是个人姓名/住址对营业执照法代/住所,不完全重复但语义需业务确认。 + - 已知样例:部分合同规则依赖字段与抽取字段命名不一致,如 `甲方/乙方` 对 `委托方/受托人`、`违约金` 对 `违约金金额`,后续需要统一 YAML 字段命名或补充别名映射。 + +## 当前测试服务 + +- 本地测试端口使用 `http://localhost:51703`。 +- 未登录访问新版规则页会跳转 `/login?redirect=...`,这是正常鉴权行为。 diff --git a/app/api/auth/check-route-permission.server.ts b/app/api/auth/check-route-permission.server.ts index a615498..2d3260f 100644 --- a/app/api/auth/check-route-permission.server.ts +++ b/app/api/auth/check-route-permission.server.ts @@ -4,6 +4,7 @@ */ import { getUserRoutesByRole, type MenuItem } from './user-routes'; +import { normalizeRoutePathForPermission } from '~/utils/route-alias'; /** * 从 MenuItem 数组中提取所有路径(包括子路由) @@ -50,15 +51,17 @@ function isDynamicIdSegment(segment: string): boolean { * 检查路径是否在允许列表中 */ function isPathAllowed(pathname: string, allowedPaths: string[]): boolean { + const checkPath = normalizeRoutePathForPermission(pathname); + // 精确匹配 - if (allowedPaths.includes(pathname)) { + if (allowedPaths.includes(checkPath)) { return true; } // 动态路由匹配 for (const allowedPath of allowedPaths) { - if (pathname.startsWith(allowedPath + '/')) { - const subPath = pathname.substring(allowedPath.length + 1); + if (checkPath.startsWith(allowedPath + '/')) { + const subPath = checkPath.substring(allowedPath.length + 1); const segments = subPath.split('/'); const firstSegment = segments[0]; @@ -69,7 +72,7 @@ function isPathAllowed(pathname: string, allowedPaths: string[]): boolean { } // 根路径 - if (pathname === '/') { + if (checkPath === '/') { return true; } diff --git a/app/api/auth/user-routes.ts b/app/api/auth/user-routes.ts index 852f43b..9d9a9ac 100644 --- a/app/api/auth/user-routes.ts +++ b/app/api/auth/user-routes.ts @@ -124,7 +124,7 @@ const FALLBACK_MENU_DATA: Record = { { id: 'rules-list', title: '评查点列表', - path: '/rules', + path: '/rules/list', icon: 'ri-list-check-3', order: 2 }, @@ -266,7 +266,7 @@ const FALLBACK_MENU_DATA: Record = { { id: 'rules-list', title: '评查点列表', - path: '/rules', + path: '/rules/list', icon: 'ri-list-check-3', order: 2 }, @@ -381,7 +381,7 @@ const FALLBACK_MENU_DATA: Record = { { id: 'rules-list', title: '评查点列表', - path: '/rules', + path: '/rules/list', icon: 'ri-list-check-3', order: 2 }, @@ -489,7 +489,7 @@ const FALLBACK_MENU_DATA: Record = { { id: 'rules-list', title: '评查点列表', - path: '/rules', + path: '/rules/list', icon: 'ri-list-check-3', order: 2 }, @@ -1009,4 +1009,4 @@ export function mapUserRoleToRoleKey(userRole: string): string { // 如果找不到映射,返回 userRole 本身(假设后端已经返回了正确的 role_key) return roleMapping[userRole] || userRole || 'common'; -} \ No newline at end of file +} diff --git a/app/api/evaluation_points/rules.ts b/app/api/evaluation_points/rules.ts index b9c1042..b0f4f87 100644 --- a/app/api/evaluation_points/rules.ts +++ b/app/api/evaluation_points/rules.ts @@ -35,6 +35,7 @@ export interface RulesQueryParams { isActive?: boolean; keyword?: string; area?: string; // 地区过滤 + documentAttributeType?: string; // 子类型(原文档属性类型) orderBy?: string; orderDirection?: 'asc' | 'desc'; userRole?: string; // 用户角色 @@ -116,6 +117,8 @@ export interface Rule { priority: string; description: string; isActive: boolean; + area?: string; + documentAttributeType?: string; createdAt: string; updatedAt: string; } @@ -194,6 +197,7 @@ export async function getRulesList(params: RulesQueryParams): Promise<{data: Rul isActive, keyword, area, + documentAttributeType, userRole, token } = params; @@ -238,6 +242,11 @@ export async function getRulesList(params: RulesQueryParams): Promise<{data: Rul queryParams.append('is_enabled', isActive.toString()); } + // 添加子类型过滤(原 document_attribute_type) + if (documentAttributeType) { + queryParams.append('document_attribute_type', documentAttributeType); + } + // 🔑 添加地区过滤 // if (user_role === 'provincial_admin') { // queryParams.append('area', '省级'); @@ -1276,4 +1285,4 @@ export async function getAttributeTypes( ] }; } -} \ No newline at end of file +} diff --git a/app/components/layout/Layout.tsx b/app/components/layout/Layout.tsx index 20ab35b..bb65085 100644 --- a/app/components/layout/Layout.tsx +++ b/app/components/layout/Layout.tsx @@ -26,6 +26,30 @@ interface Match { data: unknown; } +type RulesTestDetailData = { + pack?: { + documentType?: string; + mainType?: string; + fields?: unknown[]; + subDocuments?: unknown[]; + visualElements?: unknown[]; + }; +}; + +type RulesTestListData = { + filters?: { + documentType?: string; + mainType?: string; + subtype?: string; + ruleGroup?: string; + keyword?: string; + }; + options?: { + subtypes?: string[]; + ruleGroups?: string[]; + }; +}; + export function Layout({ children, userRole = 'developer' as UserRole, frontendJWT = '', isMobile = false }: LayoutProps) { const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const [effectiveUserRole, setEffectiveUserRole] = useState(userRole); @@ -121,7 +145,31 @@ export function Layout({ children, userRole = 'developer' as UserRole, frontendJ if (shouldHideSidebar) { return <>{children}; } - + + const isRulesTestList = location.pathname.startsWith('/rulesTest/list'); + const isRulesTestDetail = location.pathname.startsWith('/rulesTest/detail'); + const isRulesTestTopbarPage = isRulesTestList || isRulesTestDetail; + const rulesTestListData = matches.find(match => match.pathname.startsWith('/rulesTest/list'))?.data as RulesTestListData | undefined; + const rulesTestDetailData = matches.find(match => match.pathname.startsWith('/rulesTest/detail'))?.data as RulesTestDetailData | undefined; + const listFilters = rulesTestListData?.filters || {}; + const listOptions = rulesTestListData?.options || {}; + const detailPack = rulesTestDetailData?.pack; + const isContractDetail = !!detailPack?.documentType?.includes('合同'); + const isCaseFileDetail = !!detailPack?.documentType?.includes('案卷'); + const showFieldNav = isContractDetail && (detailPack?.fields?.length || 0) > 0; + const showSubDocumentNav = isCaseFileDetail && (detailPack?.subDocuments?.length || 0) > 0; + const showVisualNav = (detailPack?.visualElements?.length || 0) > 0; + const rulesListHref = detailPack?.documentType + ? `/rulesTest/list?documentType=${encodeURIComponent(detailPack.documentType)}${detailPack.mainType ? `&mainType=${encodeURIComponent(detailPack.mainType)}` : ''}` + : '/rulesTest/list'; + const listScopeText = [ + listFilters.documentType, + listFilters.mainType && listFilters.mainType !== listFilters.documentType ? listFilters.mainType : '' + ].filter(Boolean).join(' / '); + const submitTopbarFilter = (event: React.ChangeEvent) => { + event.currentTarget.form?.requestSubmit(); + }; + return (
{/* 侧边栏始终保留,不再使用条件渲染 */} @@ -131,8 +179,111 @@ export function Layout({ children, userRole = 'developer' as UserRole, frontendJ userRole={effectiveUserRole} frontendJWT={effectiveFrontendJWT} /> - -
+ + {/* 规则列表页顶部栏 */} + {isRulesTestList && ( +
+
+
+ +
+

评查规则列表

+ + 评查规则库 + {listScopeText && ( + <> + / + {listScopeText} + + )} + +
+
+ +
+
+ + {listFilters.mainType && } + + + + +
+
+ )} + + {/* 规则详情页顶部栏 */} + {isRulesTestDetail && ( +
+
+
+ +
+

规则配置详情

+ + 规则列表 + / + 配置详情 + +
+
+ +
+
+ {showFieldNav && 字段抽取} + {showSubDocumentNav && 案卷文书} + {showVisualNav && 视觉要素} + 评查规则 +
+
+ )} + +
{/* 应用模块选择器 */} {/*
{APP_MODULES.map(app => ( @@ -150,10 +301,10 @@ export function Layout({ children, userRole = 'developer' as UserRole, frontendJ
*/}
- {!shouldHideBreadcrumb && } + {!shouldHideBreadcrumb && !isRulesTestTopbarPage && } {children}
); -} \ No newline at end of file +} diff --git a/app/components/layout/Sidebar.tsx b/app/components/layout/Sidebar.tsx index e8dc95d..f39db09 100644 --- a/app/components/layout/Sidebar.tsx +++ b/app/components/layout/Sidebar.tsx @@ -169,9 +169,18 @@ export function Sidebar({ onToggle, collapsed, userRole, frontendJWT = '' }: Sid })); }; - const isActive = (path: string) => { - return location.pathname === path || location.pathname.startsWith(`${path}/`); - }; + const isActive = (path: string) => { + const target = new URL(path, 'http://sidebar.local'); + + if (target.search) { + const currentParams = new URLSearchParams(location.search); + return location.pathname === target.pathname && Array.from(target.searchParams.entries()).every( + ([key, value]) => currentParams.get(key) === value + ); + } + + return location.pathname === target.pathname || location.pathname.startsWith(`${target.pathname}/`); + }; // 处理侧边栏切换事件 const handleToggleSidebar = (e: React.MouseEvent) => { @@ -181,17 +190,81 @@ export function Sidebar({ onToggle, collapsed, userRole, frontendJWT = '' }: Sid onToggle(); }; - // 处理子菜单项点击事件 - const handleSubMenuClick = (child: MenuItem, e: React.MouseEvent) => { - // 只需要阻止冒泡,不阻止默认行为 - e.stopPropagation(); - // console.log('子菜单点击:', child.title, '路径:', child.path); - }; - - // const isPort51707 = typeof window !== 'undefined' && window.location.port === '51707' - - // 处理菜单项:清理子菜单结构 - const processedMenuItems: MenuItem[] = menuItems.filter(item =>{ + // 处理子菜单项点击事件 + const handleSubMenuClick = (child: MenuItem, e: React.MouseEvent) => { + // 只需要阻止冒泡,不阻止默认行为 + e.stopPropagation(); + // console.log('子菜单点击:', child.title, '路径:', child.path); + }; + + const isRuleManagementMenu = (item: MenuItem) => + item.id === 'rule-management' || + item.path === '/rules' || + item.title === '评查规则库' || + !!item.children?.some(child => child.id === 'rules-list' || child.path === '/rules/list'); + const isCaseFileModule = selectedModuleName.includes('案卷') || selectedModuleName.includes('卷宗'); + const buildRulesTestListPath = (mainType?: string) => { + const params = new URLSearchParams(); + + if (isCaseFileModule) { + params.set('documentType', '案卷'); + if (mainType) params.set('mainType', mainType); + } else if (selectedModuleName.includes('合同')) { + params.set('documentType', '合同'); + params.set('mainType', '合同'); + } else if (selectedModuleName.includes('公文')) { + params.set('documentType', '内部公文'); + params.set('mainType', '内部公文'); + } else if (selectedModuleName) { + params.set('documentType', selectedModuleName); + params.set('mainType', selectedModuleName); + } + + const query = params.toString(); + return query ? `/rulesTest/list?${query}` : '/rulesTest/list'; + }; + + const normalizeRuleManagementMenu = (item: MenuItem): MenuItem => { + if (!isRuleManagementMenu(item)) { + return item; + } + + if (isCaseFileModule) { + return { + ...item, + children: [ + { + id: 'rules-admin-penalty', + title: '行政处罚', + path: buildRulesTestListPath('行政处罚'), + icon: 'ri-list-check-3', + order: 1 + }, + { + id: 'rules-admin-license', + title: '行政许可', + path: buildRulesTestListPath('行政许可'), + icon: 'ri-list-check-3', + order: 2 + } + ] + }; + } + + return { + ...item, + children: item.children?.map(child => ( + child.id === 'rules-list' || child.path === '/rules' || child.path === '/rules/list' + ? { ...child, path: buildRulesTestListPath() } + : child + )) + }; + }; + + // const isPort51707 = typeof window !== 'undefined' && window.location.port === '51707' + + // 处理菜单项:清理子菜单结构 + const processedMenuItems: MenuItem[] = menuItems.filter(item =>{ // console.log('菜单项:', item.title, 'Icon:', item.icon) // 🔑 优先检查:如果处于系统设置模式,只显示 /settings 及其子路由 @@ -255,11 +328,11 @@ export function Sidebar({ onToggle, collapsed, userRole, frontendJWT = '' }: Sid if (item.path === '/contract-template' || item.path?.startsWith('/contract-template/')) { return false; } - - // 保留其他菜单 - return true; - - }).map((item): MenuItem => { + + // 保留其他菜单 + return true; + + }).map(normalizeRuleManagementMenu).map((item): MenuItem => { // 处理子菜单:过滤隐藏的子菜单 if (item.children && item.children.length > 0) { // 过滤掉 hideBreadcrumb=true 的子菜单(这些通常是隐藏菜单) @@ -449,4 +522,4 @@ export function Sidebar({ onToggle, collapsed, userRole, frontendJWT = '' }: Sid
); -} \ No newline at end of file +} diff --git a/app/config/api-config.ts b/app/config/api-config.ts index bbb7bb3..9113805 100644 --- a/app/config/api-config.ts +++ b/app/config/api-config.ts @@ -1,493 +1,509 @@ -/** - * API配置文件 - * 统一管理所有API地址,方便部署时修改 - * 支持环境变量覆盖配置 - */ -// 环境配置类型 -interface ApiConfig { - // 主API基础URL(FastAPI后端地址,包含Dify代理) - baseUrl: string; - // 文档服务URL - documentUrl: string; - // 文档上传API URL - uploadUrl: string; - // Collabora Online 服务器地址 - collaboraUrl: string; - // 应用基础URL(用于 WOPI 回调) - appUrl: string; - // OAuth2.0配置 - oauth: { - // IDaaS服务器地址 - serverUrl?: string; - // OAuth2应用Client ID - clientId?: string; - // OAuth2应用Client Secret - clientSecret?: string; - // 回调地址(内网Web) - redirectUri?: string; - // 钉钉Web回调地址(互联网地址) - dingtalkRedirectUri?: string; - // 应用ID(用于登出) - appId?: string; - }; - // Dify 知识库检索配置 - dify: { - // Reranking 模型提供商 - rerankingProviderName: string; - // Reranking 模型名称 - rerankingModelName: string; - }; -} - -// 端口特定配置映射 -// 根据不同端口提供不同的API配置 -export const portConfigs: Record> = { - - // 主要 - // 梅州 - '51703': { - - baseUrl: 'http://nas.7bm.co:8073', - documentUrl: 'http://nas.7bm.co:8073/docauditai/', - uploadUrl: 'http://nas.7bm.co:8073/api/v2/documents', - collaboraUrl: 'http://nas.7bm.co:9980', - appUrl: 'http://nas.7bm.co:51703', - - - // baseUrl: 'http://172.16.0.56:8073', - // documentUrl: 'http://172.16.0.56:8073/docauditai/', - // uploadUrl: 'http://172.16.0.56:8073/api/v2/documents', - // collaboraUrl: 'http://172.16.0.81:9980', - // appUrl: 'http://172.16.0.34:51703', - - // baseUrl: 'http://10.79.97.17:8000', - // documentUrl: 'http://10.79.97.17:8000/docauditai/', - // uploadUrl: 'http://10.79.97.17:8000/api/v2/documents', - // collaboraUrl: 'http://10.79.97.17:9980', - // appUrl: 'http://10.79.97.17:51703', - - oauth: { - redirectUri: 'http://10.79.97.17:51703/callback', - // 钉钉Web回调地址(互联网地址)- 需要根据实际部署修改 - dingtalkRedirectUri: process.env.DINGTALK_REDIRECT_URI_51703 || 'https://10-79-97-1751703-b2oaixksdrrisox0t3.ztna-dingtalk.com/callback' - } - }, - - - // 云浮 - '51704': { - // baseUrl: 'http://172.16.0.55:8001', - // documentUrl: 'http://172.16.0.55:8001/docauditai/', - // uploadUrl: 'http://172.16.0.55:8001/api/v2/documents', - // collaboraUrl: 'http://172.16.0.81:9980', - // appUrl: 'http://172.16.0.34:51704', - - baseUrl: 'http://10.79.97.17:8001', - documentUrl: 'http://10.79.97.17:8001/docauditai/', - uploadUrl: 'http://10.79.97.17:8001/api/v2/documents', - collaboraUrl: 'http://10.79.97.17:9980', - appUrl: 'http://10.79.97.17:51704', - oauth: { - redirectUri: 'http://10.79.97.17:51704/callback', - dingtalkRedirectUri: process.env.DINGTALK_REDIRECT_URI_51704 || 'https://10-79-97-1751704-xxxxxxxxx.ztna-dingtalk.com/callback' - } - }, - - // 揭阳 - '51705': { - baseUrl: 'http://10.79.97.17:8002', - documentUrl: 'http://10.79.97.17:8002/docauditai/', - uploadUrl: 'http://10.79.97.17:8002/api/v2/documents', - collaboraUrl: 'http://10.79.97.17:9980', - appUrl: 'http://10.79.97.17:51705', - oauth: { - redirectUri: 'http://10.79.97.17:51705/callback', - dingtalkRedirectUri: process.env.DINGTALK_REDIRECT_URI_51705 || 'https://10-79-97-1751705-xxxxxxxxx.ztna-dingtalk.com/callback' - } - }, - - // 潮州 - '51706': { - baseUrl: 'http://10.79.97.17:8003', - documentUrl: 'http://10.79.97.17:8003/docauditai/', - uploadUrl: 'http://10.79.97.17:8003/api/v2/documents', - collaboraUrl: 'http://10.79.97.17:9980', - appUrl: 'http://10.79.97.17:51706', - oauth: { - redirectUri: 'http://10.79.97.17:51706/callback', - dingtalkRedirectUri: process.env.DINGTALK_REDIRECT_URI_51706 || 'https://10-79-97-1751706-xxxxxxxxx.ztna-dingtalk.com/callback' - } - }, - - - // 省局 - '51707': { - // baseUrl: 'http://172.16.0.55:8866', - // documentUrl: 'http://172.16.0.55:8866/docauditai/', - // uploadUrl: 'http://172.16.0.55:8866/api/v2/documents', - // collaboraUrl: 'http://172.16.0.81:9980', - // appUrl: 'http://172.16.0.34:51707', - - // 正式环境 - baseUrl: 'http://10.79.97.17:8866', - documentUrl: 'http://10.79.97.17:8866/docauditai/', - uploadUrl: 'http://10.79.97.17:8866/api/v2/documents', - collaboraUrl: 'http://10.79.97.17:9980', - appUrl: 'http://10.79.97.17:51707', - - oauth: { - redirectUri: 'http://10.79.97.17:51707/callback', - dingtalkRedirectUri: process.env.DINGTALK_REDIRECT_URI_51707 || 'https://10-79-97-1751707-xxxxxxxxx.ztna-dingtalk.com/callback' - } - }, - - //test - '51708': { - baseUrl: 'http://10.79.97.17:8005', - documentUrl: 'http://10.79.97.17:8005/docauditai/', - uploadUrl: 'http://10.79.97.17:8005/api/v2/documents', - collaboraUrl: 'http://10.79.97.17:9980', - appUrl: 'http://10.79.97.17:51708' - }, -}; - -// 不同环境的默认配置 -// 由于合同模板的上传,后续的的uploadUrl都不需要/upload,直接写/api/v2/documents,由程序自动添加/upload或/upload_contract_template -const configs: Record = { - // 开发环境 - development: { - // baseUrl: 'http://172.16.0.58:8073', // FastAPI后端(包含/dify代理) - // documentUrl: 'http://172.16.0.58:8073/docauditai/', - // uploadUrl: 'http://172.16.0.58:8073/api/v2/documents', - baseUrl: 'http://172.16.0.84:8073', // FastAPI后端(包含/dify代理) - documentUrl: 'http://172.16.0.84:8073/docauditai/', - uploadUrl: 'http://172.16.0.84:8073/api/v2/documents', - - collaboraUrl: 'http://172.16.0.58:9980', - appUrl: 'http://172.16.0.34:5173', - - oauth: { - serverUrl: 'http://10.79.112.85', // IDaaS服务器地址 - clientId: 'none', - clientSecret: 'none', // 需要替换为实际的Client Secret - redirectUri: 'http://10.79.97.17/', // 回调地址 - appId: 'idaasoauth2' // 应用ID,用于登出 - }, - dify: { - rerankingProviderName: 'langgenius/tongyi/tongyi', - rerankingModelName: 'gte-rerank' - } - }, - - // 测试环境 - testing: { - baseUrl: 'http://172.16.0.55:8073', // FastAPI后端(包含/dify代理) - documentUrl: 'http://172.16.0.55:8073/docauditai/', - uploadUrl: 'http://172.16.0.55:8073/api/v2/documents', - collaboraUrl: 'http://172.16.0.81:9980', - // appUrl: 'http://10.79.97.17:51703', - appUrl: 'http://172.16.0.34:5183', - oauth: { - serverUrl: 'http://10.79.112.85', // IDaaS服务器地址 - clientId: '54d2a619fe5c81ae1250434c441fccccqMtKwh7H4fO', - clientSecret: 'placeholder', // 需要替换为实际的Client Secret - redirectUri: 'http://10.79.97.17/', // 回调地址 - appId: 'idaasoauth2' // 应用ID,用于登出 - }, - dify: { - rerankingProviderName: 'langgenius/tongyi/tongyi', - rerankingModelName: 'gte-rerank' - } - }, - - // 生产环境 - production: { - baseUrl: 'http://10.79.97.17:8000', // FastAPI后端(包含/dify代理) - // minio - documentUrl: 'http://10.76.244.156:9000/docauditai/', - // 文件上传 - uploadUrl: 'http://10.79.97.17:8000/api/v2/documents', - collaboraUrl: 'http://10.79.97.17:9980', - appUrl: 'http://10.79.97.17:51703', - oauth: { - clientId: '224266374b56ee6254ed3d339014b033kaZy92exUmy', - // serverUrl: 'http://10.79.112.85', // IDaaS服务器地址(测试) - serverUrl: 'http://10.79.97.252', // IDaaS服务器地址(生产) - // ⚠️ 安全警告:clientSecret 不应该硬编码在代码中 - // 请在生产环境使用环境变量 OAUTH_CLIENT_SECRET - clientSecret: 'placeholder', // 占位符,实际值从环境变量获取 - redirectUri: 'http://10.79.97.17/', // 回调地址 - appId: 'idaasoauth2' // 应用ID,用于登出 - }, - dify: { - // rerankingProviderName: 'langgenius/tongyi/tongyi', - rerankingProviderName: 'langgenius/xinference/xinference', - // rerankingModelName: 'gte-rerank' - rerankingModelName: 'bge-reranker-v2-m3' - } - }, - - // 备用配置 (可以根据需要添加更多环境) - staging: { - baseUrl: 'http://172.16.0.119:9000/admin', // FastAPI后端(包含/dify代理) - documentUrl: 'http://nas.7bm.co:9000/docauditai/', - uploadUrl: 'http://172.16.0.119:8000/api/v2/documents', - collaboraUrl: 'http://10.79.97.17:9980', - appUrl: 'http://172.16.0.119:3000', - oauth: { - serverUrl: 'http://10.79.112.85', // IDaaS服务器地址 - clientId: 'none', // 需要替换为实际的Client ID - clientSecret: 'your_client_secret', // 需要替换为实际的Client Secret - redirectUri: 'http://172.16.0.119:3000/callback', // 回调地址 - appId: 'idaasoauth2' // 应用ID,用于登出 - }, - dify: { - rerankingProviderName: 'langgenius/tongyi/tongyi', - rerankingModelName: 'gte-rerank' - } - } -}; - -// 获取当前环境,默认为development -const getCurrentEnvironment = (): string => { - // 在服务器端,优先使用PM2设置的环境变量 - if (typeof window === 'undefined') { - // 服务器端:直接使用process.env.NODE_ENV - const nodeEnv = process.env.NODE_ENV; - // console.log('🔧 服务器端环境检测:', { - // NODE_ENV: nodeEnv, - // result: nodeEnv || 'development' - // }); - return nodeEnv || 'development'; - } - - // 客户端:优先使用NEXT_PUBLIC_前缀的环境变量 - const nextPublicNodeEnv = process.env.NEXT_PUBLIC_NODE_ENV; - const nodeEnv = process.env.NODE_ENV; - const result = nextPublicNodeEnv || nodeEnv || 'development'; - - // console.log('🔧 客户端环境检测:', { - // NEXT_PUBLIC_NODE_ENV: nextPublicNodeEnv, - // NODE_ENV: nodeEnv, - // result: result - // }); - - return result; -}; - -// 从环境变量获取配置,如果环境变量不存在则使用默认配置 -const getConfigFromEnv = (defaultConfig: ApiConfig): ApiConfig => { - return { - baseUrl: process.env.NEXT_PUBLIC_API_BASE_URL || defaultConfig.baseUrl, - documentUrl: process.env.NEXT_PUBLIC_DOCUMENT_URL || defaultConfig.documentUrl, - uploadUrl: process.env.NEXT_PUBLIC_UPLOAD_URL || defaultConfig.uploadUrl, - - collaboraUrl: defaultConfig.collaboraUrl || '', - appUrl: defaultConfig.appUrl || '', - - oauth: { - serverUrl: process.env.NEXT_PUBLIC_OAUTH_SERVER_URL || defaultConfig.oauth.serverUrl, - clientId: process.env.NEXT_PUBLIC_OAUTH_CLIENT_ID || defaultConfig.oauth.clientId, - // ⚠️ 注意:clientSecret 不应该使用 NEXT_PUBLIC_ 前缀 - // 应该只在服务器端通过 process.env.OAUTH_CLIENT_SECRET 访问 - clientSecret: process.env.OAUTH_CLIENT_SECRET || defaultConfig.oauth.clientSecret, - redirectUri: process.env.NEXT_PUBLIC_OAUTH_REDIRECT_URI || defaultConfig.oauth.redirectUri, - appId: process.env.NEXT_PUBLIC_OAUTH_APP_ID || defaultConfig.oauth.appId - }, - dify: defaultConfig.dify - }; -}; - -/** - * 获取当前端口号 - * 优先从浏览器location获取,然后从环境变量获取 - */ -const getCurrentPort = (): string => { - // 在客户端,优先从浏览器location获取端口 - let windowPort = ''; - if (typeof window !== 'undefined') { - windowPort = window.location.port || ''; - } - - // 在服务器端,优先使用运行时端口检测 - if (typeof window === 'undefined') { - const runtimePort = getRuntimePort(); - if (runtimePort) { - // console.log('🔧 服务器端运行时端口检测:', runtimePort); - return runtimePort; - } - } - - // 优先使用环境变量中的端口配置 - const nextPublicApiPortConfig = process.env.NEXT_PUBLIC_API_PORT_CONFIG; - const nextPublicPort = process.env.NEXT_PUBLIC_PORT; - const apiPortConfig = process.env.API_PORT_CONFIG; - const portEnv = process.env.PORT; - - // 优先级:windowPort > NEXT_PUBLIC_API_PORT_CONFIG > NEXT_PUBLIC_PORT > API_PORT_CONFIG > PORT环境变量 - const result = windowPort || nextPublicApiPortConfig || nextPublicPort || apiPortConfig || portEnv || ''; - - // console.log('🔧 端口检测:', { - // windowPort: windowPort, - // NEXT_PUBLIC_API_PORT_CONFIG: nextPublicApiPortConfig, - // NEXT_PUBLIC_PORT: nextPublicPort, - // API_PORT_CONFIG: apiPortConfig, - // PORT: portEnv, - // result: result - // }); - - return result; -}; - -/** - * 运行时端口检测 - 从服务器启动参数或环境变量获取实际端口 - * 这个方法只在服务器端运行,用于动态获取实际运行端口 - */ -const getRuntimePort = (): string => { - if (typeof window !== 'undefined') { - return ''; // 客户端不执行此逻辑 - } - - // 尝试从进程参数中获取端口 - const args = process.argv; - for (let i = 0; i < args.length; i++) { - if (args[i] === '--port' && i + 1 < args.length) { - return args[i + 1]; - } - if (args[i].startsWith('--port=')) { - return args[i].split('=')[1]; - } - } - - // 从环境变量获取 - return process.env.PORT || ''; -}; - -/** - * 获取当前配置 - * 支持根据端口动态切换API配置 - */ -const getCurrentConfig = (): ApiConfig => { - const env = getCurrentEnvironment(); - const port = getCurrentPort(); - - // console.log('🔧 配置调试信息:', { - // environment: env, - // port: port, - // hasPortConfig: !!(port && portConfigs[port]), - // portConfig: port ? portConfigs[port] : null - // }); - - // 获取基础配置 - let defaultConfig = configs[env] || configs.development; - - // 如果有端口特定配置,则合并配置 - if (port && portConfigs[port]) { - console.log(`🔧 使用端口特定配置: ${port}`); - const portConfig = portConfigs[port]; - defaultConfig = { - ...defaultConfig, - ...portConfig, - // 如果端口配置中有oauth,需要深度合并oauth配置 - oauth: portConfig.oauth - ? { ...defaultConfig.oauth, ...portConfig.oauth } - : defaultConfig.oauth - }; - // console.log(`🔧 使用端口特定配置---深度合并后: ${JSON.stringify(defaultConfig.oauth)}`) - } else { - // console.log(`🔧 使用环境配置: ${env}`, defaultConfig); - } - - // 只有在明确设置了环境变量的情况下才覆盖配置 - const hasEnvOverrides = process.env.NEXT_PUBLIC_API_BASE_URL || - process.env.NEXT_PUBLIC_DOCUMENT_URL || - process.env.NEXT_PUBLIC_UPLOAD_URL; - - if (hasEnvOverrides) { - // console.log('🔧 检测到环境变量覆盖,使用环境变量配置'); - return getConfigFromEnv(defaultConfig); - } - - console.log('🔧 最终配置:', defaultConfig); - return defaultConfig; -}; - -// 导出当前环境的配置 -export const apiConfig = getCurrentConfig(); - -// 导出具体的配置项,方便使用 -export const { - baseUrl: API_BASE_URL, - documentUrl: DOCUMENT_URL, - uploadUrl: UPLOAD_URL, - collaboraUrl: COLLABORA_URL, - appUrl: APP_URL, - oauth: OAUTH_CONFIG, - dify: DIFY_CONFIG -} = apiConfig; - -/** - * 🔓 客户端安全的 OAuth 配置(不包含 clientSecret) - * 可以安全地在客户端代码中使用 - */ -export const CLIENT_OAUTH_CONFIG = { - serverUrl: OAUTH_CONFIG.serverUrl as string, - clientId: OAUTH_CONFIG.clientId as string, - redirectUri: OAUTH_CONFIG.redirectUri as string, - appId: OAUTH_CONFIG.appId as string, - // 客户端不需要 clientSecret -}; - -// 导出所有配置,供调试使用 -export { configs }; - -// 工具函数:设置环境(主要用于测试) -export const setEnvironment = (env: string): ApiConfig => { - return configs[env] || configs.development; -}; - -/** - * 工具函数:获取当前端口配置信息(用于调试) - */ -export const getCurrentPortConfig = () => { - const port = getCurrentPort(); - const env = getCurrentEnvironment(); - return { - currentPort: port, - currentEnvironment: env, - hasPortConfig: !!(port && portConfigs[port]), - portConfig: port ? portConfigs[port] : null - }; -}; - -// 调试信息(仅在开发环境显示) -if (process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'testing') { - console.log('📦 API配置信息:', { - environment: getCurrentEnvironment(), - currentEnv: process.env.NODE_ENV, - port: getCurrentPort(), - config: { - ...apiConfig, - oauth: { - ...apiConfig.oauth, - clientSecret: '***' // 隐藏敏感信息 - } - }, - }); -} - -/** - * 交叉评查专属模式配置 - * 当为 true 且端口为 51707 时,首页只显示交叉评查入口 - */ -export const CROSS_CHECKING_ONLY_MODE = process.env.CROSS_CHECKING_ONLY_MODE === 'true'; - -/** - * 交叉评查专属模式的目标端口 - */ -export const CROSS_CHECKING_ONLY_PORT = '51707'; - -/** - * 获取当前端口(服务端使用) - */ +/** + * API配置文件 + * 统一管理所有API地址,方便部署时修改 + * 支持环境变量覆盖配置 + */ +// 环境配置类型 +interface ApiConfig { + // 主API基础URL(FastAPI后端地址,包含Dify代理) + baseUrl: string; + // 文档服务URL + documentUrl: string; + // 文档上传API URL + uploadUrl: string; + // Collabora Online 服务器地址 + collaboraUrl: string; + // 应用基础URL(用于 WOPI 回调) + appUrl: string; + // OAuth2.0配置 + oauth: { + // IDaaS服务器地址 + serverUrl?: string; + // OAuth2应用Client ID + clientId?: string; + // OAuth2应用Client Secret + clientSecret?: string; + // 回调地址(内网Web) + redirectUri?: string; + // 钉钉Web回调地址(互联网地址) + dingtalkRedirectUri?: string; + // 应用ID(用于登出) + appId?: string; + }; + // Dify 知识库检索配置 + dify: { + // Reranking 模型提供商 + rerankingProviderName: string; + // Reranking 模型名称 + rerankingModelName: string; + }; +} + +// 端口特定配置映射 +// 根据不同端口提供不同的API配置 +export const portConfigs: Record> = { + + // 主要 + // 梅州 + '51703': { + + // baseUrl: 'http://172.16.0.59:8096', // FastAPI后端(包含/dify代理) + // documentUrl: 'http://172.16.0.59:8096/docauditai/', + // uploadUrl: 'http://172.16.0.59:8096/api/v2/documents', + // baseUrl: 'http://nas.7bm.co:8096', + // documentUrl: 'http://nas.7bm.co:8096/docauditai/', + // uploadUrl: 'http://nas.7bm.co:8096/api/v2/documents', + baseUrl: 'http://nas.7bm.co:8073', + documentUrl: 'http://nas.7bm.co:8073/docauditai/', + uploadUrl: 'http://nas.7bm.co:8073/api/v2/documents', + collaboraUrl: 'http://172.16.0.58:9980', + appUrl: 'http://172.16.0.34:51703', + + + // baseUrl: 'http://172.16.0.56:8073', + // documentUrl: 'http://172.16.0.56:8073/docauditai/', + // uploadUrl: 'http://172.16.0.56:8073/api/v2/documents', + // collaboraUrl: 'http://172.16.0.81:9980', + // appUrl: 'http://172.16.0.34:51703', + + // baseUrl: 'http://10.79.97.17:8000', + // documentUrl: 'http://10.79.97.17:8000/docauditai/', + // uploadUrl: 'http://10.79.97.17:8000/api/v2/documents', + // collaboraUrl: 'http://10.79.97.17:9980', + // appUrl: 'http://10.79.97.17:51703', + + oauth: { + redirectUri: 'http://10.79.97.17:51703/callback', + // 钉钉Web回调地址(互联网地址)- 需要根据实际部署修改 + dingtalkRedirectUri: process.env.DINGTALK_REDIRECT_URI_51703 || 'https://10-79-97-1751703-b2oaixksdrrisox0t3.ztna-dingtalk.com/callback' + } + }, + + + // 云浮 + '51704': { + // baseUrl: 'http://172.16.0.55:8001', + // documentUrl: 'http://172.16.0.55:8001/docauditai/', + // uploadUrl: 'http://172.16.0.55:8001/api/v2/documents', + // collaboraUrl: 'http://172.16.0.81:9980', + // appUrl: 'http://172.16.0.34:51704', + + baseUrl: 'http://172.16.0.59:8096', // FastAPI后端(包含/dify代理) + documentUrl: 'http://172.16.0.59:8096/docauditai/', + uploadUrl: 'http://172.16.0.59:8096/api/v2/documents', + collaboraUrl: 'http://172.16.0.58:9980', + appUrl: 'http://172.16.0.34:51703', + + // baseUrl: 'http://10.79.97.17:8001', + // documentUrl: 'http://10.79.97.17:8001/docauditai/', + // uploadUrl: 'http://10.79.97.17:8001/api/v2/documents', + // collaboraUrl: 'http://10.79.97.17:9980', + // appUrl: 'http://10.79.97.17:51704', + oauth: { + redirectUri: 'http://10.79.97.17:51704/callback', + dingtalkRedirectUri: process.env.DINGTALK_REDIRECT_URI_51704 || 'https://10-79-97-1751704-xxxxxxxxx.ztna-dingtalk.com/callback' + } + }, + + // 揭阳 + '51705': { + baseUrl: 'http://10.79.97.17:8002', + documentUrl: 'http://10.79.97.17:8002/docauditai/', + uploadUrl: 'http://10.79.97.17:8002/api/v2/documents', + collaboraUrl: 'http://10.79.97.17:9980', + appUrl: 'http://10.79.97.17:51705', + oauth: { + redirectUri: 'http://10.79.97.17:51705/callback', + dingtalkRedirectUri: process.env.DINGTALK_REDIRECT_URI_51705 || 'https://10-79-97-1751705-xxxxxxxxx.ztna-dingtalk.com/callback' + } + }, + + // 潮州 + '51706': { + baseUrl: 'http://10.79.97.17:8003', + documentUrl: 'http://10.79.97.17:8003/docauditai/', + uploadUrl: 'http://10.79.97.17:8003/api/v2/documents', + collaboraUrl: 'http://10.79.97.17:9980', + appUrl: 'http://10.79.97.17:51706', + oauth: { + redirectUri: 'http://10.79.97.17:51706/callback', + dingtalkRedirectUri: process.env.DINGTALK_REDIRECT_URI_51706 || 'https://10-79-97-1751706-xxxxxxxxx.ztna-dingtalk.com/callback' + } + }, + + + // 省局 + '51707': { + // baseUrl: 'http://172.16.0.55:8866', + // documentUrl: 'http://172.16.0.55:8866/docauditai/', + // uploadUrl: 'http://172.16.0.55:8866/api/v2/documents', + // collaboraUrl: 'http://172.16.0.81:9980', + // appUrl: 'http://172.16.0.34:51707', + + // 正式环境 + baseUrl: 'http://10.79.97.17:8866', + documentUrl: 'http://10.79.97.17:8866/docauditai/', + uploadUrl: 'http://10.79.97.17:8866/api/v2/documents', + collaboraUrl: 'http://10.79.97.17:9980', + appUrl: 'http://10.79.97.17:51707', + + oauth: { + redirectUri: 'http://10.79.97.17:51707/callback', + dingtalkRedirectUri: process.env.DINGTALK_REDIRECT_URI_51707 || 'https://10-79-97-1751707-xxxxxxxxx.ztna-dingtalk.com/callback' + } + }, + + //test + '51708': { + baseUrl: 'http://10.79.97.17:8005', + documentUrl: 'http://10.79.97.17:8005/docauditai/', + uploadUrl: 'http://10.79.97.17:8005/api/v2/documents', + collaboraUrl: 'http://10.79.97.17:9980', + appUrl: 'http://10.79.97.17:51708' + }, +}; + +// 不同环境的默认配置 +// 由于合同模板的上传,后续的的uploadUrl都不需要/upload,直接写/api/v2/documents,由程序自动添加/upload或/upload_contract_template +const configs: Record = { + // 开发环境 + development: { + // baseUrl: 'http://172.16.0.59:8096', // FastAPI后端(包含/dify代理) + // documentUrl: 'http://172.16.0.59:8096/docauditai/', + // uploadUrl: 'http://172.16.0.59:8096/api/v2/documents', + baseUrl: 'http://nas.7bm.co:8073', // FastAPI后端(包含/dify代理) + documentUrl: 'http://nas.7bm.co:8073/docauditai/', + uploadUrl: 'http://nas.7bm.co:8073/api/v2/documents', + // baseUrl: 'http://172.16.0.84:8073', // FastAPI后端(包含/dify代理) + // documentUrl: 'http://172.16.0.84:8073/docauditai/', + // uploadUrl: 'http://172.16.0.84:8073/api/v2/documents', + + collaboraUrl: 'http://172.16.0.58:9980', + // collaboraUrl: 'http://nas.7bm.co:9980', + appUrl: 'http://172.16.0.34:51703', + + oauth: { + serverUrl: 'http://10.79.112.85', // IDaaS服务器地址 + clientId: 'none', + clientSecret: 'none', // 需要替换为实际的Client Secret + redirectUri: 'http://10.79.97.17/', // 回调地址 + appId: 'idaasoauth2' // 应用ID,用于登出 + }, + dify: { + rerankingProviderName: 'langgenius/tongyi/tongyi', + rerankingModelName: 'gte-rerank' + } + }, + + // 测试环境 + testing: { + baseUrl: 'http://172.16.0.55:8073', // FastAPI后端(包含/dify代理) + documentUrl: 'http://172.16.0.55:8073/docauditai/', + uploadUrl: 'http://172.16.0.55:8073/api/v2/documents', + collaboraUrl: 'http://172.16.0.81:9980', + // appUrl: 'http://10.79.97.17:51703', + appUrl: 'http://172.16.0.34:5183', + oauth: { + serverUrl: 'http://10.79.112.85', // IDaaS服务器地址 + clientId: '54d2a619fe5c81ae1250434c441fccccqMtKwh7H4fO', + clientSecret: 'placeholder', // 需要替换为实际的Client Secret + redirectUri: 'http://10.79.97.17/', // 回调地址 + appId: 'idaasoauth2' // 应用ID,用于登出 + }, + dify: { + rerankingProviderName: 'langgenius/tongyi/tongyi', + rerankingModelName: 'gte-rerank' + } + }, + + // 生产环境 + production: { + baseUrl: 'http://10.79.97.17:8000', // FastAPI后端(包含/dify代理) + // minio + documentUrl: 'http://10.76.244.156:9000/docauditai/', + // 文件上传 + uploadUrl: 'http://10.79.97.17:8000/api/v2/documents', + collaboraUrl: 'http://10.79.97.17:9980', + appUrl: 'http://10.79.97.17:51703', + oauth: { + clientId: '224266374b56ee6254ed3d339014b033kaZy92exUmy', + // serverUrl: 'http://10.79.112.85', // IDaaS服务器地址(测试) + serverUrl: 'http://10.79.97.252', // IDaaS服务器地址(生产) + // ⚠️ 安全警告:clientSecret 不应该硬编码在代码中 + // 请在生产环境使用环境变量 OAUTH_CLIENT_SECRET + clientSecret: 'placeholder', // 占位符,实际值从环境变量获取 + redirectUri: 'http://10.79.97.17/', // 回调地址 + appId: 'idaasoauth2' // 应用ID,用于登出 + }, + dify: { + // rerankingProviderName: 'langgenius/tongyi/tongyi', + rerankingProviderName: 'langgenius/xinference/xinference', + // rerankingModelName: 'gte-rerank' + rerankingModelName: 'bge-reranker-v2-m3' + } + }, + + // 备用配置 (可以根据需要添加更多环境) + staging: { + baseUrl: 'http://172.16.0.119:9000/admin', // FastAPI后端(包含/dify代理) + documentUrl: 'http://nas.7bm.co:9000/docauditai/', + uploadUrl: 'http://172.16.0.119:8000/api/v2/documents', + collaboraUrl: 'http://10.79.97.17:9980', + appUrl: 'http://172.16.0.119:3000', + oauth: { + serverUrl: 'http://10.79.112.85', // IDaaS服务器地址 + clientId: 'none', // 需要替换为实际的Client ID + clientSecret: 'your_client_secret', // 需要替换为实际的Client Secret + redirectUri: 'http://172.16.0.119:3000/callback', // 回调地址 + appId: 'idaasoauth2' // 应用ID,用于登出 + }, + dify: { + rerankingProviderName: 'langgenius/tongyi/tongyi', + rerankingModelName: 'gte-rerank' + } + } +}; + +// 获取当前环境,默认为development +const getCurrentEnvironment = (): string => { + // 在服务器端,优先使用PM2设置的环境变量 + if (typeof window === 'undefined') { + // 服务器端:直接使用process.env.NODE_ENV + const nodeEnv = process.env.NODE_ENV; + // console.log('🔧 服务器端环境检测:', { + // NODE_ENV: nodeEnv, + // result: nodeEnv || 'development' + // }); + return nodeEnv || 'development'; + } + + // 客户端:优先使用NEXT_PUBLIC_前缀的环境变量 + const nextPublicNodeEnv = process.env.NEXT_PUBLIC_NODE_ENV; + const nodeEnv = process.env.NODE_ENV; + const result = nextPublicNodeEnv || nodeEnv || 'development'; + + // console.log('🔧 客户端环境检测:', { + // NEXT_PUBLIC_NODE_ENV: nextPublicNodeEnv, + // NODE_ENV: nodeEnv, + // result: result + // }); + + return result; +}; + +// 从环境变量获取配置,如果环境变量不存在则使用默认配置 +const getConfigFromEnv = (defaultConfig: ApiConfig): ApiConfig => { + return { + baseUrl: process.env.NEXT_PUBLIC_API_BASE_URL || defaultConfig.baseUrl, + documentUrl: process.env.NEXT_PUBLIC_DOCUMENT_URL || defaultConfig.documentUrl, + uploadUrl: process.env.NEXT_PUBLIC_UPLOAD_URL || defaultConfig.uploadUrl, + + collaboraUrl: defaultConfig.collaboraUrl || '', + appUrl: defaultConfig.appUrl || '', + + oauth: { + serverUrl: process.env.NEXT_PUBLIC_OAUTH_SERVER_URL || defaultConfig.oauth.serverUrl, + clientId: process.env.NEXT_PUBLIC_OAUTH_CLIENT_ID || defaultConfig.oauth.clientId, + // ⚠️ 注意:clientSecret 不应该使用 NEXT_PUBLIC_ 前缀 + // 应该只在服务器端通过 process.env.OAUTH_CLIENT_SECRET 访问 + clientSecret: process.env.OAUTH_CLIENT_SECRET || defaultConfig.oauth.clientSecret, + redirectUri: process.env.NEXT_PUBLIC_OAUTH_REDIRECT_URI || defaultConfig.oauth.redirectUri, + appId: process.env.NEXT_PUBLIC_OAUTH_APP_ID || defaultConfig.oauth.appId + }, + dify: defaultConfig.dify + }; +}; + +/** + * 获取当前端口号 + * 优先从浏览器location获取,然后从环境变量获取 + */ +const getCurrentPort = (): string => { + // 在客户端,优先从浏览器location获取端口 + let windowPort = ''; + if (typeof window !== 'undefined') { + windowPort = window.location.port || ''; + } + + // 在服务器端,优先使用运行时端口检测 + if (typeof window === 'undefined') { + const runtimePort = getRuntimePort(); + if (runtimePort) { + // console.log('🔧 服务器端运行时端口检测:', runtimePort); + return runtimePort; + } + } + + // 优先使用环境变量中的端口配置 + const nextPublicApiPortConfig = process.env.NEXT_PUBLIC_API_PORT_CONFIG; + const nextPublicPort = process.env.NEXT_PUBLIC_PORT; + const apiPortConfig = process.env.API_PORT_CONFIG; + const portEnv = process.env.PORT; + + // 优先级:windowPort > NEXT_PUBLIC_API_PORT_CONFIG > NEXT_PUBLIC_PORT > API_PORT_CONFIG > PORT环境变量 + const result = windowPort || nextPublicApiPortConfig || nextPublicPort || apiPortConfig || portEnv || ''; + + // console.log('🔧 端口检测:', { + // windowPort: windowPort, + // NEXT_PUBLIC_API_PORT_CONFIG: nextPublicApiPortConfig, + // NEXT_PUBLIC_PORT: nextPublicPort, + // API_PORT_CONFIG: apiPortConfig, + // PORT: portEnv, + // result: result + // }); + + return result; +}; + +/** + * 运行时端口检测 - 从服务器启动参数或环境变量获取实际端口 + * 这个方法只在服务器端运行,用于动态获取实际运行端口 + */ +const getRuntimePort = (): string => { + if (typeof window !== 'undefined') { + return ''; // 客户端不执行此逻辑 + } + + // 尝试从进程参数中获取端口 + const args = process.argv; + for (let i = 0; i < args.length; i++) { + if (args[i] === '--port' && i + 1 < args.length) { + return args[i + 1]; + } + if (args[i].startsWith('--port=')) { + return args[i].split('=')[1]; + } + } + + // 从环境变量获取 + return process.env.PORT || ''; +}; + +/** + * 获取当前配置 + * 支持根据端口动态切换API配置 + */ +const getCurrentConfig = (): ApiConfig => { + const env = getCurrentEnvironment(); + const port = getCurrentPort(); + + // console.log('🔧 配置调试信息:', { + // environment: env, + // port: port, + // hasPortConfig: !!(port && portConfigs[port]), + // portConfig: port ? portConfigs[port] : null + // }); + + // 获取基础配置 + let defaultConfig = configs[env] || configs.development; + + // 如果有端口特定配置,则合并配置 + if (port && portConfigs[port]) { + console.log(`🔧 使用端口特定配置: ${port}`); + const portConfig = portConfigs[port]; + defaultConfig = { + ...defaultConfig, + ...portConfig, + // 如果端口配置中有oauth,需要深度合并oauth配置 + oauth: portConfig.oauth + ? { ...defaultConfig.oauth, ...portConfig.oauth } + : defaultConfig.oauth + }; + // console.log(`🔧 使用端口特定配置---深度合并后: ${JSON.stringify(defaultConfig.oauth)}`) + } else { + // console.log(`🔧 使用环境配置: ${env}`, defaultConfig); + } + + // 只有在明确设置了环境变量的情况下才覆盖配置 + const hasEnvOverrides = process.env.NEXT_PUBLIC_API_BASE_URL || + process.env.NEXT_PUBLIC_DOCUMENT_URL || + process.env.NEXT_PUBLIC_UPLOAD_URL; + + if (hasEnvOverrides) { + // console.log('🔧 检测到环境变量覆盖,使用环境变量配置'); + return getConfigFromEnv(defaultConfig); + } + + console.log('🔧 最终配置:', defaultConfig); + return defaultConfig; +}; + +// 导出当前环境的配置 +export const apiConfig = getCurrentConfig(); + +// 导出具体的配置项,方便使用 +export const { + baseUrl: API_BASE_URL, + documentUrl: DOCUMENT_URL, + uploadUrl: UPLOAD_URL, + collaboraUrl: COLLABORA_URL, + appUrl: APP_URL, + oauth: OAUTH_CONFIG, + dify: DIFY_CONFIG +} = apiConfig; + +/** + * 🔓 客户端安全的 OAuth 配置(不包含 clientSecret) + * 可以安全地在客户端代码中使用 + */ +export const CLIENT_OAUTH_CONFIG = { + serverUrl: OAUTH_CONFIG.serverUrl as string, + clientId: OAUTH_CONFIG.clientId as string, + redirectUri: OAUTH_CONFIG.redirectUri as string, + appId: OAUTH_CONFIG.appId as string, + // 客户端不需要 clientSecret +}; + +// 导出所有配置,供调试使用 +export { configs }; + +// 工具函数:设置环境(主要用于测试) +export const setEnvironment = (env: string): ApiConfig => { + return configs[env] || configs.development; +}; + +/** + * 工具函数:获取当前端口配置信息(用于调试) + */ +export const getCurrentPortConfig = () => { + const port = getCurrentPort(); + const env = getCurrentEnvironment(); + return { + currentPort: port, + currentEnvironment: env, + hasPortConfig: !!(port && portConfigs[port]), + portConfig: port ? portConfigs[port] : null + }; +}; + +// 调试信息(仅在开发环境显示) +if (process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'testing') { + console.log('📦 API配置信息:', { + environment: getCurrentEnvironment(), + currentEnv: process.env.NODE_ENV, + port: getCurrentPort(), + config: { + ...apiConfig, + oauth: { + ...apiConfig.oauth, + clientSecret: '***' // 隐藏敏感信息 + } + }, + }); +} + +/** + * 交叉评查专属模式配置 + * 当为 true 且端口为 51707 时,首页只显示交叉评查入口 + */ +export const CROSS_CHECKING_ONLY_MODE = process.env.CROSS_CHECKING_ONLY_MODE === 'true'; + +/** + * 交叉评查专属模式的目标端口 + */ +export const CROSS_CHECKING_ONLY_PORT = '51707'; + +/** + * 获取当前端口(服务端使用) + */ export { getCurrentPort }; \ No newline at end of file diff --git a/app/root.tsx b/app/root.tsx index b923c39..11da3c1 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -48,11 +48,12 @@ import { } from "~/config/api-config"; // 导入移动端检测工具 -import { - isMobileDevice, - isMobileAllowedPath, - MOBILE_CHAT_PATH -} from "~/utils/mobile-detect.server"; +import { + isMobileDevice, + isMobileAllowedPath, + MOBILE_CHAT_PATH +} from "~/utils/mobile-detect.server"; +import { normalizeRoutePathForPermission } from "~/utils/route-alias"; // 定义需要高级权限的路径 // export const developerOnlyPaths = [ @@ -138,16 +139,13 @@ function isDynamicIdSegment(segment: string): boolean { * @param allowedPaths 允许访问的路径列表(从菜单配置中提取) * @returns true 表示允许访问,false 表示拒绝访问 */ -function isPathAllowed(pathname: string, allowedPaths: string[]): boolean { - // --- 开发测试 START:reviewsTest 复用 reviews 的权限(测试完后 git checkout app/root.tsx 还原)--- - const testPath = pathname.replace(/^\/reviewsTest/, '/reviews'); - const checkPath = testPath !== pathname ? testPath : pathname; - // --- 开发测试 END --- - - // 1. 精确匹配(原版用 pathname,测试期间用 checkPath) - if (allowedPaths.includes(checkPath)) { - return true; - } +function isPathAllowed(pathname: string, allowedPaths: string[]): boolean { + const checkPath = normalizeRoutePathForPermission(pathname); + + // 1. 精确匹配 + if (allowedPaths.includes(checkPath)) { + return true; + } // 2. 动态路由匹配(只允许看起来像ID的子路径) for (const allowedPath of allowedPaths) { @@ -495,4 +493,4 @@ export function ErrorBoundary() { ); -} \ No newline at end of file +} diff --git a/app/routes/documents.list.tsx b/app/routes/documents.list.tsx index ec6e139..16b15e0 100644 --- a/app/routes/documents.list.tsx +++ b/app/routes/documents.list.tsx @@ -810,7 +810,7 @@ export default function DocumentsIndex() { if (typeof window !== 'undefined') { sessionStorage.setItem(SEARCH_PARAMS_STORAGE_KEY, searchParams.toString()); } - navigate(`/reviews?id=${fileId}&previousRoute=documents`); + navigate(`/reviewsTest?id=${fileId}&previousRoute=documents`); }; // 处理附件追加文件选择 @@ -1163,8 +1163,8 @@ export default function DocumentsIndex() { {/* 查看按钮 - 需要 document:document:view 权限 */} {canView && ( @@ -1422,7 +1422,7 @@ export default function DocumentsIndex() { ) : ( diff --git a/app/routes/files.upload.tsx b/app/routes/files.upload.tsx index a3028a8..a9c3383 100644 --- a/app/routes/files.upload.tsx +++ b/app/routes/files.upload.tsx @@ -2087,8 +2087,8 @@ export default function FilesUpload() { setTimeout(() => { try { if (isMountedRef.current) { - // console.log(`【调试-handleViewFile】执行导航,URL: /reviews?id=${record.id}&previousRoute=filesUpload`); - navigate(`/reviews?id=${record.id}&previousRoute=filesUpload`); + // console.log(`【调试-handleViewFile】执行导航,URL: /reviewsTest?id=${record.id}&previousRoute=filesUpload`); + navigate(`/reviewsTest?id=${record.id}&previousRoute=filesUpload`); } else { console.error('【调试-handleViewFile】组件已卸载,取消延迟导航'); } @@ -3034,4 +3034,4 @@ export function ErrorBoundary({ error }: { error?: Error }) { ); -} \ No newline at end of file +} diff --git a/app/routes/home.tsx b/app/routes/home.tsx index d6d7531..a88387e 100644 --- a/app/routes/home.tsx +++ b/app/routes/home.tsx @@ -390,7 +390,7 @@ export default function Home() {
- +
*/} diff --git a/app/routes/rules.list.tsx b/app/routes/rules.list.tsx index 790e604..ba6bc95 100644 --- a/app/routes/rules.list.tsx +++ b/app/routes/rules.list.tsx @@ -9,7 +9,7 @@ import { Switch } from '~/components/ui/Switch'; import { TableRowSkeleton, LoadingIndicator, NumberSkeleton } from '~/components/ui/SkeletonScreen'; import rulesStyles from "~/styles/pages/rules_index.css?url"; import type { Rule, RuleType, RulePriority } from '~/models/rule'; -import { RULE_TYPE_COLORS, RULE_PRIORITY_LABELS, RULE_PRIORITY_COLORS } from '~/models/rule'; +import { RULE_PRIORITY_LABELS, RULE_PRIORITY_COLORS } from '~/models/rule'; import type { TagColor } from '~/components/ui/Tag'; import { Table } from '~/components/ui/Table'; import { FilterPanel, FilterSelect, SearchFilter } from '~/components/ui/FilterPanel'; @@ -22,12 +22,15 @@ import { deleteRule, getRuleTypes, getRuleGroupsByType, + getAttributeTypes, batchUpdateRuleStatus, batchDeleteRules, updateEvaluationPoint, type RuleType as ApiRuleType, - type RuleGroup + type RuleGroup, + type AttributeTypeOption } from '~/api/evaluation_points/rules'; +import { CONTRACT_TYPES } from '~/constants/contractTypes'; export const links = () => [ { rel: "stylesheet", href: rulesStyles } @@ -79,6 +82,131 @@ interface ActionResponse { message: string; } +type SubtypeOption = { + code: string; + label: string; +}; + +const ADMIN_LICENSE_SUBTYPE_OPTIONS: SubtypeOption[] = [ + { code: '新办', label: '新办' }, + { code: '变更', label: '变更' }, + { code: '延续', label: '延续' }, + { code: '停业', label: '停业' }, + { code: '歇业', label: '歇业' }, + { code: '补办', label: '补办' }, + { code: '恢复营业', label: '恢复营业' }, + { code: '收回', label: '收回' }, + { code: '注销', label: '注销' } +]; + +const CONTRACT_SUBTYPE_OPTIONS: SubtypeOption[] = CONTRACT_TYPES.map(type => ({ + code: type.value === '通用' ? '通用' : type.label, + label: type.label +})); +const SUBTYPE_DISCOVERY_PAGE_SIZE = 100; + +function uniqueOptions(options: SubtypeOption[]): SubtypeOption[] { + const seen = new Set(); + return options.filter(option => { + if (!option.code || seen.has(option.code)) { + return false; + } + seen.add(option.code); + return true; + }); +} + +function matchesOption(option: AttributeTypeOption, target: SubtypeOption): boolean { + return [option.code, option.label].some(value => + value === target.code || + value === target.label || + value.replace(/合同$/, '') === target.code || + target.label.replace(/合同$/, '') === value + ); +} + +function normalizeSubtypeCode(value: string | undefined, options: SubtypeOption[]): string | undefined { + if (!value) { + return undefined; + } + + const matchedOption = options.find(option => + option.code === value || + option.label === value || + option.label.replace(/合同$/, '') === value + ); + + return matchedOption?.code || value; +} + +function matchRuleTypeByName(ruleTypes: ApiRuleType[], ruleTypeName?: string | null): ApiRuleType | undefined { + if (!ruleTypeName) { + return undefined; + } + + return ruleTypes.find(type => + type.name === ruleTypeName || + type.name.includes(ruleTypeName) || + ruleTypeName.includes(type.name) + ); +} + +function resolveCurrentRuleTypeId( + ruleTypes: ApiRuleType[], + ruleTypeId?: string | null, + ruleTypeName?: string | null +): string | undefined { + if (ruleTypeId && ruleTypeId !== 'all') { + return ruleTypeId; + } + + return matchRuleTypeByName(ruleTypes, ruleTypeName)?.id || ruleTypes[0]?.id; +} + +function resolveScopedSubtypeOptions( + params: { + documentTypeIds: number[]; + selectedModuleName: string; + ruleTypeName?: string | null; + apiOptions: AttributeTypeOption[]; + ruleOptions: SubtypeOption[]; + } +): SubtypeOption[] { + const { documentTypeIds, selectedModuleName, ruleTypeName, apiOptions, ruleOptions } = params; + const isContractModule = selectedModuleName.includes('合同') || documentTypeIds.includes(1); + const isCaseFileModule = selectedModuleName.includes('案卷') || selectedModuleName.includes('卷宗') || documentTypeIds.includes(2) || documentTypeIds.includes(3); + + if (isContractModule) { + const apiContractOptions = CONTRACT_SUBTYPE_OPTIONS.flatMap(target => + apiOptions + .filter(option => matchesOption(option, target)) + .map(option => ({ code: option.code, label: option.label })) + ); + + return uniqueOptions(apiContractOptions); + } + + if (isCaseFileModule && ruleTypeName?.includes('行政许可')) { + const apiLicenseOptions = ADMIN_LICENSE_SUBTYPE_OPTIONS.flatMap(target => + apiOptions + .filter(option => matchesOption(option, target)) + .map(option => ({ code: option.code, label: option.label })) + ); + + return uniqueOptions(apiLicenseOptions); + } + + if (isCaseFileModule && ruleTypeName?.includes('行政处罚')) { + const punishmentOptions = apiOptions + .filter(option => option.code === '通用' || option.label === '通用') + .map(option => ({ code: option.code, label: option.label })); + + return uniqueOptions(punishmentOptions.length > 0 ? punishmentOptions : [{ code: '通用', label: '通用' }]); + } + + return uniqueOptions(ruleOptions); +} + function mapApiRuleToModel(apiRule: ApiRule): Rule { // 🔑 清洗评查点编码:移除最后一个 '--' 及其后面的字符 // 例如:'code-mis--mz' --> 'code-mis', 'code-mbs--alsi--gz' --> 'code-mbs--alsi' @@ -220,6 +348,7 @@ export default function RulesIndex() { const [filteredRules, setFilteredRules] = useState(initialRules); const [filteredTotalCount, setFilteredTotalCount] = useState(initialTotalCount); const [ruleTypes, setRuleTypes] = useState(initialRuleTypes); + const [attributeTypes, setAttributeTypes] = useState>([]); // 添加一个状态来跟踪是否执行了删除操作 const [isDeleting, setIsDeleting] = useState(false); @@ -235,9 +364,11 @@ export default function RulesIndex() { // 获取当前的ruleType值 const ruleTypeParam = searchParams.get('ruleType'); + const ruleTypeNameParam = searchParams.get('ruleTypeName'); + const selectedRuleTypeId = resolveCurrentRuleTypeId(ruleTypes, ruleTypeParam, ruleTypeNameParam) || ''; // 判断是否禁用规则组选择 - const isRuleGroupSelectDisabled = loadingGroups || !ruleTypeParam || ruleGroups.length === 0; + const isRuleGroupSelectDisabled = loadingGroups || !selectedRuleTypeId || ruleGroups.length === 0; // 在组件渲染时初始化状态 // useEffect(() => { @@ -267,6 +398,9 @@ export default function RulesIndex() { // 🔑 从 sessionStorage 获取 documentTypeIds const typeIdsStr = typeof window !== 'undefined' ? sessionStorage.getItem('documentTypeIds') : null; const documentTypeIds = typeIdsStr ? JSON.parse(typeIdsStr) : null; + const selectedModuleName = typeof window !== 'undefined' + ? sessionStorage.getItem('selectedModuleName') || '' + : ''; if (!documentTypeIds || documentTypeIds.length === 0) { console.warn('无法加载评查点数据:未找到 documentTypeIds'); @@ -306,31 +440,139 @@ export default function RulesIndex() { 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 finalRuleType = resolveCurrentRuleTypeId(loadedRuleTypes, ruleTypeParam, ruleTypeNameParam); + + let apiAttributeTypes: AttributeTypeOption[] = []; + try { + const attributeTypesResponse = await getAttributeTypes(loaderData.frontendJWT); + if (attributeTypesResponse.data) { + apiAttributeTypes = attributeTypesResponse.data; + } + } catch (error) { + console.error('加载子类型枚举失败:', error); } - const queryParams = { + let effectiveAttributeType = searchParams.get('documentAttributeType') || undefined; + const presetScopedAttributeTypes = resolveScopedSubtypeOptions({ + documentTypeIds, + selectedModuleName, + ruleTypeName: ruleTypeNameParam, + apiOptions: apiAttributeTypes, + ruleOptions: [] + }); + + if (presetScopedAttributeTypes.length > 0) { + setAttributeTypes(presetScopedAttributeTypes); + const originalAttributeType = effectiveAttributeType; + effectiveAttributeType = normalizeSubtypeCode(effectiveAttributeType, presetScopedAttributeTypes); + + if (effectiveAttributeType && originalAttributeType !== effectiveAttributeType) { + const nextParams = new URLSearchParams(searchParams); + nextParams.set('documentAttributeType', effectiveAttributeType); + nextParams.set('page', '1'); + setSearchParams(nextParams); + } else if (effectiveAttributeType && !presetScopedAttributeTypes.some(type => type.code === effectiveAttributeType)) { + effectiveAttributeType = undefined; + const nextParams = new URLSearchParams(searchParams); + nextParams.delete('documentAttributeType'); + nextParams.set('page', '1'); + setSearchParams(nextParams); + } + } else if (finalRuleType) { + const attributeResponse = await getRulesList({ + ruleType: finalRuleType, + page: 1, + pageSize: SUBTYPE_DISCOVERY_PAGE_SIZE, + token: loaderData.frontendJWT + }); + + if (attributeResponse.data) { + const ruleAttributeTypes = Array.from( + new Set( + attributeResponse.data.rules + .map(rule => rule.documentAttributeType) + .filter((value): value is string => Boolean(value)) + ) + ).map(value => ({ code: value, label: value })); + const scopedAttributeTypes = resolveScopedSubtypeOptions({ + documentTypeIds, + selectedModuleName, + ruleTypeName: ruleTypeNameParam, + apiOptions: apiAttributeTypes, + ruleOptions: ruleAttributeTypes + }); + + setAttributeTypes(scopedAttributeTypes); + const originalAttributeType = effectiveAttributeType; + effectiveAttributeType = normalizeSubtypeCode(effectiveAttributeType, scopedAttributeTypes); + + if (effectiveAttributeType && originalAttributeType !== effectiveAttributeType) { + const nextParams = new URLSearchParams(searchParams); + nextParams.set('documentAttributeType', effectiveAttributeType); + nextParams.set('page', '1'); + setSearchParams(nextParams); + } else if (effectiveAttributeType && !scopedAttributeTypes.some(type => type.code === effectiveAttributeType)) { + effectiveAttributeType = undefined; + const nextParams = new URLSearchParams(searchParams); + nextParams.delete('documentAttributeType'); + nextParams.set('page', '1'); + setSearchParams(nextParams); + } + } else { + setAttributeTypes([]); + effectiveAttributeType = undefined; + } + } else { + setAttributeTypes([]); + effectiveAttributeType = undefined; + } + + const baseQueryParams = { 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, token: loaderData.frontendJWT }; + if (effectiveAttributeType) { + const allRules: ApiRule[] = []; + let totalCount = 0; + let pageToFetch = 1; + + do { + const response = await getRulesList({ + ...baseQueryParams, + page: pageToFetch, + pageSize: SUBTYPE_DISCOVERY_PAGE_SIZE + }); + + if (!response.data) { + break; + } + + allRules.push(...(response.data.rules as unknown as ApiRule[])); + totalCount = response.data.totalCount || 0; + pageToFetch += 1; + } while (allRules.length < totalCount); + + const subtypeRules = allRules.filter(rule => rule.documentAttributeType === effectiveAttributeType); + const startIndex = (currentPage - 1) * pageSize; + const pageRules = subtypeRules.slice(startIndex, startIndex + pageSize); + + setFilteredRules(pageRules.map((apiRule: ApiRule) => mapApiRuleToModel(apiRule))); + setFilteredTotalCount(subtypeRules.length); + return; + } + // 调用 API 获取数据 - const response = await getRulesList(queryParams); + const response = await getRulesList({ + ...baseQueryParams, + page: currentPage, + pageSize + }); if (response.data) { const apiRules = response.data.rules || []; @@ -347,12 +589,12 @@ export default function RulesIndex() { setLoading(false); isLoadingRef.current = false; } - }, [ruleTypeParam, searchParams, currentPage, pageSize, loaderData.frontendJWT]); + }, [ruleTypeParam, ruleTypeNameParam, searchParams, currentPage, pageSize, loaderData.frontendJWT, setSearchParams]); // 当评查点类型变化时,加载对应的规则组 useEffect(() => { // 如果选择了"全部"或未选择,则清空规则组 - if (!ruleTypeParam || ruleTypeParam === 'all') { + if (!selectedRuleTypeId) { setRuleGroups([]); return; } @@ -361,7 +603,7 @@ export default function RulesIndex() { const loadRuleGroups = async () => { setLoadingGroups(true); try { - const response = await getRuleGroupsByType(ruleTypeParam, loaderData.frontendJWT); + const response = await getRuleGroupsByType(selectedRuleTypeId, loaderData.frontendJWT); if (response.data) { setRuleGroups(response.data); } else if (response.error) { @@ -377,7 +619,7 @@ export default function RulesIndex() { }; loadRuleGroups(); - }, [ruleTypeParam]); + }, [selectedRuleTypeId, loaderData.frontendJWT]); // 使用useEffect监听fetcher状态变化并显示Toast fetcher.state有以下几种状态: 通过fetcher提交数据后,action返回结果,fetcher.state会发生变化 // idle: 空闲状态 @@ -476,6 +718,7 @@ export default function RulesIndex() { if (value === '' || value === 'all') { setRuleGroups([]); } + newParams.delete('ruleTypeName'); } } else { newParams.delete(name); @@ -485,6 +728,9 @@ export default function RulesIndex() { newParams.delete('groupId'); setRuleGroups([]); } + if (name === 'ruleType') { + newParams.delete('ruleTypeName'); + } } // 切换筛选条件时,重置到第一页 @@ -757,26 +1003,27 @@ export default function RulesIndex() { align: "left" as const, width: "12%" }, - { - title: "评查点类型", - key: "ruleType", - align: "left" as const, - width: "8%", - render: (_: unknown, record: Rule) => { - const typeColor = RULE_TYPE_COLORS[record.ruleType] as TagColor; - return ( - record.ruleType ? - {record.ruleType} - : null - ); - } - }, + // 主类型已拆分到左侧菜单,列表不再重复展示评查点类型列。 + // { + // title: "评查点类型", + // key: "ruleType", + // align: "left" as const, + // width: "8%", + // render: (_: unknown, record: Rule) => { + // const typeColor = RULE_TYPE_COLORS[record.ruleType] as TagColor; + // return ( + // record.ruleType ? + // {record.ruleType} + // : null + // ); + // } + // }, { title: "所属规则组", dataIndex: "groupName" as keyof Rule, key: "groupName", align: "left" as const, - width: "8%" + width: "12%" }, { title: "地区", @@ -787,11 +1034,11 @@ export default function RulesIndex() { render: (value: string) => value || '-' }, { - title: "属性类型", + title: "子类型", dataIndex: "documentAttributeType" as keyof Rule, key: "documentAttributeType", align: "left" as const, - width: "6%", + width: "10%", render: (value: string) => value || '-' }, { @@ -935,33 +1182,31 @@ export default function RulesIndex() { } > - ({ - value: type.id, - label: type.name - })) - ]} - onChange={handleFilterChange} - className="mr-3 w-[15%]" - /> - ({ value: group.id, label: group.name })) ]} onChange={handleFilterChange} - className={`mr-3 w-[20%] ${isRuleGroupSelectDisabled ? 'opacity-50' : ''}`} + className={`mr-3 w-[22%] ${isRuleGroupSelectDisabled ? 'opacity-50' : ''}`} + /> + + ({ + value: type.code, + label: type.label + }))} + onChange={handleFilterChange} + className="mr-3 w-[18%]" /> 返回首页 ); -} \ No newline at end of file +} diff --git a/app/routes/rules.new.tsx b/app/routes/rules.new.tsx index a7db764..778367c 100644 --- a/app/routes/rules.new.tsx +++ b/app/routes/rules.new.tsx @@ -319,7 +319,7 @@ export default function RuleNew() { // API返回错误 toastService.error(`获取评查点数据失败: ${response.error}`); resetFormData(); - navigate('/rules'); + navigate('/rules/list'); return; } @@ -366,7 +366,7 @@ export default function RuleNew() { console.error('JSON处理错误:', jsonError); toastService.error(`数据处理错误: ${jsonError instanceof Error ? jsonError.message : '未知错误'}`); resetFormData(); - navigate('/rules'); + navigate('/rules/list'); } } } catch (error) { @@ -374,7 +374,7 @@ export default function RuleNew() { toastService.error(`获取评查点数据失败: ${error instanceof Error ? error.message : '未知错误'}`); // 获取数据失败时返回上一页 resetFormData(); - navigate('/rules'); + navigate('/rules/list'); } finally { setIsLoading(false); } @@ -909,7 +909,7 @@ export default function RuleNew() { } else { // 无法获取ID的情况 toastService.warning(`评查点${isEditMode ? '更新' : '创建'}成功,但无法获取ID。正在返回列表页面。`); - navigate('/rules'); + navigate('/rules/list'); } } else { toastService.error('系统繁忙'); diff --git a/app/routes/rules.tsx b/app/routes/rules.tsx index 4ff68cc..b67aa6a 100644 --- a/app/routes/rules.tsx +++ b/app/routes/rules.tsx @@ -14,7 +14,7 @@ export const meta: MetaFunction = () => { export const handle = { breadcrumb: "评查点列表", - to: "/rules/list" // 指定面包屑点击后跳转的路径 + to: "/rulesTest/list" // 新版规则维护入口;旧版可从新版页面内返回 }; /** @@ -22,4 +22,4 @@ export const handle = { */ export default function RulesLayout() { return ; -} \ No newline at end of file +} diff --git a/app/routes/rulesTest.detail.tsx b/app/routes/rulesTest.detail.tsx new file mode 100644 index 0000000..970c66d --- /dev/null +++ b/app/routes/rulesTest.detail.tsx @@ -0,0 +1,948 @@ +import { type LoaderFunctionArgs, type MetaFunction } from '@remix-run/node'; +import { Link, useLoaderData } from '@remix-run/react'; +import type React from 'react'; +import { useEffect, useMemo, useState } from 'react'; +import { Button } from '~/components/ui/Button'; +import { Card } from '~/components/ui/Card'; +import { Table } from '~/components/ui/Table'; +import { Tag, type TagColor } from '~/components/ui/Tag'; +import { loadRuleYamlPack, loadRuleYamlPacks, type RuleSummary, type RuleYamlPack } from '~/utils/rules-yaml-mock.server'; +import { buildRuleYamlPreview, collectDependencyOptions, getDefaultExpandedDependencyGroups, type DependencyOption, type EditableRuleConfig, type ValidationIssue } from '~/utils/rules-config-editor'; +import styles from '~/styles/pages/rules_test.css?url'; + +export const links = () => [ + { rel: 'stylesheet', href: styles } +]; + +export const meta: MetaFunction = () => [ + { title: '评查点详情 - 智慧法务' } +]; + +type LoaderData = { + pack: RuleYamlPack; + requestedRuleId: string; +}; + +type EditorState = { kind: 'rule'; mode: 'create' | 'edit'; id?: string } | null; + +type RuleDraft = Pick & { + checkTypes: string[]; + dependencies: string[]; +}; + +function riskColor(risk: string): TagColor { + if (risk === 'high') return 'red'; + if (risk === 'medium') return 'orange'; + if (risk === 'low') return 'green'; + return 'gray'; +} + +function riskLabel(risk: string): string { + if (risk === 'high') return '高'; + if (risk === 'medium') return '中'; + if (risk === 'low') return '低'; + return risk || '-'; +} + +function uniqueOptions(values: Array): string[] { + return Array.from(new Set(values.map(value => value?.trim()).filter(Boolean) as string[])); +} + +function uniqueDependencyOptions(options: DependencyOption[]): DependencyOption[] { + const seen = new Set(); + return options.filter(option => { + if (!option.value || seen.has(option.value)) { + return false; + } + seen.add(option.value); + return true; + }); +} + +function ruleKey(rule: Pick): string { + return rule.ruleId || rule.id; +} + +function ruleTypeLabel(type: string): string { + const labels: Record = { + deterministic: '确定性检查', + ai_rule: '智能语义检查', + rule_group: '规则组合', + llm: '智能语义检查', + manual: '人工复核' + }; + return labels[type] ? `${labels[type]} (${type})` : type || '-'; +} + +function checkTypeLabel(type: string): string { + const labels: Record = { + required: '必填', + ai: '智能判断', + contains: '包含', + match: '匹配', + format: '格式', + compare: '比较', + amount_match: '金额一致', + visual: '视觉要素', + assert: '断言' + }; + return labels[type] ? `${labels[type]} (${type})` : type; +} + +function phaseLabel(phase: string): string { + const labels: Record = { + draft: '草稿', + executed: '已执行' + }; + return labels[phase] ? `${labels[phase]} (${phase})` : phase; +} + +function isStepReferenced(logic: string, stepId: string): boolean { + if (!logic.trim()) return false; + return new RegExp(`(^|[^\\w-])${stepId}([^\\w-]|$)`).test(logic); +} + +function fallbackDependencyOption(value: string, optionMap?: Map): DependencyOption { + if (/^-?\d+(\.\d+)?$/.test(value)) { + return { value, label: value, source: '常量', group: '常量' }; + } + if (value.startsWith('derived.')) { + return { value, label: value.replace(/^derived\./, ''), source: '派生字段', group: '派生字段' }; + } + if (value.startsWith('visual.')) { + return { value, label: value.replace(/^visual\./, ''), source: '视觉要素引用', group: '视觉要素' }; + } + if (value.includes('[*].')) { + return { value, label: value, source: '多实体字段', group: value.split('[*].')[0] }; + } + const prefix = value.split('.')[0]; + const parent = value.includes('.') ? optionMap?.get(prefix) : undefined; + if (parent) { + return { value, label: value, source: `${parent.source} / 子项未显式定义`, group: parent.group }; + } + return { + value, + label: value, + source: '未匹配', + group: '未匹配' + }; +} + +function makeId(prefix: string): string { + return `${prefix}-${Date.now()}`; +} + +function emptyRuleDraft(group = '未分组'): RuleDraft { + return { + id: makeId('rule'), + ruleId: '', + name: '', + group, + risk: 'medium', + score: '1', + type: 'deterministic', + checkTypes: [], + logic: '', + subRules: [], + subRuleIds: [], + prompt: '', + description: '', + dependencies: [] + }; +} + +function issueColor(severity: ValidationIssue['severity']): TagColor { + return severity === 'error' ? 'red' : 'orange'; +} + +function renderYamlLine(line: string, index: number) { + const indent = line.match(/^\s*/)?.[0] || ''; + const content = line.slice(indent.length); + const listMatch = content.match(/^(-\s+)([^:]+:)(.*)$/); + const keyMatch = content.match(/^([^:]+:)(.*)$/); + + if (!content) { + return  ; + } + + if (content.startsWith('#')) { + return ( + + {content} + + ); + } + + if (listMatch) { + return ( + + {indent} + {listMatch[1]} + {listMatch[2]} + + + ); + } + + if (keyMatch) { + return ( + + {indent} + {keyMatch[1]} + + + ); + } + + return ( + + {indent} + {content} + + ); +} + +function YamlValue({ value }: { value: string }) { + const trimmed = value.trim(); + const className = /^'.*'$|^".*"$/.test(trimmed) + ? 'yaml-string' + : /^(true|false|null)$/i.test(trimmed) + ? 'yaml-boolean' + : /^-?\d+(\.\d+)?$/.test(trimmed) + ? 'yaml-number' + : 'yaml-value'; + + return {value}; +} + +function validateRule(rule: RuleSummary | undefined, dependencyOptions: DependencyOption[]): ValidationIssue[] { + if (!rule) { + return [{ + id: 'rule-missing', + severity: 'error', + area: '评查规则', + target: '未找到评查点', + message: '当前链接没有匹配到评查点,请从规则列表重新进入。' + }]; + } + + const issues: ValidationIssue[] = []; + const dependencyValues = new Set(dependencyOptions.map(option => option.value)); + const hasKnownDependency = (dependency: string) => { + if (/^-?\d+(\.\d+)?$/.test(dependency)) return true; + if (dependencyValues.has(dependency)) return true; + const prefix = dependency.split('.')[0]; + return dependency.includes('.') && dependencyValues.has(prefix); + }; + + if (!rule.name.trim()) { + issues.push({ + id: `rule-name-${rule.id}`, + severity: 'error', + area: '评查规则', + target: rule.ruleId || rule.id, + message: '评查点名称不能为空。' + }); + } + + if (!rule.group.trim()) { + issues.push({ + id: `rule-group-${rule.id}`, + severity: 'error', + area: '评查规则', + target: rule.name || rule.ruleId || rule.id, + message: '评查点必须选择规则组。' + }); + } + + if (!rule.score.trim() || rule.score === '-') { + issues.push({ + id: `rule-score-${rule.id}`, + severity: 'error', + area: '评查规则', + target: rule.name || rule.ruleId || rule.id, + message: '评查点必须设置分值。' + }); + } + + if ((rule.type === 'ai_rule' || rule.checkTypes.includes('ai')) && !rule.prompt.trim()) { + issues.push({ + id: `rule-prompt-${rule.id}`, + severity: 'warning', + area: '评查规则', + target: rule.name || rule.ruleId || rule.id, + message: '智能语义检查建议维护提示词。' + }); + } + + if (rule.type === 'rule_group' && !rule.logic.trim()) { + issues.push({ + id: `rule-group-logic-${rule.id}`, + severity: 'error', + area: '评查规则', + target: rule.name || rule.ruleId || rule.id, + message: '规则组合必须维护逻辑运算式。' + }); + } + + rule.dependencies.forEach(dependency => { + if (!hasKnownDependency(dependency)) { + issues.push({ + id: `rule-dependency-${rule.id}-${dependency}`, + severity: 'warning', + area: '评查规则', + target: rule.name || rule.ruleId || rule.id, + message: `依赖字段【${dependency}】未在当前 YAML 的字段配置或视觉要素中找到。` + }); + } + }); + + return issues; +} + +export async function loader({ request }: LoaderFunctionArgs) { + const url = new URL(request.url); + const packId = url.searchParams.get('packId') || url.searchParams.get('id') || ''; + const requestedRuleId = url.searchParams.get('ruleId') || ''; + const packs = await loadRuleYamlPacks(); + const pack = (packId ? await loadRuleYamlPack(packId) : undefined) || packs[0]; + + if (!pack) { + throw new Response('未找到 YAML 配置', { status: 404 }); + } + + return Response.json({ pack, requestedRuleId } satisfies LoaderData); +} + +export default function RulesTestDetail() { + const { pack, requestedRuleId } = useLoaderData() as LoaderData; + const initialRuleKey = requestedRuleId || ruleKey(pack.rules[0] || { id: '', ruleId: '' }); + const [rules, setRules] = useState(pack.rules); + const [selectedRuleKey, setSelectedRuleKey] = useState(initialRuleKey); + const [editor, setEditor] = useState(null); + const [ruleDraft, setRuleDraft] = useState(emptyRuleDraft(pack.rules[0]?.group)); + const [dependencyDialogOpen, setDependencyDialogOpen] = useState(false); + const [dependencySearch, setDependencySearch] = useState(''); + const [dependencySelection, setDependencySelection] = useState([]); + const [expandedDependencyGroups, setExpandedDependencyGroups] = useState([]); + const [showValidation, setShowValidation] = useState(false); + const [showYamlPreview, setShowYamlPreview] = useState(false); + const [draftSaved, setDraftSaved] = useState(false); + + useEffect(() => { + setRules(pack.rules); + setSelectedRuleKey(requestedRuleId || ruleKey(pack.rules[0] || { id: '', ruleId: '' })); + setEditor(null); + setDependencyDialogOpen(false); + setDependencySearch(''); + setDependencySelection([]); + setExpandedDependencyGroups([]); + setShowValidation(false); + setShowYamlPreview(false); + setDraftSaved(false); + }, [pack.id, requestedRuleId]); + + const currentRule = useMemo(() => { + return rules.find(rule => rule.id === selectedRuleKey || rule.ruleId === selectedRuleKey) || rules[0]; + }, [rules, selectedRuleKey]); + + const editableConfig: EditableRuleConfig = useMemo(() => ({ + metadata: pack.metadata, + documentType: pack.documentType, + mainType: pack.mainType, + subtype: pack.subtype, + fields: pack.fields, + subDocuments: pack.subDocuments, + visualElements: pack.visualElements, + rules + }), [pack, rules]); + + const dependencyOptions = useMemo(() => collectDependencyOptions(editableConfig), [editableConfig]); + const dependencyOptionMap = useMemo(() => new Map(dependencyOptions.map(option => [option.value, option])), [dependencyOptions]); + const validationIssues = useMemo(() => validateRule(currentRule, dependencyOptions), [currentRule, dependencyOptions]); + const yamlPreview = useMemo(() => currentRule ? buildRuleYamlPreview(editableConfig, currentRule) : '', [currentRule, editableConfig]); + const ruleGroups = useMemo(() => Array.from(new Set(rules.map(rule => rule.group || '未分组'))), [rules]); + const ruleTypeOptions = useMemo(() => uniqueOptions([ + ...rules.map(rule => rule.type), + 'deterministic', + 'ai_rule', + 'rule_group' + ]), [rules]); + const selectedDependencyOptions = useMemo(() => { + return ruleDraft.dependencies.map(value => dependencyOptionMap.get(value) || fallbackDependencyOption(value, dependencyOptionMap)); + }, [dependencyOptionMap, ruleDraft.dependencies]); + const currentDependencyRows = useMemo(() => { + return (currentRule?.dependencies || []).map(value => dependencyOptionMap.get(value) || fallbackDependencyOption(value, dependencyOptionMap)); + }, [currentRule, dependencyOptionMap]); + const dialogDependencyOptions = useMemo(() => { + const selectedValues = new Set(ruleDraft.dependencies); + return uniqueDependencyOptions([ + ...selectedDependencyOptions, + ...dependencyOptions + ]).sort((left, right) => { + const selectedDelta = Number(selectedValues.has(right.value)) - Number(selectedValues.has(left.value)); + if (selectedDelta !== 0) return selectedDelta; + return left.label.localeCompare(right.label, 'zh-CN'); + }); + }, [dependencyOptions, ruleDraft.dependencies, selectedDependencyOptions]); + const filteredDependencyOptions = useMemo(() => { + const keyword = dependencySearch.trim().toLowerCase(); + return dialogDependencyOptions.filter(option => { + if (!keyword) return true; + return [option.value, option.label, option.source, option.group] + .some(text => text.toLowerCase().includes(keyword)); + }); + }, [dialogDependencyOptions, dependencySearch]); + const dependencyGroups = useMemo(() => { + const groups = new Map(); + filteredDependencyOptions.forEach(option => { + const current = groups.get(option.group) || []; + current.push(option); + groups.set(option.group, current); + }); + return Array.from(groups.entries()); + }, [filteredDependencyOptions]); + const isDependencySearching = Boolean(dependencySearch.trim()); + const defaultExpandedDependencyGroups = useMemo(() => { + return getDefaultExpandedDependencyGroups(dialogDependencyOptions, dependencySelection); + }, [dialogDependencyOptions, dependencySelection]); + const dependencyDialogEmptyText = dependencySearch.trim() ? '没有匹配的字段。' : '当前文档类型暂无可追加字段。'; + const hasErrors = validationIssues.some(issue => issue.severity === 'error'); + const isSmartRuleDraft = ruleDraft.type === 'ai_rule' || ruleDraft.checkTypes.includes('ai'); + const isRuleGroupDraft = ruleDraft.type === 'rule_group'; + const rulesById = useMemo(() => new Map(rules.map(rule => [rule.ruleId || rule.id, rule])), [rules]); + + const openRuleEditor = (rule?: RuleSummary) => { + setRuleDraft(rule ? { ...rule } : emptyRuleDraft(ruleGroups[0])); + setDependencyDialogOpen(false); + setDependencySearch(''); + setDependencySelection(rule?.dependencies || []); + setExpandedDependencyGroups([]); + setEditor({ kind: 'rule', mode: rule ? 'edit' : 'create', id: rule?.id }); + }; + + const openDependencyDialog = () => { + setDependencySelection(ruleDraft.dependencies); + setDependencySearch(''); + setExpandedDependencyGroups(getDefaultExpandedDependencyGroups(dialogDependencyOptions, ruleDraft.dependencies)); + setDependencyDialogOpen(true); + }; + + const updateDependencySearch = (value: string) => { + setDependencySearch(value); + if (!value.trim()) { + setExpandedDependencyGroups(defaultExpandedDependencyGroups); + } + }; + + const toggleDependencyGroup = (group: string) => { + setExpandedDependencyGroups(current => ( + current.includes(group) + ? current.filter(item => item !== group) + : [...current, group] + )); + }; + + const applyDependencySelection = () => { + setRuleDraft({ ...ruleDraft, dependencies: dependencySelection }); + setDependencyDialogOpen(false); + }; + + const saveRule = () => { + if (!editor || editor.kind !== 'rule') return; + const existingRule = editor.id ? rules.find(rule => rule.id === editor.id) : undefined; + const normalizedRule: RuleSummary = { + ...ruleDraft, + id: ruleDraft.id || makeId('rule'), + ruleId: ruleDraft.ruleId || ruleDraft.id, + group: ruleDraft.group || '未分组', + checkTypes: ruleDraft.type === 'ai_rule' ? uniqueOptions([...ruleDraft.checkTypes, 'ai']) : ruleDraft.checkTypes, + appliesIn: existingRule?.appliesIn || [], + scope: existingRule?.scope || [], + stageCount: existingRule?.stageCount || ruleDraft.subRules.length + }; + setRules(current => editor.mode === 'edit' + ? current.map(rule => rule.id === editor.id ? normalizedRule : rule) + : [...current, normalizedRule]); + setSelectedRuleKey(ruleKey(normalizedRule)); + setDraftSaved(true); + setEditor(null); + }; + + const resetDraft = () => { + setRules(pack.rules); + setSelectedRuleKey(requestedRuleId || ruleKey(pack.rules[0] || { id: '', ruleId: '' })); + setDependencyDialogOpen(false); + setDependencySearch(''); + setDependencySelection([]); + setShowValidation(false); + setShowYamlPreview(false); + setDraftSaved(false); + }; + + const dependencyColumns = [ + { + title: '依赖字段', + key: 'label', + width: '38%', + render: (_: unknown, record: DependencyOption) => ( +
+ {record.label} + YAML引用:{record.value} +
+ ) + }, + { + title: '来源', + dataIndex: 'source' as keyof DependencyOption, + key: 'source', + width: '26%' + }, + { + title: '分组', + dataIndex: 'group' as keyof DependencyOption, + key: 'group', + width: '36%' + } + ]; + + const backLink = `/rulesTest/list?documentType=${encodeURIComponent(pack.documentType)}&mainType=${encodeURIComponent(pack.mainType)}&subtype=${encodeURIComponent(pack.subtype)}`; + + return ( +
+
+ +
+
+
{currentRule?.name || '未找到评查点'}
+
+ {pack.documentType} / {pack.mainType} / {pack.subtype} / {currentRule?.ruleId || '-'} +
+
+
+ + 返回列表 + + + + + +
+
+ {draftSaved && ( +
+ + 草稿已保存到当前页面状态。本次验证不提交后端,也不会更新 OSS 文件。 +
+ )} +
+ + {showValidation && ( + +
+ {hasErrors ? '存在必改问题' : '可提交验证'} + 当前评查点发现 {validationIssues.length} 项提示,其中 {validationIssues.filter(issue => issue.severity === 'error').length} 项需要处理。 +
+
+ {validationIssues.length === 0 ? ( +
未发现配置问题。
+ ) : validationIssues.map(issue => ( +
+ {issue.severity === 'error' ? '必改' : '提醒'} + {issue.area} + {issue.target} +

{issue.message}

+
+ ))} +
+
+ )} + + {showYamlPreview && currentRule && ( + +
+              {yamlPreview.split('\n').map(renderYamlLine)}
+            
+
+ )} + + {currentRule ? ( + <> + +
+
+ +
{currentRule.ruleId || '-'}
+
+
+ +
{currentRule.group || '-'}
+
+
+ +
{ruleTypeLabel(currentRule.type)}
+
+
+ +
{currentRule.appliesIn.length > 0 ? currentRule.appliesIn.map(phaseLabel).join('、') : '全部阶段'}
+
+
+ +
{riskLabel(currentRule.risk)}
+
+
+ +
{currentRule.score || '-'}
+
+
+ +
{currentRule.checkTypes.length > 0 ? currentRule.checkTypes.map(checkTypeLabel).join('、') : '-'}
+
+
+ {currentRule.description && ( +
+ +

{currentRule.description}

+
+ )} +
+ + openRuleEditor(currentRule)}>维护依赖} + > + 当前评查点暂未配置依赖字段。} + /> + + + +
+ {currentRule.subRules.length > 0 && ( +
+
+
+ {currentRule.type === 'rule_group' ? `子规则与逻辑(${currentRule.subRules.length}步)` : `规则步骤(${currentRule.subRules.length}步)`} + {currentRule.type === 'rule_group' ? '规则组合只维护子规则编号、内容和逻辑表达式,不在此处维护字段库。' : '展示当前评查点 YAML stages 中的每一个检查步骤。'} +
+
+
+ {currentRule.subRules.map(subRule => ( +
+ {subRule.id} +
+ + {checkTypeLabel(subRule.check)} + {currentRule.logic && ( + + {isStepReferenced(currentRule.logic, subRule.id) ? '参与逻辑' : '未参与逻辑'} + + )} + + {subRule.content} +
+
+ ))} +
+ {currentRule.logic && ( +
+ +
{currentRule.logic}
+
+ )} +
+ )} + + {currentRule.type === 'rule_group' && currentRule.subRules.length === 0 && ( +
+
+
+ 子规则与逻辑 + 规则组合只维护子规则编号、内容和逻辑表达式,不在此处维护字段库。 +
+
+
+ {currentRule.subRuleIds.length > 0 ? currentRule.subRuleIds.map(ruleId => { + const referencedRule = rulesById.get(ruleId); + return ( +
+ {ruleId} +
+ {referencedRule?.name || '引用规则'} + {referencedRule ? `${ruleTypeLabel(referencedRule.type)} / ${referencedRule.group}` : '当前 YAML 未找到对应规则内容'} +
+
+ ); + }) : ( +
当前规则组合还没有子规则内容。
+ )} +
+
+ +
{currentRule.logic || '-'}
+
+
+ )} + + {(currentRule.type === 'ai_rule' || currentRule.checkTypes.includes('ai')) && ( +
+
+
+ 智能语义检查提示词 + 提示词属于当前评查点规则,不属于案卷文书或字段库。 +
+
+
{currentRule.prompt || '当前评查点尚未维护提示词。'}
+
+ )} + + {currentRule.subRules.length === 0 && currentRule.type !== 'rule_group' && currentRule.type !== 'ai_rule' && !currentRule.checkTypes.includes('ai') && ( +
+ +

{currentRule.description || '当前评查点没有额外规则内容。'}

+
+ )} +
+
+ + ) : ( + +
当前链接没有匹配到评查点,请返回列表重新进入。
+
+ )} + + + {editor && ( +
+ +