From c54f84382b9c8a9edfa4bc6ea09a3ccf81f1179b Mon Sep 17 00:00:00 2001 From: wren <“porlong@qq.com”> Date: Wed, 6 May 2026 09:40:37 +0800 Subject: [PATCH] feat: align frontend document and rule management flows --- app/api/auth/user-routes.ts | 141 +- app/api/document-types/document-types.ts | 1 + app/api/evaluation_points/rule-groups.ts | 64 +- app/api/evaluation_points/rules.ts | 385 ++- app/api/files/documents.ts | 32 +- app/api/files/files-upload.ts | 174 +- app/components/layout/Sidebar.tsx | 1003 ++++---- app/components/rules/new/BasicInfo.tsx | 28 +- app/config/minimal-scope.ts | 8 +- app/hooks/usePermission.tsx | 22 +- app/root.tsx | 2 + app/routes/_index.tsx | 38 +- app/routes/contract-template.detail.$id.tsx | 6 +- app/routes/contract-template.list._index.tsx | 8 +- .../contract-template.search._index.tsx | 8 +- .../contract-template.search.results.tsx | 6 +- app/routes/document-types._index.tsx | 4 +- app/routes/document-types.new.tsx | 90 +- app/routes/documents.edit.tsx | 20 + app/routes/documents.list.tsx | 165 +- app/routes/documents.tsx | 29 +- app/routes/files.upload.tsx | 146 +- app/routes/home.tsx | 2 +- app/routes/reviews.tsx | 2 +- app/routes/rule-groups._index.tsx | 2078 +++++++++-------- app/routes/rule-groups.new.tsx | 787 +------ app/routes/rule-groups.tsx | 6 +- app/routes/rules.list.tsx | 34 +- app/routes/rules.new.tsx | 92 +- app/routes/rules.sets.tsx | 25 + app/routes/rules.tsx | 22 +- app/routes/rulesTest.detail.tsx | 229 +- app/routes/rulesTest.list.tsx | 5 +- app/styles/pages/document-types_new.css | 45 + app/styles/pages/rule-groups_index.css | 964 ++++++-- app/styles/pages/rules_test.css | 6 + app/utils/route-alias.shared.js | 88 +- app/utils/rule-yaml-parser.ts | 241 ++ app/utils/rules-config-packs.server.ts | 115 + app/utils/rules-yaml-mock.server.ts | 18 +- .../packs/yc/contract_loan/rules.yaml | 3 +- 41 files changed, 4239 insertions(+), 2903 deletions(-) create mode 100644 app/routes/rules.sets.tsx create mode 100644 app/utils/rule-yaml-parser.ts create mode 100644 app/utils/rules-config-packs.server.ts diff --git a/app/api/auth/user-routes.ts b/app/api/auth/user-routes.ts index 9870428..87a1f7a 100644 --- a/app/api/auth/user-routes.ts +++ b/app/api/auth/user-routes.ts @@ -110,51 +110,44 @@ const FALLBACK_MENU_DATA: Record = { }, { id: 'rule-management', - title: '评查规则库', + title: '规则管理', path: '/rules', icon: 'ri-book-3-line', order: 4, children: [ - { - id: 'rule-groups', - title: '评查点分组', - path: '/rule-groups', - icon: 'ri-folder-open-line', - order: 1 - }, { id: 'rules-list', title: '评查点列表', path: '/rules/list', icon: 'ri-list-check-3', - order: 2 + order: 1 }, { id: 'rules-file', title: '评查文件列表', path: '/rules-files', icon: 'ri-list-check-2', - order: 3 + order: 2 } ] }, { id: 'contract-template', - title: '合同模板', + title: '合同管理', path: '/contract-template', icon: 'ri-file-search-line', order: 5, children: [ { id: 'contract-search-ai', - title: '智能搜索', + title: '模板搜索', path: '/contract-template/search', icon: 'ri-search-line', order: 1 }, { id: 'contract-list', - title: '合同列表', + title: '模板列表', path: '/contract-template/list', icon: 'ri-folder-line', order: 2 @@ -167,31 +160,34 @@ const FALLBACK_MENU_DATA: Record = { path: '/settings', icon: 'ri-settings-4-line', order: 6, - requiredRole: 'developer', children: [ + { + id: 'rule-groups', + title: '规则组导航', + path: '/rule-groups', + icon: 'ri-folder-open-line', + order: 1 + }, { id: 'config-lists', title: '配置列表', path: '/config-lists', icon: 'ri-list-check-3', - order: 1, - requiredRole: 'developer' + order: 2 }, { id: 'document-types', title: '文档类型', path: '/document-types', icon: 'ri-file-list-line', - order: 2, - requiredRole: 'developer' + order: 3 }, { id: 'prompt-management', title: '提示词管理', path: '/prompts', icon: 'ri-chat-1-line', - order: 3, - requiredRole: 'developer' + order: 4 } ] }, @@ -252,51 +248,44 @@ const FALLBACK_MENU_DATA: Record = { }, { id: 'rule-management', - title: '评查规则库', + title: '规则管理', path: '/rules', icon: 'ri-book-3-line', order: 4, children: [ - { - id: 'rule-groups', - title: '评查点分组', - path: '/rule-groups', - icon: 'ri-folder-open-line', - order: 1 - }, { id: 'rules-list', title: '评查点列表', path: '/rules/list', icon: 'ri-list-check-3', - order: 2 + order: 1 }, { id: 'rules-file', title: '评查文件列表', path: '/rules-files', icon: 'ri-list-check-2', - order: 3 + order: 2 } ] }, { id: 'contract-template', - title: '合同模板', + title: '合同管理', path: '/contract-template', icon: 'ri-file-search-line', order: 5, children: [ { id: 'contract-search-ai', - title: '智能搜索', + title: '模板搜索', path: '/contract-template/search', icon: 'ri-search-line', order: 1 }, { id: 'contract-list', - title: '合同列表', + title: '模板列表', path: '/contract-template/list', icon: 'ri-folder-line', order: 2 @@ -367,14 +356,14 @@ const FALLBACK_MENU_DATA: Record = { }, { id: 'rule-management', - title: '评查规则库', + title: '规则管理', path: '/rules', icon: 'ri-book-3-line', order: 4, children: [ { id: 'rule-groups', - title: '评查点分组', + title: '规则组导航', path: '/rule-groups', icon: 'ri-folder-open-line', order: 1 @@ -397,21 +386,21 @@ const FALLBACK_MENU_DATA: Record = { }, { id: 'contract-template', - title: '合同模板', + title: '合同管理', path: '/contract-template', icon: 'ri-file-search-line', order: 5, children: [ { id: 'contract-search-ai', - title: '智能搜索', + title: '模板搜索', path: '/contract-template/search', icon: 'ri-search-line', order: 1 }, { id: 'contract-list', - title: '合同列表', + title: '模板列表', path: '/contract-template/list', icon: 'ri-folder-line', order: 2 @@ -475,14 +464,14 @@ const FALLBACK_MENU_DATA: Record = { }, { id: 'rule-management', - title: '评查规则库', + title: '规则管理', path: '/rules', icon: 'ri-book-3-line', order: 4, children: [ { id: 'rule-groups', - title: '评查点分组', + title: '规则组导航', path: '/rule-groups', icon: 'ri-folder-open-line', order: 1 @@ -505,21 +494,21 @@ const FALLBACK_MENU_DATA: Record = { }, { id: 'contract-template', - title: '合同模板', + title: '合同管理', path: '/contract-template', icon: 'ri-file-search-line', order: 5, children: [ { id: 'contract-search-ai', - title: '智能搜索', + title: '模板搜索', path: '/contract-template/search', icon: 'ri-search-line', order: 1 }, { id: 'contract-list', - title: '合同列表', + title: '模板列表', path: '/contract-template/list', icon: 'ri-folder-line', order: 2 @@ -997,7 +986,7 @@ function convertBackendRoutesToMenuItems(backendRoutes: BackendRouteInfo[], incl // console.log('🔄 [convertBackendRoutesToMenuItems] 转换完成,返回的菜单项数量:', result.length); // console.log('🔄 [convertBackendRoutesToMenuItems] 返回的菜单数据:', JSON.stringify(result, null, 2)); - return result; + return normalizeMenuStructure(result); } @@ -1038,7 +1027,69 @@ function buildFallbackRoutes(roleKey: string): { return { success: true, - data: fallbackMenus.filter(item => isMinimalMenuPath(item.path)), + data: normalizeMenuStructure(fallbackMenus.filter(item => isMinimalMenuPath(item.path))), permissionMap, }; } + +function normalizeMenuStructure(menuItems: MenuItem[]): MenuItem[] { + const clonedMenuItems = menuItems.map(item => ({ + ...item, + children: item.children ? normalizeMenuStructure(item.children) : undefined, + })); + + const collectDescendantPaths = (items: MenuItem[] | undefined): string[] => { + if (!items || items.length === 0) { + return []; + } + + return items.flatMap((item) => [ + item.path, + ...collectDescendantPaths(item.children), + ]); + }; + + const nestedPathSet = new Set( + clonedMenuItems.flatMap(item => collectDescendantPaths(item.children)), + ); + + const dedupedTopLevelItems = clonedMenuItems.filter(item => !nestedPathSet.has(item.path)); + + const ruleManagement = dedupedTopLevelItems.find(item => item.path === '/rules'); + const systemSettings = dedupedTopLevelItems.find(item => item.path === '/settings'); + const syntheticRuleGroupsMenu: MenuItem = { + id: 'rule-groups', + title: '规则组导航', + path: '/rule-groups', + icon: 'ri-folder-open-line', + order: 1, + }; + + let ruleGroupsMenu: MenuItem = syntheticRuleGroupsMenu; + + if (ruleManagement?.children?.length) { + const ruleGroupIndex = ruleManagement.children.findIndex(child => child.path === '/rule-groups'); + if (ruleGroupIndex !== -1) { + const [existingRuleGroupsMenu] = ruleManagement.children.splice(ruleGroupIndex, 1); + ruleGroupsMenu = existingRuleGroupsMenu; + ruleManagement.children = ruleManagement.children + .map((child, index) => ({ ...child, order: index + 1 })) + .sort((a, b) => a.order - b.order); + } + } + + if (!systemSettings) { + return dedupedTopLevelItems; + } + + const settingsChildren = systemSettings.children ? [...systemSettings.children] : []; + if (!settingsChildren.some(child => child.path === '/rule-groups')) { + settingsChildren.unshift({ ...ruleGroupsMenu, order: 1 }); + } + + systemSettings.children = settingsChildren + .map((child, index) => ({ ...child, order: index + 1 })) + .sort((a, b) => a.order - b.order); + + return dedupedTopLevelItems; +} diff --git a/app/api/document-types/document-types.ts b/app/api/document-types/document-types.ts index c1e214f..ad8cc64 100644 --- a/app/api/document-types/document-types.ts +++ b/app/api/document-types/document-types.ts @@ -90,6 +90,7 @@ export async function getDocumentTypes( pageSize: searchParams.pageSize || 50, }; if (searchParams.ids) params.ids = searchParams.ids.join(","); + if (searchParams.entry_module_id) params.entry_module_id = searchParams.entry_module_id; const response = await axios.get(`${API_BASE_URL}/api/document-types`, { params, diff --git a/app/api/evaluation_points/rule-groups.ts b/app/api/evaluation_points/rule-groups.ts index 768b1e8..b065f48 100644 --- a/app/api/evaluation_points/rule-groups.ts +++ b/app/api/evaluation_points/rule-groups.ts @@ -2,6 +2,13 @@ import { postgrestGet, postgrestPut, type PostgrestParams } from '../postgrest-c import { apiRequest } from '../axios-client'; import { formatDate } from '../../utils'; +function buildMissingRuleGroupApiError(): { error: string; status: number } { + return { + error: '评查点分组接口未部署:后端 `GET /api/v3/evaluation-point-groups` 当前返回 404。', + status: 404 + }; +} + /** * 评查点分组接口 */ @@ -252,6 +259,9 @@ export async function getEvaluationPointGroups( if (response.error) { + if (response.status === 404) { + return buildMissingRuleGroupApiError(); + } console.error('❌ getEvaluationPointGroups 错误:', response.error); return { error: response.error, status: response.status }; } @@ -274,8 +284,12 @@ export async function getEvaluationPointGroups( } catch (error) { console.error('❌ 获取一级分组列表出错:', error); return { - error: error instanceof Error ? error.message : '获取一级分组列表失败', - status: 500 + ...(error instanceof Error && error.message.includes('404') + ? buildMissingRuleGroupApiError() + : { + error: error instanceof Error ? error.message : '获取一级分组列表失败', + status: 500 + }) }; } } @@ -305,6 +319,9 @@ export async function getAllEvaluationPointGroups( }); if (response.error) { + if (response.status === 404) { + return buildMissingRuleGroupApiError(); + } return { error: response.error, status: response.status }; } @@ -337,6 +354,49 @@ export async function getAllEvaluationPointGroups( } } +/** + * 按文档类型范围获取可用评查点分组(树形结构) + */ +export async function getEvaluationPointGroupsByDocumentTypes( + documentTypeIds: number[], + token?: string +): Promise<{data: RuleGroup[]; error?: never} | {data?: never; error: string; status?: number}> { + try { + if (!documentTypeIds || documentTypeIds.length === 0) { + return { data: [] }; + } + + const queryParams = new URLSearchParams(); + queryParams.append('document_type_ids', documentTypeIds.join(',')); + queryParams.append('include_disabled', 'false'); + queryParams.append('with_rule_count', 'false'); + + const response = await apiRequest( + `/api/v3/evaluation-point-groups/by-document-types?${queryParams.toString()}`, + { + method: 'GET', + ...(token ? { headers: { 'Authorization': `Bearer ${token}` } } : {}) + } + ); + + if (response.error) { + return { error: response.error, status: response.status }; + } + + if (response.data && Array.isArray(response.data)) { + return { data: response.data.map(convertApiGroupToRuleGroup) }; + } + + return { error: '获取文档类型可用分组失败:返回数据格式不正确', status: 500 }; + } catch (error) { + console.error('❌ 获取文档类型可用分组出错:', error); + return { + error: error instanceof Error ? error.message : '获取文档类型可用分组失败', + status: 500 + }; + } +} + /** * 3. 获取单个分组详情(FastAPI v3) * @param id 分组ID diff --git a/app/api/evaluation_points/rules.ts b/app/api/evaluation_points/rules.ts index b0f4f87..479f522 100644 --- a/app/api/evaluation_points/rules.ts +++ b/app/api/evaluation_points/rules.ts @@ -1,6 +1,11 @@ import { postgrestGet, postgrestPost, postgrestPut, postgrestDelete, type PostgrestParams } from '../postgrest-client'; import { apiRequest } from '../axios-client'; import { formatDate } from '../../utils'; +import { + getEvaluationPointGroup, + getEvaluationPointGroupChildren, + getEvaluationPointGroupsByDocumentTypes +} from './rule-groups'; /** * 从不同格式的 API 响应中提取数据 @@ -181,6 +186,74 @@ function mapApiRuleToFrontendModel(apiRule: ApiRule): Rule { }; } +async function enrichRuleGroupRelation(apiRule: ApiRule, token?: string): Promise { + if (!apiRule.evaluation_point_groups_id || apiRule.child_group || apiRule.parent_group) { + return apiRule; + } + + const groupResponse = await getEvaluationPointGroup(String(apiRule.evaluation_point_groups_id), false, token); + if (groupResponse.error || !groupResponse.data) { + return apiRule; + } + + const childGroup = groupResponse.data; + const parentGroupId = childGroup.pid && childGroup.pid !== '0' ? Number(childGroup.pid) : null; + + let parentGroup: ApiRule['parent_group'] = null; + if (parentGroupId) { + const parentResponse = await getEvaluationPointGroup(String(parentGroupId), false, token); + if (!parentResponse.error && parentResponse.data) { + parentGroup = { + id: Number(parentResponse.data.id), + name: parentResponse.data.name + }; + } + } + + return { + ...apiRule, + evaluation_point_groups_pid: parentGroupId ?? apiRule.evaluation_point_groups_pid ?? null, + child_group: { + id: Number(childGroup.id), + name: childGroup.name + }, + parent_group: parentGroup + }; +} + +async function enrichEvaluationPointGroupRelation( + point: EvaluationPointData, + token?: string +): Promise { + if (!point.evaluation_point_groups_id) { + return point; + } + + const childResponse = await getEvaluationPointGroup(String(point.evaluation_point_groups_id), false, token); + if (childResponse.error || !childResponse.data) { + return point; + } + + const childGroup = childResponse.data; + const parentGroupId = childGroup.pid && childGroup.pid !== '0' ? Number(childGroup.pid) : null; + + let ruleType = point.ruleType || ''; + if (parentGroupId) { + const parentResponse = await getEvaluationPointGroup(String(parentGroupId), false, token); + if (!parentResponse.error && parentResponse.data) { + ruleType = parentResponse.data.name; + } + } + + return { + ...point, + evaluation_point_groups_pid: parentGroupId ?? point.evaluation_point_groups_pid ?? null, + groupId: String(childGroup.id), + groupName: childGroup.name, + ruleType + }; +} + /** * 获取评查点列表 * @param params 查询参数 @@ -279,6 +352,9 @@ export async function getRulesList(params: RulesQueryParams): Promise<{data: Rul ); if (response.error) { + if (response.status === 404) { + return await getRulesListFromPostgrest(params); + } return { error: response.error, status: response.status }; } @@ -334,6 +410,9 @@ export async function getRulesList(params: RulesQueryParams): Promise<{data: Rul }; } catch (error) { console.error('❌ 获取评查点列表出错:', error); + if (error instanceof Error && error.message.includes('404')) { + return await getRulesListFromPostgrest(params); + } return { error: error instanceof Error ? error.message : '获取评查点列表失败', status: 500 @@ -341,6 +420,66 @@ export async function getRulesList(params: RulesQueryParams): Promise<{data: Rul } } +async function getRulesListFromPostgrest( + params: RulesQueryParams +): Promise<{data: RulesListResponse; error?: never} | {data?: never; error: string; status?: number}> { + const { + page = 1, + pageSize = 10, + ruleType, + groupId, + risk, + isActive, + keyword, + area, + documentAttributeType, + token + } = params; + + const postgrestParams: PostgrestParams = { + select: 'id,code,name,area,evaluation_point_groups_id,evaluation_point_groups_pid,risk,description,is_enabled,document_attribute_type,created_at,updated_at,child_group:evaluation_point_groups!fk_evaluation_points_group(id,name),parent_group:evaluation_point_groups!fk_evaluation_points_parent_group(id,name)', + order: 'updated_at.desc', + limit: pageSize, + offset: (page - 1) * pageSize, + token, + filter: {} + }; + + if (ruleType) postgrestParams.filter!.evaluation_point_groups_pid = `eq.${ruleType}`; + if (groupId) postgrestParams.filter!.evaluation_point_groups_id = `eq.${groupId}`; + if (risk) postgrestParams.filter!.risk = `eq.${risk}`; + if (isActive !== undefined) postgrestParams.filter!.is_enabled = `eq.${isActive}`; + if (area) postgrestParams.filter!.area = `eq.${area}`; + if (documentAttributeType) postgrestParams.filter!.document_attribute_type = `eq.${documentAttributeType}`; + if (keyword?.trim()) { + const safeKeyword = keyword.trim().replace(/,/g, ' '); + postgrestParams.or = `name.ilike.*${safeKeyword}*,code.ilike.*${safeKeyword}*`; + } + + const response = await postgrestGet<{ code: number; msg: string; data: ApiRule[] } | ApiRule[]>( + '/api/postgrest/proxy/evaluation_points', + postgrestParams + ); + + if (response.error) { + return { error: response.error, status: response.status }; + } + + let rows: ApiRule[] = []; + if (Array.isArray(response.data)) { + rows = response.data; + } else if (response.data && 'data' in response.data && Array.isArray(response.data.data)) { + rows = response.data.data; + } + + return { + data: { + rules: rows.map(mapApiRuleToFrontendModel), + totalCount: rows.length + } + }; +} + /** * 获取单个评查点详情 * @param id 评查点ID @@ -403,32 +542,11 @@ export async function getRule(id: string, token?: string): Promise<{data: Rule; return { error: '评查点不存在', status: 404 }; } - // 获取分组信息 try { - if (apiRule.evaluation_point_groups_id) { - const groupParams: PostgrestParams = { - select: 'id,name', - filter: { - 'id': `eq.${apiRule.evaluation_point_groups_id}` - }, - token - }; - - // 查询评查点分组 - const groupResponse = await postgrestGet<{code: number; msg: string; data: {id: number; name: string}[]}>('/api/postgrest/proxy/evaluation_point_groups', groupParams); - - if (groupResponse.data?.data && groupResponse.data.data.length > 0) { - // 将分组信息添加到评查点数据中 - const group = groupResponse.data.data[0]; - apiRule.evaluation_point_groups = { - first_name: group.name, - second_name: group.name - }; - } - } + apiRule = await enrichRuleGroupRelation(apiRule, token); } catch (error) { console.error('获取分组信息失败:', error); - // 忽略错误,使用默认分组名 + // 忽略错误,保持评查点详情主链路可用 } // 将API返回的数据映射到前端模型 @@ -522,140 +640,24 @@ export interface RuleGroup { */ export async function getRuleTypes(documentTypeIds?: number[], token?: string): Promise<{data: RuleType[]; error?: never} | {data?: never; error: string; status?: number}> { try { - // 🔑 如果没有传入 documentTypeIds,返回空数组 if (!documentTypeIds || documentTypeIds.length === 0) { - console.warn('getRuleTypes: 未提供 documentTypeIds 参数'); return { data: [] }; } - - const documentTypesParams: PostgrestParams = { - select: 'id, name, evaluation_point_groups_ids', - filter: { - // 🔑 只查询指定的文档类型 ID - 'id': `in.(${documentTypeIds.join(',')})` - }, - token - }; - - const documentTypesResponse = await postgrestGet<{ - code: number; - msg: string; - data: Array<{ - id: number; - name: string; - evaluation_point_groups_ids: number[]; - }> - }>('/api/postgrest/proxy/document_types', documentTypesParams); - - if (documentTypesResponse.error) { - return { error: documentTypesResponse.error, status: documentTypesResponse.status }; - } - - // 提取 document_types 数据 - let documentTypesData: Array<{ - id: number; - name: string; - evaluation_point_groups_ids: number[]; - }> = []; - - if (documentTypesResponse.data && 'code' in documentTypesResponse.data && documentTypesResponse.data.data) { - if (Array.isArray(documentTypesResponse.data.data)) { - documentTypesData = documentTypesResponse.data.data; - } - } else if (Array.isArray(documentTypesResponse.data)) { - documentTypesData = documentTypesResponse.data as Array<{ - id: number; - name: string; - evaluation_point_groups_ids: number[]; - }>; - } - - if (documentTypesData.length === 0) { - console.warn('getRuleTypes: 未找到对应的文档类型数据'); - return { data: [] }; - } - - // 2️⃣ 提取并组合所有的 evaluation_point_groups_ids - const allGroupIds = new Set(); - documentTypesData.forEach(docType => { - if (Array.isArray(docType.evaluation_point_groups_ids)) { - docType.evaluation_point_groups_ids.forEach(id => allGroupIds.add(id)); - } - }); - - if (allGroupIds.size === 0) { - console.warn('getRuleTypes: 未找到评查点组 ID'); - return { data: [] }; - } - - // console.log('📋 [getRuleTypes] 提取的评查点组 IDs:', Array.from(allGroupIds)); - - // 3️⃣ 根据组合后的 ID 查询 evaluation_point_groups 表 - const groupIdsStr = Array.from(allGroupIds).join(','); - const groupsParams: PostgrestParams = { - select: ` - id, - pid, - code, - name, - description, - is_enabled - `, - filter: { - 'id': `in.(${groupIdsStr})`, - 'pid': 'eq.0' - }, - token - }; - - const response = await postgrestGet<{ - code: number; - msg: string; - data: Array<{ - id: number; - pid: number; - code: string; - name: string; - description: string; - is_enabled: boolean; - }> - }>('/api/postgrest/proxy/evaluation_point_groups', groupsParams); - - // 检查是否有错误响应 + const response = await getEvaluationPointGroupsByDocumentTypes(documentTypeIds, token); if (response.error) { return { error: response.error, status: response.status }; } - // 处理响应数据 - if (response.data && 'code' in response.data && response.data.data) { - if (Array.isArray(response.data.data)) { - const ruleTypes = response.data.data.map(item => ({ - id: item.id.toString(), - pid: item.pid.toString(), - code: item.code, - name: item.name, - description: item.description, - isEnabled: item.is_enabled - })); - // console.log('📋 [getRuleTypes] 返回评查点类型:', ruleTypes); - return { data: ruleTypes }; - } else { - return { data: [] }; - } - } else if (Array.isArray(response.data)) { - const ruleTypes = response.data.map(item => ({ - id: item.id.toString(), - pid: item.pid.toString(), - code: item.code, + return { + data: (response.data || []).map(item => ({ + id: item.id, + pid: item.pid, + code: item.code || '', name: item.name, - description: item.description, + description: item.description || '', isEnabled: item.is_enabled - })); - // console.log('📋 [getRuleTypes] 返回评查点类型:', ruleTypes); - return { data: ruleTypes }; - } else { - return { data: [] }; - } + })) + }; } catch (error) { console.error('获取评查点类型出错:', error); return { @@ -673,73 +675,30 @@ export async function getRuleTypes(documentTypeIds?: number[], token?: string): */ export async function getRuleGroupsByType(typeId: string, token?: string): Promise<{data: RuleGroup[]; error?: never} | {data?: never; error: string; status?: number}> { try { - // 如果typeId为空或为"全部",则返回空数组 if (!typeId || typeId === 'all') { return { data: [] }; } - - // 构建PostgrestParams参数 - const postgrestParams: PostgrestParams = { - select: ` - id, - pid, - code, - name, - description, - is_enabled - `, - // 查询指定类型ID的规则组 - filter: { - 'pid': `eq.${typeId}` - }, - token - }; - - // 发送请求获取规则组列表 - const response = await postgrestGet<{code: number; msg: string; data: Array<{ - id: number; - pid: number; - code: string; - name: string; - description: string; - is_enabled: boolean; - }>; - }>('/api/postgrest/proxy/evaluation_point_groups', postgrestParams); - - // 检查是否有错误响应 + + const response = await getEvaluationPointGroupChildren(typeId, { pageSize: 500 }, token); if (response.error) { + if (response.status === 404) { + return { + data: [ + { code: '通用', label: '通用' } + ] + }; + } return { error: response.error, status: response.status }; } - - // 确保响应数据存在且符合预期格式 - if(response.data && 'code' in response.data && response.data.data){ - if(Array.isArray(response.data.data) && response.data.data.length > 0){ - // 将API返回的数据映射到前端模型 - // console.log("评查点类型列表",response.data); - const ruleGroups = response.data.data.map(item => ({ - id: item.id.toString(), - name: item.name, - description: item.description, - isEnabled: item.is_enabled, - code: item.code - })); - return { data: ruleGroups }; - }else{ - return { error: '9000接口返回数据格式不正确', status: 500 }; - } - }else if(Array.isArray(response.data) && response.data.length > 0){ - // console.log("评查点类型列表",response.data); - const ruleGroups = response.data.map(item => ({ - id: item.id.toString(), - name: item.name, - description: item.description, - isEnabled: item.is_enabled, - code: item.code - })); - return { data: ruleGroups }; - }else{ - return { error: '3000接口返回数据格式不正确', status: 500 }; - } + + const ruleGroups = (response.data || []).map(item => ({ + id: item.id, + name: item.name, + description: item.description, + isEnabled: item.is_enabled, + code: item.code + })); + return { data: ruleGroups }; } catch (error) { console.error('获取规则组出错:', error); return { @@ -853,7 +812,7 @@ export function convertApiRuleToFormData(apiRule: ApiRule): FormattedEvaluationP is_enabled: apiRule.is_enabled, description: apiRule.description, references_laws: apiRule.references_laws || null, - evaluation_point_groups_pid: apiRule.evaluation_point_groups?.first_name ? null : null, + evaluation_point_groups_pid: apiRule.parent_group?.id || apiRule.evaluation_point_groups_pid || null, evaluation_point_groups_id: apiRule.evaluation_point_groups_id, extraction_config: extractFields(), evaluation_config: { @@ -1219,8 +1178,10 @@ export async function getEvaluationPoint( return { error: '评查点不存在', status: 404 }; } - console.log('✅ getEvaluationPoint 成功:', response.data); - return { data: response.data }; + const enrichedData = await enrichEvaluationPointGroupRelation(response.data, token); + + console.log('✅ getEvaluationPoint 成功:', enrichedData); + return { data: enrichedData }; } catch (error) { console.error('❌ 获取评查点出错:', error); return { diff --git a/app/api/files/documents.ts b/app/api/files/documents.ts index d66904d..690915e 100644 --- a/app/api/files/documents.ts +++ b/app/api/files/documents.ts @@ -65,6 +65,8 @@ export interface DocumentUI { documentNumber: string; type: string; typeName: string; + groupId?: number | null; + groupName?: string | null; size: number; auditStatus: number; // -1: 不通过, 0: 待审核, 1: 通过, 2: 警告, 3: 审核中 fileStatus: string; // Waiting, Cutting, Extractioning, Failed, Evaluationing, Processed @@ -106,6 +108,8 @@ export interface DocumentVersionUI { documentNumber: string; type: string; typeName: string; + groupId?: number | null; + groupName?: string | null; size: number; auditStatus: number; fileStatus: string; @@ -152,6 +156,9 @@ interface LeauditListItem { previousVersionId?: number | null; typeId?: number | null; typeCode?: string | null; + typeName?: string | null; + groupId?: number | null; + groupName?: string | null; region: string; normalizedName?: string | null; fileId?: number | null; @@ -168,6 +175,9 @@ interface LeauditListItem { passedCount?: number | null; failedCount?: number | null; skippedCount?: number | null; + documentNumber?: string | null; + auditStatus?: number | null; + isTestDocument?: boolean | null; updatedAt?: string | null; hasHistory?: boolean; totalVersions?: number; @@ -279,7 +289,9 @@ function mapHistoryVersionToUI(history: LeauditHistoryVersion, source: LeauditLi name: history.fileName || source.fileName || source.normalizedName || '未命名文档', documentNumber: buildDocumentNumber(history), type: source.typeId?.toString() || '', - typeName: typeNameFromCode(source.typeCode), + typeName: source.typeName || typeNameFromCode(source.typeCode), + groupId: source.groupId ?? null, + groupName: source.groupName ?? null, size: 0, auditStatus: mapLeauditDocToAuditStatus({ processingStatus: history.processingStatus, @@ -316,7 +328,9 @@ function mapLeauditDocumentToUI(doc: LeauditListItem | LeauditDocumentDetail): D name: doc.fileName || doc.normalizedName || '未命名文档', documentNumber: ('documentNumber' in doc && doc.documentNumber) ? doc.documentNumber : buildDocumentNumber(doc), type: doc.typeId?.toString() || '', - typeName: typeNameFromCode(doc.typeCode), + typeName: doc.typeName || typeNameFromCode(doc.typeCode), + groupId: doc.groupId ?? null, + groupName: doc.groupName ?? null, size: doc.fileSize || 0, auditStatus: ('auditStatus' in doc && doc.auditStatus !== null && doc.auditStatus !== undefined) ? doc.auditStatus @@ -778,6 +792,7 @@ export async function getDocumentsListFromAPI(searchParams: { name?: string; documentNumber?: string; documentTypeIds?: number[]; // 文档类型ID数组 + entryModuleId?: number; auditStatus?: string; fileStatus?: string; dateFrom?: string; @@ -795,6 +810,7 @@ export async function getDocumentsListFromAPI(searchParams: { name, documentNumber, documentTypeIds, + entryModuleId, auditStatus, fileStatus, dateFrom, @@ -824,11 +840,13 @@ export async function getDocumentsListFromAPI(searchParams: { params.type_ids = documentTypeIds.join(','); } - // 下面几个旧筛选项暂未完全对齐: - // - documentNumber - // - auditStatus - void documentNumber; - void auditStatus; + if (entryModuleId && entryModuleId > 0) { + params.entry_module_id = entryModuleId; + } + if (documentNumber) params.documentNumber = documentNumber; + if (auditStatus !== undefined && auditStatus !== "") { + params.auditStatus = Number(auditStatus); + } if (dateFrom) params.dateFrom = dateFrom; if (dateTo) params.dateTo = dateTo; diff --git a/app/api/files/files-upload.ts b/app/api/files/files-upload.ts index aa90d76..e9e6757 100644 --- a/app/api/files/files-upload.ts +++ b/app/api/files/files-upload.ts @@ -151,6 +151,21 @@ export interface DocumentType { ruleSetIds?: number[]; } +export interface DocumentSubtypeGroup { + id: number; + name: string; + code: string; + documentTypeId: number; + documentTypeName?: string | null; + rootGroupId?: number | null; + rootGroupName?: string | null; + entryModuleName?: string | null; + entryModuleId?: number | null; + isDefault?: boolean; + displayName?: string; + displayHint?: string; +} + export interface UploadErrorDetails { title: string; summary: string; @@ -211,6 +226,7 @@ export interface UploadResult { fileName: string; fileSize: number; typeId: number; + groupId?: number | null; region: string; processingStatus: string; duplicateUpload: boolean; @@ -236,6 +252,7 @@ interface NewUploadResponse { fileId: number; typeId: number; typeCode: string; + groupId?: number | null; region: string; fileName: string; ossUrl: string; @@ -373,7 +390,7 @@ export function buildUploadErrorDetails( detailLines, actionLines: [ '到“系统设置 / 文档类型管理”检查该文档类型是否绑定了正确的规则集。', - '到“规则管理 / 规则集管理”确认对应规则集的可用规则数是否正常。', + '到“规则管理”确认对应规则集的可用规则数是否正常。', '如果首页入口也异常,请同时到“系统设置 / 入口模块管理”检查入口模块绑定。', ], rawMessage: message, @@ -395,7 +412,7 @@ export function buildUploadErrorDetails( summary: '当前上传入口关联的规则集不可用,文件无法开始审核。', detailLines, actionLines: [ - '到“规则管理 / 规则集管理”检查对应规则集是否存在、可用规则数是否正常。', + '到“规则管理”检查对应规则集是否存在、可用规则数是否正常。', '如文档类型绑定了错误的规则集,请到“系统设置 / 文档类型管理”修正绑定关系。', ], rawMessage: message, @@ -595,15 +612,12 @@ export async function appendContractAttachments( formData.append('files', file); }); - // 添加其他参数 - formData.append('merge_mode', mergeMode); - formData.append('is_reprocess', isReprocess.toString()); - if (remark) { - formData.append('remark', remark); - } - + // 新链路仅保留附件追加;mergeMode / remark 在后端暂不消费,但继续保留函数签名兼容旧页面调用。 + void mergeMode; + void remark; + // 构建请求URL - const uploadUrl = `${UPLOAD_URL}/contracts/${documentId}/append_attachments`; + const uploadUrl = `${API_BASE_URL}/api/documents/${documentId}/attachments`; console.log('【合同附件追加】准备发送请求到服务器:', uploadUrl); // 设置请求头 @@ -627,11 +641,28 @@ export async function appendContractAttachments( const result = response.data; console.log('【合同附件追加】服务器返回结果:', result); - if (result.success) { - return { data: result.result }; - } else { - return { error: result.error || '附件追加失败' }; + if (result?.data) { + if (isReprocess) { + await axios.post( + `${API_BASE_URL}/api/audit/run`, + { + documentId, + force: true, + speed: 'normal', + }, + { headers } + ); + } + + return { + data: { + success: true, + result: result.data, + error: null, + } + }; } + return { error: result?.message || result?.msg || '附件追加失败' }; } catch (error) { console.error('【合同附件追加】上传过程中发生错误:', error); @@ -646,8 +677,10 @@ export async function uploadDocumentToServer( fileName: string, fileType: string, typeId: number, + groupId?: number | null, region: string = "default", createdBy?: number, + attachments?: File[], autoRun: boolean = true, speed: string = "normal", jwtToken?: string, @@ -657,6 +690,12 @@ export async function uploadDocumentToServer( const blob = new Blob([binaryData], { type: fileType }); formData.append("file", blob, fileName); formData.append("typeId", String(typeId)); + if (groupId) { + formData.append("groupId", String(groupId)); + } + (attachments || []).forEach((attachment) => { + formData.append("attachments", attachment); + }); formData.append("region", region); formData.append("fileRole", "primary"); if (createdBy !== undefined) { @@ -689,6 +728,7 @@ export async function uploadDocumentToServer( fileName: uploadData.fileName, fileSize: binaryData.byteLength, typeId: uploadData.typeId, + groupId: uploadData.groupId, region: uploadData.region, processingStatus: uploadData.processingStatus, duplicateUpload: uploadData.duplicateUpload, @@ -819,6 +859,112 @@ export async function getDocumentTypes(token?: string): Promise<{data: DocumentT } } +function mapSubtypeChild(child: any, root?: any): DocumentSubtypeGroup { + const rawName = typeof child.name === "string" ? child.name.trim() : ""; + const rawCode = typeof child.code === "string" ? child.code.trim() : ""; + const isDefault = rawName === "通用" || rawCode.endsWith(".default"); + const entryModuleId = child.entry_module_id ?? root?.entry_module_id ?? null; + const entryModuleName = child.entry_module_name ?? root?.entry_module_name ?? null; + const rootGroupName = root?.name ?? null; + + return { + id: child.id, + name: child.name, + code: child.code, + documentTypeId: child.document_type_id, + documentTypeName: child.document_type_name, + rootGroupId: root?.id ?? null, + rootGroupName, + entryModuleId: typeof entryModuleId === "number" ? entryModuleId : null, + entryModuleName, + isDefault, + displayName: isDefault ? `默认子类型(${rawName || "通用"})` : rawName || child.name, + displayHint: [rootGroupName, child.document_type_name, entryModuleName, rawCode].filter(Boolean).join(" · "), + }; +} + +function dedupeSubtypeGroups(groups: DocumentSubtypeGroup[]): DocumentSubtypeGroup[] { + const groupMap = new Map(); + groups.forEach((group) => { + const existing = groupMap.get(group.id); + if (!existing) { + groupMap.set(group.id, group); + return; + } + const currentScore = (group.rootGroupName ? 2 : 0) + (group.entryModuleId ? 1 : 0); + const existingScore = (existing.rootGroupName ? 2 : 0) + (existing.entryModuleId ? 1 : 0); + if (currentScore > existingScore) { + groupMap.set(group.id, group); + } + }); + return Array.from(groupMap.values()); +} + +export async function getDocumentSubtypeGroups( + documentTypeId: number, + token?: string, + entryModuleId?: number | null, +): Promise<{ data: DocumentSubtypeGroup[]; error?: never } | { data?: never; error: string; status?: number }> { + try { + const headers: Record = {}; + if (token) { + headers["Authorization"] = `Bearer ${token}`; + } + + const allResponse = await axios.get(`${API_BASE_URL}/api/v3/evaluation-point-groups/all`, { + params: { + include_disabled: false, + with_rule_count: false, + }, + headers, + }); + const allRoots = extractApiData(allResponse.data) || []; + const matchedFromTree = allRoots.flatMap((root: any) => { + if (!Array.isArray(root?.children)) return []; + if (entryModuleId && Number(root?.entry_module_id || 0) !== Number(entryModuleId)) { + return []; + } + return root.children + .filter((child: any) => Number(child?.document_type_id || 0) === Number(documentTypeId)) + .map((child: any) => mapSubtypeChild(child, root)); + }); + + if (matchedFromTree.length > 0) { + return { data: dedupeSubtypeGroups(matchedFromTree) }; + } + + const response = await axios.get(`${API_BASE_URL}/api/v3/evaluation-point-groups/by-document-types`, { + params: { + document_type_ids: String(documentTypeId), + include_disabled: false, + with_rule_count: false, + }, + headers, + }); + const roots = extractApiData(response.data) || []; + const filteredRoots = entryModuleId + ? roots.filter((root: any) => Number(root?.entry_module_id || 0) === Number(entryModuleId)) + : roots; + const fallbackRoots = filteredRoots.length > 0 ? filteredRoots : roots; + const groups = dedupeSubtypeGroups( + fallbackRoots.flatMap((root: any) => + Array.isArray(root?.children) + ? root.children + .filter((child: any) => Number(child?.document_type_id || 0) === Number(documentTypeId)) + .map((child: any) => mapSubtypeChild(child, root)) + : [], + ), + ); + return { data: groups }; + } catch (error) { + console.error("获取子类型分组失败:", error); + return { + error: error instanceof Error ? error.message : "获取子类型分组失败", + status: 500, + }; + } +} + /** * 获取指定文档的状态 * @param documentIds 文档ID列表 diff --git a/app/components/layout/Sidebar.tsx b/app/components/layout/Sidebar.tsx index f39db09..0aa5a86 100644 --- a/app/components/layout/Sidebar.tsx +++ b/app/components/layout/Sidebar.tsx @@ -1,174 +1,222 @@ -import { useState, useEffect } from 'react'; -import { Link, useLocation, useNavigate } from '@remix-run/react'; -import type { UserRole } from '~/root'; -import { getUserRoutesByRole, mapUserRoleToRoleKey, type MenuItem } from '~/api/auth/user-routes'; -import { DOCUMENT_URL, CROSS_CHECKING_ONLY_PORT, CROSS_CHECKING_ONLY_MODE } from '~/config/api-config'; - -interface SidebarProps { - onToggle: () => void; - collapsed: boolean; - userRole: UserRole; - frontendJWT?: string; -} - -export function Sidebar({ onToggle, collapsed, userRole, frontendJWT = '' }: SidebarProps) { - const location = useLocation(); - const [expandedMenus, setExpandedMenus] = useState>({}); - const [menuItems, setMenuItems] = useState([]); // 动态菜单项 - const [isLoadingRoutes, setIsLoadingRoutes] = useState(true); // 路由加载状态 - const [isMobile, setIsMobile] = useState(false); // 移动端检测 - const [selectedModuleName, setSelectedModuleName] = useState(''); // 当前选中的模块名称 - const [selectedModulePicPath, setSelectedModulePicPath] = useState(''); // 当前选中的模块图片路径 - const navigate = useNavigate(); - - // 移动端检测 - useEffect(() => { - const checkMobile = () => { - const mobile = window.innerWidth <= 768; // 768px以下视为移动端 - setIsMobile(mobile); - }; - - // 初始检测 - checkMobile(); - - // 监听窗口大小变化 - window.addEventListener('resize', checkMobile); - return () => window.removeEventListener('resize', checkMobile); - }, []); - - // 获取用户路由权限 - useEffect(() => { - // console.log('🔍 [Sidebar] useEffect 触发,开始获取路由权限'); - - const fetchUserRoutes = async () => { - setIsLoadingRoutes(true); - try { - // 优先使用传入的 frontendJWT,否则从 localStorage 读取 - let jwt = frontendJWT; - - if (!jwt && typeof window !== 'undefined') { - jwt = localStorage.getItem('access_token') || ''; - console.log('📖 [Sidebar] 从 localStorage 读取 JWT'); - } - - if (!jwt) { - console.error('❌ [Sidebar] JWT token 未找到,props.frontendJWT:', frontendJWT, 'localStorage.access_token:', localStorage.getItem('access_token')); - setMenuItems([]); - setIsLoadingRoutes(false); - return; - } - - // console.log('🔍 [Sidebar] 当前用户角色:', userRole, 'JWT前20字符:', jwt.substring(0, 20)); - // console.log('🔍 [Sidebar] 映射后的角色key:', roleKey); - const result = await getUserRoutesByRole(userRole, jwt); - - if (result.success && result.data) { - setMenuItems(result.data); - // console.log('✅ [Sidebar] 用户路由权限加载成功:', result.data); - } else { - console.error('❌ [Sidebar] 获取用户路由权限失败:', result.error); - - // 如果需要重定向到首页 - if (result.shouldRedirectToHome) { - // console.log('🔄 [Sidebar] 重定向到首页'); - navigate('/'); - return; - } - - // 其他错误情况,使用空数组 - setMenuItems([]); - } - } catch (error) { - console.error('❌ [Sidebar] 获取用户路由权限时发生错误:', error); - // 发生异常时也重定向到首页 - navigate('/'); - return; - } finally { - setIsLoadingRoutes(false); - } - }; - - fetchUserRoutes(); - }, [userRole, frontendJWT, navigate]); - - // 🔑 检查是否处于系统设置模式或交叉评查模式 - const [isSettingsMode, setIsSettingsMode] = useState(false); - const [isCrossCheckingMode, setIsCrossCheckingMode] = useState(false); - - // 🔒 检测当前端口,用于控制交叉评查入口的显示 - const [currentPort, setCurrentPort] = useState(''); - - useEffect(() => { - if (typeof window !== 'undefined') { - setCurrentPort(window.location.port || ''); - } - }, []); - - // 从 sessionStorage 读取当前选中的模块名称和图片路径,以及各种模式标志 - useEffect(() => { - if (typeof window !== 'undefined') { - try { - const moduleName = sessionStorage.getItem('selectedModuleName'); - const modulePicPath = sessionStorage.getItem('selectedModulePicPath'); - const settingsMode = sessionStorage.getItem('settingsMode'); - const crossCheckingMode = sessionStorage.getItem('crossCheckingMode'); - - if (moduleName) { - setSelectedModuleName(moduleName); - console.log('📌 [Sidebar] 当前选中模块:', moduleName); - } - - if (modulePicPath) { - setSelectedModulePicPath(modulePicPath); - console.log('🖼️ [Sidebar] 模块图片路径:', modulePicPath); - } - - // 🔑 检查是否处于系统设置模式 - if (settingsMode === 'true') { - setIsSettingsMode(true); - setIsCrossCheckingMode(false); // 互斥 - console.log('⚙️ [Sidebar] 进入系统设置模式'); - } - // 🔑 检查是否处于交叉评查模式 - else if (crossCheckingMode === 'true') { - setIsCrossCheckingMode(true); - setIsSettingsMode(false); // 互斥 - console.log('🔀 [Sidebar] 进入交叉评查模式'); - } - // 普通模式 - else { - setIsSettingsMode(false); - setIsCrossCheckingMode(false); - } - } catch (error) { - console.error('❌ [Sidebar] 读取 sessionStorage 失败:', error); - } - } - }, [location.pathname]); // 路由变化时重新读取 - - // 初始化展开状态,默认全部展开 - useEffect(() => { - const initialExpandedState: Record = {}; - menuItems.forEach(item => { - if (item.children) { - initialExpandedState[item.id] = true; - } - }); - setExpandedMenus(initialExpandedState); - }, [menuItems]); - - const toggleMenu = (id: string, e: React.MouseEvent) => { - // 我们只防止事件冒泡,不阻止默认行为 - e.stopPropagation(); - - // console.log('父菜单展开/折叠:', id); - - setExpandedMenus(prev => ({ - ...prev, - [id]: !prev[id] - })); - }; - +import { useState, useEffect } from 'react'; +import { Link, useLocation, useNavigate } from '@remix-run/react'; +import type { UserRole } from '~/root'; +import { getUserRoutesByRole, mapUserRoleToRoleKey, type MenuItem } from '~/api/auth/user-routes'; +import { DOCUMENT_URL, CROSS_CHECKING_ONLY_PORT, CROSS_CHECKING_ONLY_MODE } from '~/config/api-config'; +import { normalizeRoutePathForPermission } from '~/utils/route-alias'; + +interface SidebarProps { + onToggle: () => void; + collapsed: boolean; + userRole: UserRole; + frontendJWT?: string; +} + +export function Sidebar({ onToggle, collapsed, userRole, frontendJWT = '' }: SidebarProps) { + const location = useLocation(); + const [expandedMenus, setExpandedMenus] = useState>({}); + const [menuItems, setMenuItems] = useState([]); // 动态菜单项 + const [isLoadingRoutes, setIsLoadingRoutes] = useState(true); // 路由加载状态 + const [isMobile, setIsMobile] = useState(false); // 移动端检测 + const [selectedModuleName, setSelectedModuleName] = useState(''); // 当前选中的模块名称 + const [selectedModulePicPath, setSelectedModulePicPath] = useState(''); // 当前选中的模块图片路径 + const navigate = useNavigate(); + + // 移动端检测 + useEffect(() => { + const checkMobile = () => { + const mobile = window.innerWidth <= 768; // 768px以下视为移动端 + setIsMobile(mobile); + }; + + // 初始检测 + checkMobile(); + + // 监听窗口大小变化 + window.addEventListener('resize', checkMobile); + return () => window.removeEventListener('resize', checkMobile); + }, []); + + // 获取用户路由权限 + useEffect(() => { + // console.log('🔍 [Sidebar] useEffect 触发,开始获取路由权限'); + + const fetchUserRoutes = async () => { + setIsLoadingRoutes(true); + try { + // 优先使用传入的 frontendJWT,否则从 localStorage 读取 + let jwt = frontendJWT; + + if (!jwt && typeof window !== 'undefined') { + jwt = localStorage.getItem('access_token') || ''; + console.log('📖 [Sidebar] 从 localStorage 读取 JWT'); + } + + if (!jwt) { + console.error('❌ [Sidebar] JWT token 未找到,props.frontendJWT:', frontendJWT, 'localStorage.access_token:', localStorage.getItem('access_token')); + setMenuItems([]); + setIsLoadingRoutes(false); + return; + } + + // console.log('🔍 [Sidebar] 当前用户角色:', userRole, 'JWT前20字符:', jwt.substring(0, 20)); + // console.log('🔍 [Sidebar] 映射后的角色key:', roleKey); + const result = await getUserRoutesByRole(userRole, jwt); + + if (result.success && result.data) { + setMenuItems(result.data); + // console.log('✅ [Sidebar] 用户路由权限加载成功:', result.data); + } else { + console.error('❌ [Sidebar] 获取用户路由权限失败:', result.error); + + // 如果需要重定向到首页 + if (result.shouldRedirectToHome) { + // console.log('🔄 [Sidebar] 重定向到首页'); + navigate('/'); + return; + } + + // 其他错误情况,使用空数组 + setMenuItems([]); + } + } catch (error) { + console.error('❌ [Sidebar] 获取用户路由权限时发生错误:', error); + // 发生异常时也重定向到首页 + navigate('/'); + return; + } finally { + setIsLoadingRoutes(false); + } + }; + + fetchUserRoutes(); + }, [userRole, frontendJWT, navigate]); + + // 🔑 检查是否处于系统设置模式或交叉评查模式 + const [isSettingsMode, setIsSettingsMode] = useState(false); + const [isCrossCheckingMode, setIsCrossCheckingMode] = useState(false); + + // 🔒 检测当前端口,用于控制交叉评查入口的显示 + const [currentPort, setCurrentPort] = useState(''); + + const isPathInSection = (prefix: string) => + location.pathname === prefix || location.pathname.startsWith(`${prefix}/`); + + const isSettingsPath = isPathInSection('/settings') + || isPathInSection('/entry-modules') + || isPathInSection('/role-permissions') + || isPathInSection('/document-types') + || isPathInSection('/rule-groups'); + const isCrossCheckingPath = isPathInSection('/cross-checking'); + const isChatPath = isPathInSection('/chat-with-llm'); + const isContractTemplatePath = isPathInSection('/contract-template') || isPathInSection('/contract-draft'); + + useEffect(() => { + if (typeof window !== 'undefined') { + setCurrentPort(window.location.port || ''); + } + }, []); + + // 从 sessionStorage 读取当前选中的模块名称和图片路径,以及各种模式标志 + useEffect(() => { + if (typeof window !== 'undefined') { + try { + const moduleName = sessionStorage.getItem('selectedModuleName'); + const modulePicPath = sessionStorage.getItem('selectedModulePicPath'); + const settingsMode = sessionStorage.getItem('settingsMode'); + const crossCheckingMode = sessionStorage.getItem('crossCheckingMode'); + const hasStaleSpecialModule = (moduleName === '智慧法务助手' || moduleName === '交叉评查') + && !isChatPath + && !isCrossCheckingPath + && !isSettingsPath + && location.pathname !== '/'; + + if (hasStaleSpecialModule) { + sessionStorage.removeItem('selectedModuleName'); + sessionStorage.removeItem('selectedModulePicPath'); + setSelectedModuleName(''); + setSelectedModulePicPath(''); + console.log('🧹 [Sidebar] 已清理残留的特殊入口模块状态:', moduleName); + } else { + setSelectedModuleName(moduleName || ''); + setSelectedModulePicPath(modulePicPath || ''); + + if (moduleName) { + console.log('📌 [Sidebar] 当前选中模块:', moduleName); + } + + if (modulePicPath) { + console.log('🖼️ [Sidebar] 模块图片路径:', modulePicPath); + } + } + + // 当前路由优先,避免历史 sessionStorage 状态把普通业务页侧边栏劫持成设置页/交叉评查页 + if (isSettingsPath) { + setIsSettingsMode(true); + setIsCrossCheckingMode(false); + console.log('⚙️ [Sidebar] 根据当前路由进入系统设置模式'); + } else if (isCrossCheckingPath) { + setIsCrossCheckingMode(true); + setIsSettingsMode(false); + console.log('🔀 [Sidebar] 根据当前路由进入交叉评查模式'); + } else if (settingsMode === 'true' && location.pathname === '/') { + setIsSettingsMode(true); + setIsCrossCheckingMode(false); + console.log('⚙️ [Sidebar] 首页保留系统设置模式'); + } else if (crossCheckingMode === 'true' && location.pathname === '/') { + setIsCrossCheckingMode(true); + setIsSettingsMode(false); + console.log('🔀 [Sidebar] 首页保留交叉评查模式'); + } else { + setIsSettingsMode(false); + setIsCrossCheckingMode(false); + + // 进入普通业务页时同步清理模式标志,避免后续刷新再次读到脏状态 + sessionStorage.removeItem('settingsMode'); + sessionStorage.removeItem('crossCheckingMode'); + } + + // 兼容用户直接刷新/直达子页时没有 sessionStorage 场景 + if (!moduleName) { + if (isChatPath) { + setSelectedModuleName('智慧法务助手'); + setSelectedModulePicPath('/images/icon_assistant.png'); + } else if (isCrossCheckingPath) { + setSelectedModuleName('交叉评查'); + setSelectedModulePicPath('/images/icon_cross@2x.png'); + } else if (isContractTemplatePath) { + setSelectedModuleName('合同管理'); + } + } + } catch (error) { + console.error('❌ [Sidebar] 读取 sessionStorage 失败:', error); + } + } + }, [isChatPath, isContractTemplatePath, isCrossCheckingPath, location.pathname]); // 路由变化时重新读取 + + // 初始化展开状态,默认全部展开 + useEffect(() => { + const initialExpandedState: Record = {}; + menuItems.forEach(item => { + if (item.children) { + initialExpandedState[item.id] = true; + } + }); + setExpandedMenus(initialExpandedState); + }, [menuItems]); + + const toggleMenu = (id: string, e: React.MouseEvent) => { + // 我们只防止事件冒泡,不阻止默认行为 + e.stopPropagation(); + + // console.log('父菜单展开/折叠:', id); + + setExpandedMenus(prev => ({ + ...prev, + [id]: !prev[id] + })); + }; + const isActive = (path: string) => { const target = new URL(path, 'http://sidebar.local'); @@ -181,15 +229,15 @@ export function Sidebar({ onToggle, collapsed, userRole, frontendJWT = '' }: Sid return location.pathname === target.pathname || location.pathname.startsWith(`${target.pathname}/`); }; - - // 处理侧边栏切换事件 - const handleToggleSidebar = (e: React.MouseEvent) => { - // console.log('侧边栏折叠/展开'); - // 只防止事件冒泡,不阻止默认行为 - e.stopPropagation(); - onToggle(); - }; - + + // 处理侧边栏切换事件 + const handleToggleSidebar = (e: React.MouseEvent) => { + // console.log('侧边栏折叠/展开'); + // 只防止事件冒泡,不阻止默认行为 + e.stopPropagation(); + onToggle(); + }; + // 处理子菜单项点击事件 const handleSubMenuClick = (child: MenuItem, e: React.MouseEvent) => { // 只需要阻止冒泡,不阻止默认行为 @@ -200,24 +248,75 @@ export function Sidebar({ onToggle, collapsed, userRole, frontendJWT = '' }: Sid const isRuleManagementMenu = (item: MenuItem) => item.id === 'rule-management' || item.path === '/rules' || - item.title === '评查规则库' || + item.title === '规则管理' || !!item.children?.some(child => child.id === 'rules-list' || child.path === '/rules/list'); - const isCaseFileModule = selectedModuleName.includes('案卷') || selectedModuleName.includes('卷宗'); + + const specialModeModuleNames = new Set(['智慧法务助手', '交叉评查']); + const shouldHideModuleBanner = isSettingsPath; + const effectiveSelectedModuleName = (() => { + if (shouldHideModuleBanner) { + return ''; + } + + if (isChatPath) { + return '智慧法务助手'; + } + + if (isCrossCheckingPath) { + return '交叉评查'; + } + + if (isContractTemplatePath && !selectedModuleName) { + return '合同管理'; + } + + if (!isChatPath && !isCrossCheckingPath && specialModeModuleNames.has(selectedModuleName)) { + return ''; + } + + return selectedModuleName; + })(); + + const effectiveSelectedModulePicPath = (() => { + if (shouldHideModuleBanner) { + return ''; + } + + if (isChatPath) { + return '/images/icon_assistant.png'; + } + + if (isCrossCheckingPath) { + return '/images/icon_cross@2x.png'; + } + + if (!isChatPath && !isCrossCheckingPath && specialModeModuleNames.has(selectedModuleName)) { + return ''; + } + + return selectedModulePicPath; + })(); + + const isCaseFileModule = effectiveSelectedModuleName.includes('案卷') || effectiveSelectedModuleName.includes('卷宗'); + const isAssistantModule = effectiveSelectedModuleName === '智慧法务助手' || isChatPath; + const isContractModule = effectiveSelectedModuleName.includes('合同') || isContractTemplatePath; + const effectiveSettingsMode = isSettingsMode || isSettingsPath; + const effectiveCrossCheckingMode = isCrossCheckingMode || isCrossCheckingPath; const buildRulesTestListPath = (mainType?: string) => { const params = new URLSearchParams(); if (isCaseFileModule) { params.set('documentType', '案卷'); if (mainType) params.set('mainType', mainType); - } else if (selectedModuleName.includes('合同')) { + } else if (isContractModule) { params.set('documentType', '合同'); params.set('mainType', '合同'); - } else if (selectedModuleName.includes('公文')) { + } else if (effectiveSelectedModuleName.includes('公文')) { params.set('documentType', '内部公文'); params.set('mainType', '内部公文'); - } else if (selectedModuleName) { - params.set('documentType', selectedModuleName); - params.set('mainType', selectedModuleName); + } else if (effectiveSelectedModuleName) { + params.set('documentType', effectiveSelectedModuleName); + params.set('mainType', effectiveSelectedModuleName); } const query = params.toString(); @@ -263,263 +362,287 @@ export function Sidebar({ onToggle, collapsed, userRole, frontendJWT = '' }: Sid // const isPort51707 = typeof window !== 'undefined' && window.location.port === '51707' + const dedupeMenuItems = (items: MenuItem[]): MenuItem[] => { + const descendantKeys = new Set(); + + const buildMenuIdentity = (item: MenuItem) => { + const normalizedPath = normalizeRoutePathForPermission(item.path || ''); + return `${normalizedPath}::${item.title}`; + }; + + const collectDescendantPaths = (children?: MenuItem[]) => { + if (!children || children.length === 0) { + return; + } + + children.forEach((child) => { + descendantKeys.add(buildMenuIdentity(child)); + collectDescendantPaths(child.children); + }); + }; + + items.forEach((item) => collectDescendantPaths(item.children)); + + return items.filter((item) => !descendantKeys.has(buildMenuIdentity(item))); + }; + // 处理菜单项:清理子菜单结构 - const processedMenuItems: MenuItem[] = menuItems.filter(item =>{ - // console.log('菜单项:', item.title, 'Icon:', item.icon) - - // 🔑 优先检查:如果处于系统设置模式,只显示 /settings 及其子路由 - if (isSettingsMode) { - return item.path === '/settings' || item.path?.startsWith('/settings/'); - } - - // 🔑 优先检查:如果处于交叉评查模式,只显示 /cross-checking 及其子路由 - if (isCrossCheckingMode) { - return item.path === '/cross-checking' || item.path?.startsWith('/cross-checking/'); - } - - // 🔑 重要:非系统设置模式下,隐藏所有 /settings 相关菜单 - if (item.path === '/settings' || item.path?.startsWith('/settings/')) { - return false; - } - - // 🔒 交叉评查访问控制: - // - CROSS_CHECKING_ONLY_MODE=false 时,所有端口都可访问(根据后端权限) - // - CROSS_CHECKING_ONLY_MODE=true 时,只有 51707 端口可访问 - if (CROSS_CHECKING_ONLY_MODE && currentPort && currentPort !== CROSS_CHECKING_ONLY_PORT) { - if (item.path === '/cross-checking' || item.path?.startsWith('/cross-checking/')) { - return false; - } - } - - // 🔑 重要:非交叉评查模式下,隐藏所有 /cross-checking 相关菜单 - if (item.path === '/cross-checking' || item.path?.startsWith('/cross-checking/')) { - return false; - } - - // 如果是省局访问 - // if(isPort51707){ - // if (selectedModuleName === '智慧法务助手'){ - // return item.path && item.path.startsWith('/chat-with-llm') - // } - // return item.path && item.path.startsWith('/cross-checking') - // } - - // 🔑 如果选择了"智慧法务助手",显示 /chat-with-llm 和 /dataset-manager 相关菜单 - if (selectedModuleName === '智慧法务助手') { - return item.path === '/chat-with-llm' || item.path?.startsWith('/chat-with-llm/') - } - - // 🔑 如果选择了包含"合同"的模块 - if (selectedModuleName.includes('合同')) { - // 排除智慧法务助手专属菜单 - if (item.path === '/chat-with-llm' || item.path?.startsWith('/chat-with-llm/')) { - return false; - } - // 保留其他所有菜单(包括 /contract-template) - return true; - } - - // 🔑 其他模块:排除特殊菜单 - // 排除智慧法务助手专属菜单 - if (item.path === '/chat-with-llm' || item.path?.startsWith('/chat-with-llm/')) { - return false; - } - // 排除合同专属菜单 - if (item.path === '/contract-template' || item.path?.startsWith('/contract-template/')) { - return false; - } + const processedMenuItems: MenuItem[] = dedupeMenuItems(menuItems.filter(item =>{ + // console.log('菜单项:', item.title, 'Icon:', item.icon) + + // 🔑 优先检查:如果处于系统设置模式,只显示 /settings 及其子路由 + if (effectiveSettingsMode) { + return item.path === '/settings' || item.path?.startsWith('/settings/'); + } + + // 🔑 优先检查:如果处于交叉评查模式,只显示 /cross-checking 及其子路由 + if (effectiveCrossCheckingMode) { + return item.path === '/cross-checking' || item.path?.startsWith('/cross-checking/'); + } + + // 🔑 重要:非系统设置模式下,隐藏所有 /settings 相关菜单 + if (item.path === '/settings' || item.path?.startsWith('/settings/')) { + return false; + } + + // 🔒 交叉评查访问控制: + // - CROSS_CHECKING_ONLY_MODE=false 时,所有端口都可访问(根据后端权限) + // - CROSS_CHECKING_ONLY_MODE=true 时,只有 51707 端口可访问 + if (CROSS_CHECKING_ONLY_MODE && currentPort && currentPort !== CROSS_CHECKING_ONLY_PORT) { + if (item.path === '/cross-checking' || item.path?.startsWith('/cross-checking/')) { + return false; + } + } + + // 🔑 重要:非交叉评查模式下,隐藏所有 /cross-checking 相关菜单 + if (item.path === '/cross-checking' || item.path?.startsWith('/cross-checking/')) { + return false; + } + + // 如果是省局访问 + // if(isPort51707){ + // if (selectedModuleName === '智慧法务助手'){ + // return item.path && item.path.startsWith('/chat-with-llm') + // } + // return item.path && item.path.startsWith('/cross-checking') + // } + + // 🔑 如果选择了"智慧法务助手",显示 /chat-with-llm 和 /dataset-manager 相关菜单 + if (isAssistantModule) { + return item.path === '/chat-with-llm' || item.path?.startsWith('/chat-with-llm/') + } + + // 🔑 如果选择了包含"合同"的模块 + if (isContractModule) { + // 排除智慧法务助手专属菜单 + if (item.path === '/chat-with-llm' || item.path?.startsWith('/chat-with-llm/')) { + return false; + } + // 保留其他所有菜单(包括 /contract-template) + return true; + } + + // 🔑 其他模块:排除特殊菜单 + // 排除智慧法务助手专属菜单 + if (item.path === '/chat-with-llm' || item.path?.startsWith('/chat-with-llm/')) { + return false; + } + // 排除合同专属菜单 + if (item.path === '/contract-template' || item.path?.startsWith('/contract-template/')) { + return false; + } // 保留其他菜单 return true; }).map(normalizeRuleManagementMenu).map((item): MenuItem => { - // 处理子菜单:过滤隐藏的子菜单 - if (item.children && item.children.length > 0) { - // 过滤掉 hideBreadcrumb=true 的子菜单(这些通常是隐藏菜单) - const visibleChildren = item.children.filter(child => !child.hideBreadcrumb); - - // 如果过滤后没有可见的子菜单,返回不带子菜单的父级(变成可点击的单级菜单) - if (visibleChildren.length === 0) { - const { children, ...itemWithoutChildren } = item; - return itemWithoutChildren as MenuItem; - } - - // 如果还有可见的子菜单,返回带过滤后子菜单的项 - return { ...item, children: visibleChildren }; - } - - // 处理空 children 数组或 undefined 的情况 - if (item.children !== undefined) { - // 如果 children 存在但为空数组,移除它(让父级变成可点击的单级菜单) - const { children, ...itemWithoutChildren } = item; - return itemWithoutChildren as MenuItem; - } - - // 没有子菜单的项直接返回 - return item; - }); - - return ( - <> - {/* 移动端遮罩层 */} - {isMobile && !collapsed && ( -
{ - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - onToggle(); - } - }} - /> - )} - -
-
-
{ - navigate('/'); - }} - role="button" - tabIndex={0} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - navigate('/'); - } - }} - > - 智慧法务 - {!collapsed &&

智慧法务

} -
- -
- - {/* 显示入口模块的名称 */} - {selectedModuleName && ( -
-
- {selectedModulePicPath && ( - {selectedModuleName} - )} - {!collapsed && ( - {selectedModuleName} - )} -
-
- )} - -
- {isLoadingRoutes ? ( - // 加载中状态显示,保留菜单布局结构 -
- {Array(5).fill(0).map((_, index) => ( -
-
-
- {!collapsed &&
} -
-
- ))} -
- ) : ( - // 数据加载完成后显示菜单 - processedMenuItems.map((item) => ( -
- {!item.children ? ( - { - // 只阻止冒泡,不阻止默认行为 - e.stopPropagation(); - // console.log('单级菜单点击:', item.title, '路径:', item.path); - }} - > - - {!collapsed && {item.title}} - - ) : ( - <> -
{ - // console.log('%c父菜单点击 ===> ', 'background: #722ed1; color: white; padding: 2px 4px; border-radius: 2px;', item.title); - toggleMenu(item.id, e); - }} - role="button" - tabIndex={0} - aria-expanded={expandedMenus[item.id] || false} - aria-controls={`submenu-${item.id}`} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - toggleMenu(item.id, (e as unknown) as React.MouseEvent); - } - }} - > -
- - {!collapsed && {item.title}} -
- {!collapsed && ( - - )} -
- - {(expandedMenus[item.id] || collapsed) && ( -
- {item.children.map((child) => ( - handleSubMenuClick(child, e)} - > - - {!collapsed && {child.title}} - - ))} -
- )} - - )} -
- )) - )} -
- - {/* 操作手册下载按钮 */} - -
- - ); + // 处理子菜单:过滤隐藏的子菜单 + if (item.children && item.children.length > 0) { + // 过滤掉 hideBreadcrumb=true 的子菜单(这些通常是隐藏菜单) + const visibleChildren = item.children.filter(child => !child.hideBreadcrumb); + + // 如果过滤后没有可见的子菜单,返回不带子菜单的父级(变成可点击的单级菜单) + if (visibleChildren.length === 0) { + const { children, ...itemWithoutChildren } = item; + return itemWithoutChildren as MenuItem; + } + + // 如果还有可见的子菜单,返回带过滤后子菜单的项 + return { ...item, children: visibleChildren }; + } + + // 处理空 children 数组或 undefined 的情况 + if (item.children !== undefined) { + // 如果 children 存在但为空数组,移除它(让父级变成可点击的单级菜单) + const { children, ...itemWithoutChildren } = item; + return itemWithoutChildren as MenuItem; + } + + // 没有子菜单的项直接返回 + return item; + })); + + return ( + <> + {/* 移动端遮罩层 */} + {isMobile && !collapsed && ( +
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onToggle(); + } + }} + /> + )} + +
+
+
{ + navigate('/'); + }} + role="button" + tabIndex={0} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + navigate('/'); + } + }} + > + 智慧法务 + {!collapsed &&

智慧法务

} +
+ +
+ + {/* 显示入口模块的名称 */} + {effectiveSelectedModuleName && ( +
+
+ {effectiveSelectedModulePicPath && ( + {effectiveSelectedModuleName} + )} + {!collapsed && ( + {effectiveSelectedModuleName} + )} +
+
+ )} + +
+ {isLoadingRoutes ? ( + // 加载中状态显示,保留菜单布局结构 +
+ {Array(5).fill(0).map((_, index) => ( +
+
+
+ {!collapsed &&
} +
+
+ ))} +
+ ) : ( + // 数据加载完成后显示菜单 + processedMenuItems.map((item) => ( +
+ {!item.children ? ( + { + // 只阻止冒泡,不阻止默认行为 + e.stopPropagation(); + // console.log('单级菜单点击:', item.title, '路径:', item.path); + }} + > + + {!collapsed && {item.title}} + + ) : ( + <> +
{ + // console.log('%c父菜单点击 ===> ', 'background: #722ed1; color: white; padding: 2px 4px; border-radius: 2px;', item.title); + toggleMenu(item.id, e); + }} + role="button" + tabIndex={0} + aria-expanded={expandedMenus[item.id] || false} + aria-controls={`submenu-${item.id}`} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + toggleMenu(item.id, (e as unknown) as React.MouseEvent); + } + }} + > +
+ + {!collapsed && {item.title}} +
+ {!collapsed && ( + + )} +
+ + {(expandedMenus[item.id] || collapsed) && ( +
+ {item.children.map((child) => ( + handleSubMenuClick(child, e)} + > + + {!collapsed && {child.title}} + + ))} +
+ )} + + )} +
+ )) + )} +
+ + {/* 操作手册下载按钮 */} + +
+ + ); } diff --git a/app/components/rules/new/BasicInfo.tsx b/app/components/rules/new/BasicInfo.tsx index 81e3b66..034c6b5 100644 --- a/app/components/rules/new/BasicInfo.tsx +++ b/app/components/rules/new/BasicInfo.tsx @@ -105,6 +105,7 @@ export function BasicInfo({ const [attributeTypeOptions, setAttributeTypeOptions] = useState([]); const [attributeTypesLoading, setAttributeTypesLoading] = useState(false); const [isCustomAttributeType, setIsCustomAttributeType] = useState(false); // 是否为自定义输入模式 + const hasScopedGroupSource = filteredRuleTypes.length > 0 || evaluationPointGroups.length > 0; // 从 Session Storage 获取 documentTypeIds 并调用 API 获取评查点类型 useEffect(() => { @@ -199,7 +200,7 @@ export function BasicInfo({ ); } - // 如果 API 返回了数据,使用 API 数据 + // 优先使用接口按当前文档类型范围返回的一级分组 if (filteredRuleTypes.length > 0) { return ( <> @@ -213,11 +214,11 @@ export function BasicInfo({ ); } - // 兜底:如果 API 没有返回数据,使用 evaluationPointGroups 中的一级分组 + // 兜底仅限当前页面已加载的 scoped 分组数据,禁止回退到“全库分组” if (!evaluationPointGroups || evaluationPointGroups.length === 0) { return ( <> - + ); } @@ -261,11 +262,13 @@ export function BasicInfo({ // 清除错误信息 setCodeError(''); // 设置新的验证定时器(500ms后触发验证) - const timer = setTimeout(async () => { - const error = await validateCodeUnique(value); - setCodeError(error); - }, 500); - setCodeValidationTimer(timer); + { + const timer = setTimeout(async () => { + const error = await validateCodeUnique(value); + setCodeError(error); + }, 500); + setCodeValidationTimer(timer); + } break; case 'risk-level': newData.risk = value; @@ -294,6 +297,7 @@ export function BasicInfo({ case 'checkpoint-type': // 处理评查点类型选择 if (value) { + newData.evaluation_point_groups_id = null; // 优先从 API 返回的 filteredRuleTypes 中查找 const selectedFromApi = filteredRuleTypes.find(ruleType => ruleType.code === value); if (selectedFromApi) { @@ -494,7 +498,8 @@ export function BasicInfo({ disabled={!formData.evaluation_point_groups_pid || filteredRuleGroups.length === 0} > @@ -505,7 +510,8 @@ export function BasicInfo({ ))}
- {!formData.evaluation_point_groups_pid ? "请先选择评查点类型" : + {!hasScopedGroupSource ? "请先到系统设置为当前入口模块绑定文档类型与评查点分组" : + !formData.evaluation_point_groups_pid ? "请先选择评查点类型" : filteredRuleGroups.length === 0 ? "该类型下暂无可用规则组" : "选择评查点所属的规则组"}
@@ -703,4 +709,4 @@ export function BasicInfo({
); -} \ No newline at end of file +} diff --git a/app/config/minimal-scope.ts b/app/config/minimal-scope.ts index 173e0dc..cd6ff42 100644 --- a/app/config/minimal-scope.ts +++ b/app/config/minimal-scope.ts @@ -1,8 +1,13 @@ export const MINIMAL_MENU_PREFIXES = [ '/home', '/chat-with-llm', + '/contract-template', + '/cross-checking', '/files', '/documents', + '/rules', + '/rule-groups', + '/rules-files', '/settings', '/entry-modules', '/role-permissions', @@ -13,7 +18,8 @@ export const MINIMAL_HOME_TARGETS = [ '/home', '/files/upload', '/documents', - '/chat-with-llm/chat' + '/chat-with-llm/chat', + '/cross-checking', ] as const; export function isMinimalMenuPath(path?: string | null): boolean { diff --git a/app/hooks/usePermission.tsx b/app/hooks/usePermission.tsx index 8cd9af7..42dd185 100644 --- a/app/hooks/usePermission.tsx +++ b/app/hooks/usePermission.tsx @@ -95,9 +95,25 @@ export function usePermission() { return []; }; - // 优先使用 permissionMap 中的权限,如果没有则使用交叉评查默认权限 - const currentPermissions = permissionMap[currentPath]?.length > 0 - ? permissionMap[currentPath] + const getInheritedRoutePermissions = (): string[] => { + if (permissionMap[currentPath]?.length > 0) { + return permissionMap[currentPath]; + } + + const segments = currentPath.split('/').filter(Boolean); + for (let i = segments.length - 1; i >= 1; i -= 1) { + const candidate = `/${segments.slice(0, i).join('/')}`; + if (permissionMap[candidate]?.length > 0) { + return permissionMap[candidate]; + } + } + + return []; + }; + + // 优先使用当前路由权限,其次继承父级导航权限,再降级到交叉评查默认权限 + const currentPermissions = getInheritedRoutePermissions().length > 0 + ? getInheritedRoutePermissions() : getCrossCheckingPermissions(); // 向后兼容:如果存在旧的permissions数组,也要支持 diff --git a/app/root.tsx b/app/root.tsx index 7cf9afe..eefe5c3 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -264,6 +264,8 @@ export async function loader({ request }: LoaderFunctionArgs) { if (!isAllowedPath) { console.warn(`⚠️ [Root Loader] 用户尝试访问未授权路由: ${pathname}`); + console.warn("⚠️ [Root Loader] 当前允许路由:", allowedPaths); + console.warn("⚠️ [Root Loader] 归一化后路径:", normalizeRoutePathForPermission(pathname)); // 返回 403 错误,而不是 redirect(避免循环) throw new Response("无权访问此页面", { status: 403 }); } diff --git a/app/routes/_index.tsx b/app/routes/_index.tsx index 2ba89f5..610239e 100644 --- a/app/routes/_index.tsx +++ b/app/routes/_index.tsx @@ -60,17 +60,23 @@ export async function loader({ request }: LoaderFunctionArgs) { if (routesResult.success && routesResult.data) { // 查找 '/settings' 路由及其子路由 - const settingsRoute = routesResult.data.find(route => route.path === '/settings'); - if (settingsRoute) { - hasSettingsAccess = true; - // 提取子路由信息(仅 path 和 title) - if (settingsRoute.children && settingsRoute.children.length > 0) { - settingsChildren = settingsRoute.children.map(child => ({ - path: child.path, - title: child.title - })); - } - } + const settingsRoute = routesResult.data.find(route => route.path === '/settings'); + if (settingsRoute) { + hasSettingsAccess = true; + // 提取子路由信息(仅 path 和 title) + if (settingsRoute.children && settingsRoute.children.length > 0) { + settingsChildren = settingsRoute.children + .map(child => ({ + path: child.path, + title: child.title + })) + .sort((a, b) => { + if (a.path === '/rule-groups') return -1; + if (b.path === '/rule-groups') return 1; + return 0; + }); + } + } // 检查是否存在顶级路由 '/cross-checking' // 🔒 交叉评查访问控制: @@ -291,10 +297,12 @@ export default function Index() { } // 跳转到第一个子路由 - const firstChildPath = loaderData.settingsChildren[0].path; - console.log(`📌 [Index] 系统设置:跳转到第一个子路由 ${firstChildPath}`); - navigate(firstChildPath); - }; + const preferredSettingsPath = + loaderData.settingsChildren.find((child: { path: string; title: string }) => child.path === '/rule-groups')?.path || + loaderData.settingsChildren[0].path; + console.log(`📌 [Index] 系统设置:跳转到首选子路由 ${preferredSettingsPath}`); + navigate(preferredSettingsPath); + }; // 处理进入交叉评查 const handleEnterCrossChecking = () => { diff --git a/app/routes/contract-template.detail.$id.tsx b/app/routes/contract-template.detail.$id.tsx index 0819ecc..ced783f 100644 --- a/app/routes/contract-template.detail.$id.tsx +++ b/app/routes/contract-template.detail.$id.tsx @@ -22,10 +22,10 @@ export const links = () => [ export const meta: MetaFunction = ({ data }) => { return [ - { title: `${data?.template.title || '合同模板详情'} - 智慧法务` }, + { title: `${data?.template.title || '模板详情'} - 合同管理 - 智慧法务` }, { name: 'description', - content: data?.template.description || '查看合同模板详细信息' + content: data?.template.description || '查看合同管理模块中的模板详细信息' } ]; }; @@ -537,4 +537,4 @@ export default function ContractTemplateDetail() { ); -} \ No newline at end of file +} diff --git a/app/routes/contract-template.list._index.tsx b/app/routes/contract-template.list._index.tsx index fc897f2..38f268f 100644 --- a/app/routes/contract-template.list._index.tsx +++ b/app/routes/contract-template.list._index.tsx @@ -15,10 +15,10 @@ export const links = () => [ export const meta: MetaFunction = () => { return [ - { title: '合同模板列表 - 智慧法务' }, + { title: '模板列表 - 合同管理 - 智慧法务' }, { name: 'description', - content: '浏览和管理所有合同模板,按分类查看各种类型的合同模板。' + content: '浏览合同管理模块下的模板列表,按分类查看和使用合同模板。' } ]; }; @@ -238,7 +238,7 @@ export default function ContractTemplateList() {

- {currentCategory === '全部' ? '合同模板库' : `${currentCategory}模板`} + {currentCategory === '全部' ? '合同管理' : `${currentCategory}模板`}

{total} 个模板 @@ -307,4 +307,4 @@ export default function ContractTemplateList() { // 面包屑导航配置 export const handle = { breadcrumb: "合同列表" -}; \ No newline at end of file +}; diff --git a/app/routes/contract-template.search._index.tsx b/app/routes/contract-template.search._index.tsx index dac51ab..8ea7276 100644 --- a/app/routes/contract-template.search._index.tsx +++ b/app/routes/contract-template.search._index.tsx @@ -12,17 +12,17 @@ export const links = () => [ export const meta: MetaFunction = () => { return [ - { title: 'AI智能合同模板搜索 - 智慧法务' }, + { title: '模板搜索 - 合同管理 - 智慧法务' }, { name: 'description', - content: '使用AI智能搜索快速找到最适合的合同模板,支持自然语言描述搜索。' + content: '在合同管理模块中使用智能检索快速找到合适的合同模板。' } ]; }; // 面包屑导航配置 export const handle = { - breadcrumb: "智能搜索" + breadcrumb: "模板搜索" }; /** @@ -117,4 +117,4 @@ export default function ContractTemplateSearchIndex() {
); -} \ No newline at end of file +} diff --git a/app/routes/contract-template.search.results.tsx b/app/routes/contract-template.search.results.tsx index 64d0286..5b8fbec 100644 --- a/app/routes/contract-template.search.results.tsx +++ b/app/routes/contract-template.search.results.tsx @@ -17,10 +17,10 @@ export const links = () => [ export const meta: MetaFunction = () => { return [ - { title: '搜索结果 - AI智能合同模板搜索 - 智慧法务' }, + { title: '模板搜索结果 - 合同管理 - 智慧法务' }, { name: 'description', - content: 'AI智能搜索合同模板结果,快速找到最适合的模板。' + content: '查看合同管理模块的模板搜索结果,快速定位合适模板。' } ]; }; @@ -323,4 +323,4 @@ export default function ContractTemplateSearchResults() { )}
); -} \ No newline at end of file +} diff --git a/app/routes/document-types._index.tsx b/app/routes/document-types._index.tsx index 83e21f7..66beae6 100644 --- a/app/routes/document-types._index.tsx +++ b/app/routes/document-types._index.tsx @@ -20,7 +20,7 @@ export function links() { export const meta: MetaFunction = () => { return [ { title: "文档类型管理 - 中国烟草AI合同及卷宗审核系统" }, - { name: "description", content: "管理文档类型及其规则集绑定" }, + { name: "description", content: "管理文档类型及其汇总规则集绑定" }, ]; }; @@ -104,7 +104,7 @@ export default function DocumentTypesIndex() { 编码 名称 入口模块 - 规则集 + 汇总规则集 状态 操作 diff --git a/app/routes/document-types.new.tsx b/app/routes/document-types.new.tsx index 57db2dc..cbe846c 100644 --- a/app/routes/document-types.new.tsx +++ b/app/routes/document-types.new.tsx @@ -16,6 +16,7 @@ import { type EntryModuleOption, type RuleSetOption, } from "~/api/document-types/document-types"; +import { getDocumentSubtypeGroups, type DocumentSubtypeGroup } from "~/api/files/files-upload"; import newStyles from "~/styles/pages/document-types_new.css?url"; export function links() { @@ -30,6 +31,7 @@ interface LoaderData { entryModules: EntryModuleOption[]; ruleSets: RuleSetOption[]; editType: DocumentType | null; + runtimeSubtypeGroups: DocumentSubtypeGroup[]; frontendJWT?: string | null; } @@ -45,12 +47,19 @@ export async function loader({ request }: LoaderFunctionArgs) { ]); let editType: DocumentType | null = null; + let runtimeSubtypeGroups: DocumentSubtypeGroup[] = []; if (editId) { const res = await getDocumentType(parseInt(editId), frontendJWT); editType = res.data || null; + if (editType?.id) { + const groupsRes = await getDocumentSubtypeGroups(editType.id, frontendJWT, editType.entryModuleId || undefined); + if ("data" in groupsRes && groupsRes.data) { + runtimeSubtypeGroups = groupsRes.data; + } + } } - return { entryModules: modulesRes.data || [], ruleSets: setsRes.data || [], editType, frontendJWT }; + return { entryModules: modulesRes.data || [], ruleSets: setsRes.data || [], editType, runtimeSubtypeGroups, frontendJWT }; } export default function DocumentTypeNew() { @@ -69,6 +78,8 @@ export default function DocumentTypeNew() { const [errors, setErrors] = useState>({}); const selectedModule = loaderData.entryModules.find((m) => m.id === entryModuleId); + const runtimeSubtypeGroups = loaderData.runtimeSubtypeGroups || []; + const ruleSetsReadonly = isEdit && runtimeSubtypeGroups.length > 0; const selectedRuleSets = loaderData.ruleSets.filter((rs) => selectedRuleSetIds.includes(rs.id)); const selectedUnavailableRuleSets = selectedRuleSets.filter((rs) => !rs.hasUsableVersion); const normalizedRuleSetKeyword = ruleSetKeyword.trim().toLowerCase(); @@ -102,7 +113,7 @@ export default function DocumentTypeNew() { if (!code.trim()) errs.code = "编码不能为空"; else if (!/^[a-zA-Z][a-zA-Z0-9_.]*$/.test(code.trim())) errs.code = "编码格式:字母开头,可含字母数字._"; if (!name.trim()) errs.name = "名称不能为空"; - if (selectedUnavailableRuleSets.length > 0) { + if (!ruleSetsReadonly && selectedUnavailableRuleSets.length > 0) { errs.ruleSetIds = "已选择的规则集中包含不可用于上传评查的项,请先确认可用规则数是否正常"; } setErrors(errs); @@ -126,7 +137,7 @@ export default function DocumentTypeNew() { name: name.trim(), description: description.trim(), entryModuleId, - ruleSetIds: selectedRuleSetIds, + ...(ruleSetsReadonly ? {} : { ruleSetIds: selectedRuleSetIds }), }; const res = await updateDocumentType(editType.id, dto, loaderData.frontendJWT ?? undefined); if (res.error) { toastService.error(res.error); return; } @@ -161,7 +172,7 @@ export default function DocumentTypeNew() { {isEdit ? `编辑文档类型 — ${editType?.code}` : "新建文档类型"}

- 为上传入口绑定清晰的文档语义、规则集和处理流向,减少后续抽取与评查配置分散的问题。 + 为上传入口绑定清晰的文档语义、汇总规则集和处理流向;实际运行时仍以评查点分组页中的“二级分组 → 规则集”绑定为准。

@@ -299,15 +310,62 @@ export default function DocumentTypeNew() { {selectedModule?.name || "未绑定入口模块,上传入口不会主动露出此类型"}
+ + {isEdit ? ( +
+ +
+ 当前运行链路摘要 + + {runtimeSubtypeGroups.length > 0 + ? `当前文档类型在评查点分组中已挂到 ${runtimeSubtypeGroups.length} 个二级分组;上传时会先按入口模块和子类型命中,再决定实际规则集。` + : "当前文档类型还没有在评查点分组中挂到任何二级分组,上传时无法形成完整的新链路。"} + +
+
+ ) : null} + {isEdit && runtimeSubtypeGroups.length > 0 ? ( +
+
+
+ 已关联的运行子类型 + 这里只展示实际会命中的二级分组,最终规则仍以分组页绑定为准。 +
+
+
+ {runtimeSubtypeGroups.map((group) => ( +
+
+ {group.displayName || group.name} + {group.rootGroupName || "未归属一级分组"}{group.entryModuleName ? ` · ${group.entryModuleName}` : ""} +
+ {group.code} +
+ ))} +
+
+ ) : null}
Step 03 -

关联规则集

+

汇总规则集

+
+

这里展示的是文档类型维度的汇总规则集,用于兼容旧页面与快速总览;如果同一文档类型拆成多个二级分组,实际上传命中仍以分组页配置为准。

+
+ +
+ +
+ {ruleSetsReadonly ? "当前已收口为只读汇总" : "这是汇总视图,不是最终运行绑定"} + + {ruleSetsReadonly + ? "当前文档类型已经接入评查点分组新链路。这里仅展示汇总结果,不再作为最终运行绑定编辑入口;请到“评查点分组管理”维护二级分组下的实际规则集。" + : "当一个文档类型下存在多个二级分组时,这里看到的是所有二级分组规则集的并集汇总;具体到某次上传,只会命中所选子类型下的规则集。"} +
-

规则集会直接影响上传后的评查范围,建议按业务场景精确绑定,不要泛滥勾选。

@@ -316,7 +374,11 @@ export default function DocumentTypeNew() { 已选择 {selectedRuleSetIds.length} / {loaderData.ruleSets.length} - 勾选后,该文档类型上传时会自动加载对应规则集执行评查 + + {ruleSetsReadonly + ? "当前只读展示的是文档类型层汇总结果;上传时将优先按入口模块下命中的二级分组执行评查。" + : "这里用于维护文档类型层的汇总结果;上传时若已拆分子类型,后端会优先按二级分组绑定执行评查。"} +
@@ -328,6 +390,17 @@ export default function DocumentTypeNew() { />
+ {ruleSetsReadonly ? ( +
+
+ 如需调整实际运行规则 + 请进入“评查点分组管理”,在对应一级分组 / 二级分组下编辑规则集绑定。 +
+ +
+ ) : null} {selectedUnavailableRuleSets.length > 0 && (
@@ -365,6 +438,7 @@ export default function DocumentTypeNew() { type="checkbox" checked={selectedRuleSetIds.includes(rs.id)} onChange={() => toggleRuleSet(rs.id)} + disabled={ruleSetsReadonly} />
@@ -497,7 +571,7 @@ export default function DocumentTypeNew() {
  • 编码保持稳定,不要混入显示文案,便于后端接口与权限配置长期复用。
  • 描述尽量写业务边界,例如“主合同”“补充协议”“付款附件”等,避免上传误选。
  • -
  • 规则集宁可精简,也不要把无关规则打包给所有文档类型,避免误报过多。
  • +
  • {ruleSetsReadonly ? "当前类型的实际运行规则请统一在评查点分组页维护,避免这里和分组页双写冲突。" : "规则集宁可精简,也不要把无关规则打包给所有文档类型,避免误报过多。"}
diff --git a/app/routes/documents.edit.tsx b/app/routes/documents.edit.tsx index 9675ac9..5f9578e 100644 --- a/app/routes/documents.edit.tsx +++ b/app/routes/documents.edit.tsx @@ -517,6 +517,12 @@ export default function DocumentEdit() { {getDocumentTypeName(documentData.type)}
+ {documentData.groupName && ( +
+ + 子类型:{documentData.groupName} +
+ )}
{documentData.uploadTime} @@ -568,6 +574,20 @@ export default function DocumentEdit() { />
如无编号可留空
+ + {documentData.groupName && ( +
+ + +
子类型由上传时命中的二级分组决定,当前页面仅展示不可修改。
+
+ )}
diff --git a/app/routes/documents.list.tsx b/app/routes/documents.list.tsx index b5a7d04..8516092 100644 --- a/app/routes/documents.list.tsx +++ b/app/routes/documents.list.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef, useCallback } from "react"; +import { Fragment, useState, useEffect, useRef, useCallback } from "react"; import { useSearchParams, useLoaderData, useFetcher, useNavigate,Link } from "@remix-run/react"; import { type MetaFunction, type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/node"; import { Card } from "~/components/ui/Card"; @@ -13,6 +13,7 @@ import { TableRowSkeleton, LoadingIndicator, NumberSkeleton } from '~/components import documentsIndexStyles from "~/styles/pages/documents_index.css?url"; import documentVersionStyles from "~/styles/components/document-version.css?url"; import { getDocumentTypesByIds, deleteDocument, getDocumentsListFromAPI, type DocumentUI, type DocumentVersionUI } from "~/api/files/documents"; +import { getDocumentTypes } from "~/api/document-types/document-types"; // import { IssuesDiff } from "~/components/ui/IssuesDiff"; import { ResultStats } from "~/components/ui/ResultStats"; import { updateDocumentAuditStatus } from "~/api/evaluation_points/rules-files"; @@ -57,7 +58,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { // label: type.name // })); - // 初始返回空数据,将在客户端根据 sessionStorage 中的 documentTypeIds 加载实际数据 + // 初始返回空数据,客户端会根据入口模块/类型作用域加载实际数据 return Response.json({ documents: [], total: 0, @@ -76,6 +77,11 @@ interface ActionResponse { message: string; } +interface DocumentListScope { + selectedModuleId: number | null; + documentTypeIds: number[]; +} + // 审核状态筛选选项 const auditStatusOptions = [ // { value: "", label: "全部" }, @@ -186,12 +192,13 @@ export default function DocumentsIndex() { const navigate = useNavigate(); // 权限控制 - const { hasPermission } = usePermission(); - const canView = hasPermission('document:document:view'); - const canUpdate = hasPermission('document:document:update'); + const { hasAnyPermission } = usePermission(); + const canView = hasAnyPermission(['documents:list:read', 'documents:detail:read']); + const canUpdate = hasAnyPermission(['documents:update:write', 'documents:detail:read', 'documents:list:read']); - // 存储从 sessionStorage 获取的 documentTypeIds - const [documentTypeIds, setDocumentTypeIds] = useState(null); + // 文档列表现在优先走入口模块作用域,documentTypeIds 仅保留兼容旧首页缓存。 + const [listScope, setListScope] = useState({ selectedModuleId: null, documentTypeIds: [] }); + const [scopeReady, setScopeReady] = useState(false); // 添加页面加载状态管理 const [isLoadingData, setIsLoadingData] = useState(true); @@ -275,8 +282,40 @@ export default function DocumentsIndex() { const pageSize = parseInt(searchParams.get("pageSize") || "10", 10); + const readListScopeFromSession = useCallback((): DocumentListScope => { + if (typeof window === 'undefined') { + return { selectedModuleId: null, documentTypeIds: [] }; + } + + let selectedModuleId: number | null = null; + const selectedModuleIdStr = sessionStorage.getItem('selectedModuleId'); + if (selectedModuleIdStr) { + const parsedModuleId = Number(selectedModuleIdStr); + if (Number.isFinite(parsedModuleId) && parsedModuleId > 0) { + selectedModuleId = parsedModuleId; + } + } + + let documentTypeIds: number[] = []; + const typeIdsStr = sessionStorage.getItem('documentTypeIds'); + if (typeIdsStr) { + try { + const parsedTypeIds = JSON.parse(typeIdsStr); + if (Array.isArray(parsedTypeIds)) { + documentTypeIds = parsedTypeIds + .map((item) => Number(item)) + .filter((item) => Number.isFinite(item) && item > 0); + } + } catch (error) { + console.error('解析 sessionStorage.documentTypeIds 失败:', error); + } + } + + return { selectedModuleId, documentTypeIds }; + }, []); + // 客户端数据请求 - const fetchData = useCallback(async (typeIds: number[]) => { + const fetchData = useCallback(async (scope: DocumentListScope) => { setIsLoadingData(true); loadingBarService.show(); @@ -290,7 +329,12 @@ export default function DocumentsIndex() { return; } - console.log('🔑 [fetchData] 文档类型IDs:', typeIds); + const selectedTypeId = Number(documentType); + const scopedTypeIds = documentType && Number.isFinite(selectedTypeId) && selectedTypeId > 0 + ? [selectedTypeId] + : scope.documentTypeIds; + + console.log('🔑 [fetchData] 文档列表作用域:', scope); // 调用新的 API 函数 const result = await getDocumentsListFromAPI({ @@ -298,7 +342,8 @@ export default function DocumentsIndex() { pageSize: pageSize, name: search || undefined, documentNumber: documentNumber || undefined, - documentTypeIds: documentType ? [parseInt(documentType, 10)] : typeIds, // 如果有单独选择的类型,优先使用 + documentTypeIds: scopedTypeIds, + entryModuleId: scope.selectedModuleId || undefined, auditStatus: auditStatus || undefined, fileStatus: fileStatus || undefined, dateFrom: dateFrom || undefined, @@ -320,14 +365,26 @@ export default function DocumentsIndex() { setDocuments(result.data.documents); setTotal(result.data.total); - // 获取经过过滤的文档类型列表 - const filteredTypesResponse = await getDocumentTypesByIds(typeIds, jwtToken); + // 文档类型下拉与列表作用域保持一致:优先入口模块,其次兼容旧缓存 typeIds。 + const filteredTypesResponse = scope.selectedModuleId + ? await getDocumentTypes({ entry_module_id: scope.selectedModuleId, page: 1, pageSize: 200 }, jwtToken) + : await getDocumentTypesByIds(scope.documentTypeIds, jwtToken); + if (filteredTypesResponse.data?.types?.length) { + const typeNameMap = new Map( + filteredTypesResponse.data.types.map((type) => [String(type.id), type.name]) + ); const filteredOptions = filteredTypesResponse.data.types.map(type => ({ value: type.id, label: type.name })); setFilteredDocumentTypeOptions(filteredOptions); + setDocuments( + result.data.documents.map((doc) => ({ + ...doc, + typeName: typeNameMap.get(doc.type) || doc.typeName, + })) + ); } else { const fallbackOptions = Array.from( new Map( @@ -337,6 +394,7 @@ export default function DocumentsIndex() { ).values() ); setFilteredDocumentTypeOptions(fallbackOptions); + setDocuments(result.data.documents); } } catch (error) { @@ -349,41 +407,27 @@ export default function DocumentsIndex() { } }, [search, documentNumber, documentType, auditStatus, fileStatus, dateFrom, dateTo, currentPage, pageSize, loaderData.frontendJWT]); - // 在组件挂载时从 sessionStorage 获取 documentTypeIds 并加载数据 + // 在组件挂载时建立页面作用域并加载数据。 useEffect(() => { try { - if (typeof window !== 'undefined') { - const typeIdsStr = sessionStorage.getItem('documentTypeIds'); - if (typeIdsStr) { - const typeIds = JSON.parse(typeIdsStr) as number[]; - console.log('📋 [useEffect] 从 sessionStorage 获取文档类型IDs:', typeIds); - setDocumentTypeIds(typeIds); - - // 加载数据(fetchData 中会自动获取并设置过滤后的文档类型选项) - fetchData(typeIds); - } else { - console.warn('⚠️ [useEffect] sessionStorage 中没有 documentTypeIds'); - // 没有 documentTypeIds 时,标记初始化完成但无数据 - setIsLoadingData(false); - setHasInitialized(true); - loadingBarService.hide(); - } - } + const nextScope = readListScopeFromSession(); + setListScope(nextScope); + setScopeReady(true); } catch (error) { - console.error('❌ [useEffect] 获取 sessionStorage 中的 documentTypeIds 失败:', error); + console.error('❌ [useEffect] 初始化文档列表作用域失败:', error); // 出错时也标记初始化完成 setIsLoadingData(false); setHasInitialized(true); + setScopeReady(true); loadingBarService.hide(); } - }, [fetchData]); + }, [readListScopeFromSession]); // 监听 URL 参数变化,重新获取数据 useEffect(() => { - if (documentTypeIds) { - fetchData(documentTypeIds); - } - }, [searchParams, fetchData, documentTypeIds]); + if (!scopeReady) return; + fetchData(listScope); + }, [searchParams, fetchData, listScope, scopeReady]); // 监听 documents 数据变化,自动修正不一致的展开状态 useEffect(() => { @@ -439,15 +483,13 @@ export default function DocumentsIndex() { if (fetcher.data.result) { toastService.success(fetcher.data.message); // 删除成功后重新加载数据 - if (documentTypeIds) { - fetchData(documentTypeIds); - } + fetchData(listScope); } else if (fetcher.data.message) { toastService.error(fetcher.data.message); // 删除失败只显示错误信息,不刷新数据 } } - }, [fetcher.data, fetcher.state, fetchData, documentTypeIds, isDeleting]); + }, [fetcher.data, fetcher.state, fetchData, listScope, isDeleting]); // 分页处理函数 const handlePageChange = (page: number) => { @@ -921,9 +963,7 @@ export default function DocumentsIndex() { setSelectedDocumentVersion(null); // 刷新文档列表 - if (documentTypeIds && documentTypeIds.length > 0) { - fetchData(documentTypeIds); - } + fetchData(listScope); } catch (error) { console.error('【附件追加】上传失败:', error); @@ -993,9 +1033,7 @@ export default function DocumentsIndex() { setSelectedDocumentVersion(null); // 刷新文档列表 - if (documentTypeIds && documentTypeIds.length > 0) { - fetchData(documentTypeIds); - } + fetchData(listScope); } catch (error) { console.error('【合同模板上传】上传失败:', error); @@ -1178,7 +1216,7 @@ export default function DocumentsIndex() { className="text-xs px-2 py-1 h-7 mr-1 hover:underline" > - 查看 + 查看详情 )} {/* 修改按钮 - 需要 document:document:view 权限 */} @@ -1316,6 +1354,11 @@ export default function DocumentsIndex() { fileType={record.fileType} colorMode="light" /> + {record.groupName && ( + + 子类型:{record.groupName} + + )} {record.isTest && ( 测试 )} @@ -1379,14 +1422,14 @@ export default function DocumentsIndex() { width:"18%", render: (_: unknown, record: DocumentUI) => ( - 查看 + 查看详情 )} @@ -1547,9 +1590,7 @@ export default function DocumentsIndex() { type="primary" icon="ri-search-line" onClick={() => { - if (documentTypeIds) { - fetchData(documentTypeIds); - } + fetchData(listScope); }} className="mr-2" > @@ -1673,7 +1714,7 @@ export default function DocumentsIndex() { {documents.map((doc) => ( - <> + {/* 主文档行 */} {columns.map((col, index) => ( - {col.render ? col.render(null, doc, index) : (doc as any)[col.key]} + {col.render ? col.render(null, doc) : (doc as any)[col.key]} ))} @@ -1738,7 +1779,7 @@ export default function DocumentsIndex() { )} )} - + ))} diff --git a/app/routes/documents.tsx b/app/routes/documents.tsx index 863eec6..b7eba7e 100644 --- a/app/routes/documents.tsx +++ b/app/routes/documents.tsx @@ -1,22 +1,31 @@ import { Outlet } from "@remix-run/react"; -import { type MetaFunction } from "@remix-run/node"; +import { redirect, type LoaderFunctionArgs, type MetaFunction } from "@remix-run/node"; export const meta: MetaFunction = () => { - return [ - { title: "文档列表 - 中国烟草AI合同及卷宗审核系统" }, - { name: "documents", content: "文档列表,新增,修改" } - ] -} + return [ + { title: "文档列表 - 中国烟草AI合同及卷宗审核系统" }, + { name: "documents", content: "文档列表,新增,修改" } + ]; +}; export const handle = { - breadcrumb: "文档列表" + breadcrumb: "文档列表" +}; + +export async function loader({ request }: LoaderFunctionArgs) { + const url = new URL(request.url); + + if (url.pathname === '/documents') { + const query = url.searchParams.toString(); + throw redirect(query ? `/documents/list?${query}` : '/documents/list'); + } + + return null; } /** * 文档列表路由布局 */ export default function DocumentsLayout() { - return ( - - ) + return ; } diff --git a/app/routes/files.upload.tsx b/app/routes/files.upload.tsx index 2b3c3aa..3df8bbb 100644 --- a/app/routes/files.upload.tsx +++ b/app/routes/files.upload.tsx @@ -14,6 +14,7 @@ import { buildUploadErrorDetails, getTodayDocuments, getDocumentTypes, + getDocumentSubtypeGroups, getDocumentsStatus, uploadFileToBinary, uploadDocumentToServer, @@ -22,6 +23,7 @@ import { checkDocumentDuplicate, type Document, type DocumentType, + type DocumentSubtypeGroup, type UploadErrorDetails, type UploadResult, DocumentStatus @@ -29,7 +31,6 @@ import { import { updateDocumentAuditStatus } from "~/api/evaluation_points/rules-files"; import { links as fileTypeTagLinks } from "~/components/ui/FileTypeTag"; import { getQueueStatus, type QueueStatus } from "~/api/queue"; -import { CONTRACT_TYPES, DEFAULT_CONTRACT_TYPE } from "~/constants/contractTypes"; export function links() { return [ @@ -132,9 +133,11 @@ async function handleFileUpload( fileName: string, fileType: string, documentType: FileType, + groupId: number | null, priority: Priority, region: string, createdBy?: number, + attachments?: File[], jwtToken?: string, ): Promise { const speed = priority === Priority.NORMAL ? "normal" : "urgent"; @@ -144,8 +147,10 @@ async function handleFileUpload( fileName, fileType, Number(documentType), + groupId, region, createdBy, + attachments, true, speed, jwtToken, @@ -331,6 +336,7 @@ export default function FilesUpload() { // 获取 sessionStorage 中的 documentTypeIds 值 // eslint-disable-next-line @typescript-eslint/no-unused-vars const [documentTypeIds, setDocumentTypeIds] = useState(null); + const [selectedModuleId, setSelectedModuleId] = useState(null); // 使用 useLoaderData 获取初始数据 const loaderData = useLoaderData(); @@ -344,7 +350,9 @@ export default function FilesUpload() { const [documentNumber, setDocumentNumber] = useState(""); const [remark, setRemark] = useState(""); const [currentFiles, setCurrentFiles] = useState([]); - const [attributeType, setAttributeType] = useState(DEFAULT_CONTRACT_TYPE); + const [subtypeGroups, setSubtypeGroups] = useState([]); + const [selectedGroupId, setSelectedGroupId] = useState(""); + const [groupOptionsLoading, setGroupOptionsLoading] = useState(false); // 合同文件上传状态 // 这些变量暂时未使用,但保留以备将来扩展 @@ -393,6 +401,16 @@ export default function FilesUpload() { loaderData.documentTypes.find(type => type.id.toString() === fileType) || null; + const getSubtypeDisplayName = (group: DocumentSubtypeGroup | null | undefined) => + group?.displayName || group?.name || ""; + + const selectedSubtypeGroup = subtypeGroups.find(group => String(group.id) === selectedGroupId) || null; + const hasMultipleSubtypeGroups = subtypeGroups.length > 1; + const singleSubtypeGroup = subtypeGroups.length === 1 ? subtypeGroups[0] : null; + const isSingleDefaultSubtype = !!(singleSubtypeGroup && singleSubtypeGroup.isDefault); + const selectedRootGroupName = selectedSubtypeGroup?.rootGroupName || singleSubtypeGroup?.rootGroupName || ""; + const selectedEntryModuleName = selectedSubtypeGroup?.entryModuleName || singleSubtypeGroup?.entryModuleName || ""; + const clearUploadErrorDetails = () => { setUploadErrorDetails(null); }; @@ -444,6 +462,8 @@ export default function FilesUpload() { ? nextSelectedModuleId : null; + setSelectedModuleId(normalizedModuleId); + if (cancelled) { return; } @@ -842,6 +862,51 @@ export default function FilesUpload() { setFileTypeError("上传文件之前请选择文件类型"); } }; + + useEffect(() => { + let cancelled = false; + + const loadSubtypeGroups = async () => { + if (!fileType) { + setSubtypeGroups([]); + setSelectedGroupId(""); + return; + } + + setGroupOptionsLoading(true); + try { + const response = await getDocumentSubtypeGroups( + Number(fileType), + loaderData.frontendJWT || undefined, + selectedModuleId, + ); + if (cancelled) return; + if ("error" in response || !response.data) { + setSubtypeGroups([]); + setSelectedGroupId(""); + return; + } + + const groups = response.data; + setSubtypeGroups(groups); + setSelectedGroupId((currentValue) => { + if (groups.some((item) => String(item.id) === currentValue)) { + return currentValue; + } + return groups.length === 1 ? String(groups[0].id) : ""; + }); + } finally { + if (!cancelled) { + setGroupOptionsLoading(false); + } + } + }; + + void loadSubtypeGroups(); + return () => { + cancelled = true; + }; + }, [fileType, loaderData.frontendJWT, selectedModuleId]); // 处理合同主文件选择 const handleContractMainFilesSelected = (files: FileList) => { @@ -1260,8 +1325,8 @@ export default function FilesUpload() { const createdBy = loaderData.userInfo?.user_id as number | undefined; const uploadResp = await handleFileUpload( binaryData, mainFile.name, mainFile.type, - fileType as FileType, priority, - region, createdBy, loaderData.frontendJWT || undefined, + fileType as FileType, selectedGroupId ? Number(selectedGroupId) : null, priority, + region, createdBy, attachmentFiles, loaderData.frontendJWT || undefined, ); if (!uploadResp.success) { @@ -1350,6 +1415,10 @@ export default function FilesUpload() { toastService.error('请先选择文件类型'); return; } + if (subtypeGroups.length > 1 && !selectedGroupId) { + toastService.error('请先选择子类型后再上传'); + return; + } // 检查是否为合同类型 const selectedType = loaderData.documentTypes.find(t => t.id.toString() === fileType); @@ -1596,8 +1665,8 @@ export default function FilesUpload() { const createdBy = loaderData.userInfo?.user_id as number | undefined; const uploadPromise = handleFileUpload( binaryData, file.name, file.type, - fileType as FileType, priority, - region, createdBy, loaderData.frontendJWT || undefined, + fileType as FileType, selectedGroupId ? Number(selectedGroupId) : null, priority, + region, createdBy, undefined, loaderData.frontendJWT || undefined, ); const timeoutPromise = new Promise((_, reject) => { @@ -2362,27 +2431,66 @@ export default function FilesUpload() {
优先级影响文档在队列中的处理顺序
- {/* 子类型(专属类型)- 始终显示 */} + {/* 子类型(二级分组) */}
-
选择文档专属类型以应用对应的审核规则(合同大类请选择技术/租赁/买卖等)
+
+ {!fileType + ? "请先选择文件类型,再确定本次上传实际命中的子类型。" + : groupOptionsLoading + ? "正在加载当前文档类型下可用的子类型配置。" + : subtypeGroups.length === 0 + ? "当前文档类型在当前入口下还没有可用子类型,请先到评查点分组管理补齐“一级分组 / 二级分组 / 规则集”绑定。" + : hasMultipleSubtypeGroups + ? "同一文档类型在当前入口下已拆分多个子类型,请选择本次上传实际命中的子类型。" + : isSingleDefaultSubtype + ? "当前文档类型在当前入口下尚未拆分业务子类型,系统将按默认子类型处理;后续可在评查点分组管理中继续细分。" + : "当前文档类型在当前入口下仅配置了一个子类型,系统会自动带出该子类型。"} +
+ {selectedRootGroupName ? ( +
+ 所属一级分组:{selectedRootGroupName} + {selectedEntryModuleName ? ` · 入口模块:${selectedEntryModuleName}` : ""} +
+ ) : null} + {selectedSubtypeGroup ? ( +
+ 当前命中:{getSubtypeDisplayName(selectedSubtypeGroup)} + {selectedSubtypeGroup.displayHint ? ` · ${selectedSubtypeGroup.displayHint}` : ""} +
+ ) : null}
@@ -2783,7 +2891,7 @@ export default function FilesUpload() { 评查成功
-

文件已成功上传并评查完成,请查看结果

+

文件已成功上传并评查完成,请查看详情。

{/*
@@ -2792,7 +2900,7 @@ export default function FilesUpload() { icon="ri-file-search-line" > - 查看详情并审核 + 查看详情
*/} diff --git a/app/routes/home.tsx b/app/routes/home.tsx index a88387e..882e7ad 100644 --- a/app/routes/home.tsx +++ b/app/routes/home.tsx @@ -391,7 +391,7 @@ export default function Home() { - + */} diff --git a/app/routes/reviews.tsx b/app/routes/reviews.tsx index 273ad14..ad2beb1 100644 --- a/app/routes/reviews.tsx +++ b/app/routes/reviews.tsx @@ -844,7 +844,7 @@ export default function ReviewDetails() { // 构建自定义面包屑项 const getBreadcrumbItems = () => { const items = [ - { title: "评查详情", to: `/reviews?id=${document?.id}` } + { title: "评查详情", to: `/reviewsTest?id=${document?.id}` } ]; // 添加前置路由 diff --git a/app/routes/rule-groups._index.tsx b/app/routes/rule-groups._index.tsx index daf27cf..99663ae 100644 --- a/app/routes/rule-groups._index.tsx +++ b/app/routes/rule-groups._index.tsx @@ -1,26 +1,21 @@ -import { type MetaFunction } from "@remix-run/node"; -import { useLoaderData, Link, useNavigate, useSearchParams } from "@remix-run/react"; -import { useState, useEffect } from "react"; +import { type LoaderFunctionArgs, type MetaFunction } from "@remix-run/node"; +import { useLoaderData, useSearchParams } from "@remix-run/react"; +import { Fragment, useEffect, useMemo, useRef, useState } from "react"; +import axios from "axios"; + import indexStyles from "~/styles/pages/rule-groups_index.css?url"; -import { Card } from "~/components/ui/Card"; -import { Button } from "~/components/ui/Button"; -import { StatusDot } from "~/components/ui/StatusDot"; -import { Table } from "~/components/ui/Table"; -import { FilterPanel, FilterSelect, SearchFilter } from "~/components/ui/FilterPanel"; -// import { Pagination } from "~/components/ui/Pagination"; import { - getEvaluationPointGroups, - getChildGroups, - type RuleGroup, - type DocTypeInfo, - deleteEvaluationPointGroup, - rebindEvaluationPointGroup, - batchUpdateEvaluationPointGroupStatus, - batchDeleteEvaluationPointGroups -} from "~/api/evaluation_points/rule-groups"; -import { RebindModal } from "~/components/rule-groups/RebindModal"; -import { toastService, messageService } from "~/components/ui"; -import { usePermission } from "~/hooks/usePermission"; + getDocumentTypes, + getEntryModules, + getRuleSets, + type DocumentType, + type EntryModuleOption, + type RuleSetOption, +} from "~/api/document-types/document-types"; +import { Button } from "~/components/ui/Button"; +import { Card } from "~/components/ui/Card"; +import { API_BASE_URL } from "~/config/api-config"; +import { parseRuleSummariesFromYaml, type RuleSummary } from "~/utils/rule-yaml-parser"; export function links() { return [{ rel: "stylesheet", href: indexStyles }]; @@ -28,965 +23,1140 @@ export function links() { export const meta: MetaFunction = () => { return [ - { title: "评查点分组 - 中国烟草AI合同及卷宗审核系统" }, - { name: "description", content: "管理评查点分组,包括创建、编辑和删除分组" }, + { title: "评查点分组管理 - 中国烟草AI合同及卷宗审核系统" }, + { name: "description", content: "维护业务大类、具体业务类型与规则集之间的运行绑定关系。" }, ]; }; -export async function loader({ request }: { request: Request }) { - try { - // 获取用户会话信息 - const { getUserSession } = await import("~/api/login/auth.server"); - const { frontendJWT } = await getUserSession(request); +interface BindingItem { + id: number; + group_id: number; + rule_set_id: number; + rule_type_binding_id?: number | null; + priority: number; + is_active: boolean; + note?: string | null; + rule_type?: string | null; + rule_name?: string | null; + current_version_id?: number | null; + fallback_version_id?: number | null; + has_usable_version: boolean; + usable_rule_count: number; +} - // 🆕 解析URL查询参数(服务端筛选和分页) - const url = new URL(request.url); - const name = url.searchParams.get('name') || undefined; - const code = url.searchParams.get('code') || undefined; - const is_enabled = url.searchParams.get('is_enabled'); - const page = parseInt(url.searchParams.get('page') || '1'); - const pageSize = parseInt(url.searchParams.get('pageSize') || '50'); +interface RuleGroupNode { + id: number; + pid: number; + name: string; + code: string; + description?: string | null; + document_type_id?: number | null; + document_type_name?: string | null; + entry_module_id?: number | null; + entry_module_name?: string | null; + sort_order: number; + is_enabled: boolean; + created_at?: string | null; + updated_at?: string | null; + rule_count?: number | null; + bindings: BindingItem[]; + children?: RuleGroupNode[] | null; +} - // 🆕 调用 FastAPI v3 的 getEvaluationPointGroups API - const response = await getEvaluationPointGroups({ - name, - code, - is_enabled: is_enabled ? is_enabled === 'true' : undefined, - pid: null, // 仅获取一级分组 - page, - pageSize, - token: frontendJWT - }, frontendJWT); +interface LoaderData { + groups: RuleGroupNode[]; + docTypes: DocumentType[]; + entryModules: EntryModuleOption[]; + ruleSets: RuleSetOption[]; + frontendJWT?: string | null; +} - if (response.error) { - throw new Error(response.error); - } +interface GroupFormState { + id?: number; + mode: "create" | "edit"; + pid: number; + name: string; + code: string; + description: string; + documentTypeId: string; + entryModuleId: string; + sortOrder: number; + isEnabled: boolean; +} - return Response.json({ - groups: response.data || [], - totalCount: ('totalCount' in response) ? (response.totalCount || 0) : 0, - page, - pageSize, - frontendJWT - }); - } catch (error) { - console.error('加载评查点分组失败:', error); - return Response.json({ - error: error instanceof Error ? error.message : String(error), - groups: [], - totalCount: 0, - page: 1, - pageSize: 50 +interface BindingFormState { + groupId: number; + bindingId?: number; + mode: "create" | "edit"; + ruleSetId: string; + priority: number; + note: string; + isActive: boolean; +} + +type ChildGroupStats = { + siblingCount: number; + siblingIndex: number; +}; + +type ChildGroupHealth = "ready" | "partial" | "empty"; +type RootGroupHealth = "ready" | "partial"; + +function authHeaders(token?: string | null): Record { + const headers: Record = {}; + if (token) headers.Authorization = `Bearer ${token}`; + return headers; +} + +async function fetchGroupTree(token?: string | null): Promise { + const response = await axios.get(`${API_BASE_URL}/api/v3/evaluation-point-groups/all`, { + headers: authHeaders(token), + params: { include_disabled: true, with_rule_count: true }, + }); + const payload = response?.data?.data ?? response?.data ?? []; + return Array.isArray(payload) ? payload : []; +} + +export async function loader({ request }: LoaderFunctionArgs) { + const { getUserSession } = await import("~/api/login/auth.server"); + const { frontendJWT } = await getUserSession(request); + + const [groups, docTypesRes, entryModulesRes, ruleSetsRes] = await Promise.all([ + fetchGroupTree(frontendJWT), + getDocumentTypes({ page: 1, pageSize: 500 }, frontendJWT), + getEntryModules(frontendJWT), + getRuleSets(frontendJWT), + ]); + + return Response.json({ + groups, + docTypes: docTypesRes.data?.types || [], + entryModules: entryModulesRes.data || [], + ruleSets: ruleSetsRes.data || [], + frontendJWT, + } satisfies LoaderData); +} + +function formatVersionLabel(binding: BindingItem): string { + if (binding.current_version_id) return `当前 #${binding.current_version_id}`; + if (binding.fallback_version_id) return `回退 #${binding.fallback_version_id}`; + return "未配置"; +} + +function formatDateTime(value?: string | null): string { + if (!value) return "-"; + const normalized = value.replace("T", " ").replace(/\.\d+/, ""); + return normalized.slice(0, 19); +} + +function toTopGroups(groups: RuleGroupNode[]): RuleGroupNode[] { + return [...groups].sort((a, b) => (a.sort_order - b.sort_order) || (a.id - b.id)); +} + +function buildChildGroupStats(topGroups: RuleGroupNode[]): Record { + const stats: Record = {}; + for (const root of topGroups) { + const sorted = [...(root.children || [])].sort((a, b) => (a.sort_order - b.sort_order) || (a.id - b.id)); + sorted.forEach((item, index) => { + stats[item.id] = { siblingCount: sorted.length, siblingIndex: index + 1 }; }); } + return stats; +} + +function getChildGroupHealth(group: RuleGroupNode): ChildGroupHealth { + if (!group.bindings.length) return "empty"; + const readyCount = group.bindings.filter( + (item) => item.is_active && item.has_usable_version && (item.usable_rule_count || 0) > 0, + ).length; + return readyCount === group.bindings.length ? "ready" : "partial"; +} + +function getChildGroupUsableRuleCount(group: RuleGroupNode): number { + return group.bindings.reduce((sum, item) => sum + (item.usable_rule_count || 0), 0); +} + +function getChildGroupReadyBindingCount(group: RuleGroupNode): number { + return group.bindings.filter( + (item) => item.is_active && item.has_usable_version && (item.usable_rule_count || 0) > 0, + ).length; +} + +function getSubtypeGroupDisplayName(group: RuleGroupNode): string { + const name = (group.name || "").trim(); + const code = (group.code || "").trim(); + if (name === "通用" || code.endsWith(".default")) { + return `默认子类型(${name || "通用"})`; + } + return group.name; +} + +function getRootGroupMode(group: RuleGroupNode): "category" | "legacy-doc-type-root" { + return group.document_type_id ? "legacy-doc-type-root" : "category"; +} + +function getRootGroupSubtitle(group: RuleGroupNode): string { + return getRootGroupMode(group) === "category" ? "一级分组 · 业务大类" : "一级分组 · 兼容中的具体类型"; +} + +function getRootGroupDescription(group: RuleGroupNode): string { + if (getRootGroupMode(group) === "category") { + return "用于承接某个入口模块下的业务大类,再向下拆分具体业务类型。"; + } + return "这是历史过渡数据:当前一级仍直接挂了具体文档类型,后续应迁入某个业务大类下。"; +} + +function getRootGroupHealth(group: RuleGroupNode): RootGroupHealth { + if (!group.entry_module_id) return "partial"; + const children = group.children || []; + if (!children.length) return "partial"; + return children.every((child) => getChildGroupHealth(child) === "ready") ? "ready" : "partial"; +} + +function getRootGroupStatusText(group: RuleGroupNode): string { + if (!group.entry_module_id) return "待绑定入口模块"; + if (!(group.children || []).length) return "待补充二级分组"; + return getRootGroupHealth(group) === "ready" ? "运行链路已就绪" : "存在待整理配置"; +} + +function getRootGroupWarningText(group: RuleGroupNode): string | null { + if (!group.entry_module_id) { + return "当前一级分组还未绑定入口模块,暂时不会在上传入口形成完整链路。"; + } + if (!(group.children || []).length) { + return "当前一级分组下还没有二级分组,无法承接具体业务类型。"; + } + const pendingChildren = (group.children || []).filter((child) => getChildGroupHealth(child) !== "ready").length; + if (pendingChildren > 0) { + return `当前一级分组下有 ${pendingChildren} 个二级分组仍需整理规则集。`; + } + return null; +} + +function getChildGroupStatusText(group: RuleGroupNode): string { + const health = getChildGroupHealth(group); + if (health === "ready") return "规则集已就绪"; + if (health === "partial") return "存在待整理规则集"; + return "尚未绑定规则集"; +} + +function getChildGroupWarningText(group: RuleGroupNode): string | null { + const health = getChildGroupHealth(group); + if (health === "empty") { + return "当前子类型还没有绑定任何规则集,上传后无法进入评查。"; + } + if (health === "partial") { + const inactiveCount = group.bindings.filter((item) => !item.is_active).length; + const unpublishedCount = group.bindings.filter((item) => item.is_active && !item.has_usable_version).length; + const zeroRuleCount = group.bindings.filter( + (item) => item.is_active && item.has_usable_version && (item.usable_rule_count || 0) <= 0, + ).length; + const parts: string[] = []; + if (inactiveCount > 0) parts.push(`${inactiveCount} 个绑定已停用`); + if (unpublishedCount > 0) parts.push(`${unpublishedCount} 个绑定待发布`); + if (zeroRuleCount > 0) parts.push(`${zeroRuleCount} 个绑定可用规则数为 0`); + return `当前子类型存在异常配置:${parts.join(",")}。`; + } + return null; +} + +function getBindingStatusText(binding: BindingItem): string { + if (!binding.is_active) return "已停用"; + if (!binding.has_usable_version) return "待发布"; + if ((binding.usable_rule_count || 0) <= 0) return "可用规则数为 0"; + return "可运行"; +} + +function getBindingStatusClass(binding: BindingItem): "ok" | "warn" { + return binding.is_active && binding.has_usable_version && (binding.usable_rule_count || 0) > 0 ? "ok" : "warn"; +} + +type RulePreviewState = { + loading: boolean; + loaded: boolean; + error: string | null; + rules: RuleSummary[]; +}; + +function GroupModal({ + visible, + form, + topGroups, + docTypes, + entryModules, + onClose, + onChange, + onSubmit, + saving, +}: { + visible: boolean; + form: GroupFormState; + topGroups: RuleGroupNode[]; + docTypes: DocumentType[]; + entryModules: EntryModuleOption[]; + onClose: () => void; + onChange: (patch: Partial) => void; + onSubmit: () => void; + saving: boolean; +}) { + if (!visible) return null; + const isRoot = form.pid === 0; + const selectedParent = !isRoot ? topGroups.find((group) => group.id === form.pid) || null : null; + const selectedRootDocTypeId = selectedParent?.document_type_id ? String(selectedParent.document_type_id) : ""; + const docTypeValue = isRoot ? form.documentTypeId : (selectedRootDocTypeId || form.documentTypeId); + const entryModuleValue = isRoot ? form.entryModuleId : ""; + return ( +
+
+
+

{form.mode === "create" ? (isRoot ? "新增一级分组" : "新增二级分组") : "编辑分组"}

+ +
+
+
+ + +
+
+ + onChange({ name: event.target.value })} placeholder={isRoot ? "如:合同、卷宗、内部公文" : "如:建设工程合同、处罚-一般程序"} /> +
+
+ + onChange({ code: event.target.value })} placeholder="请输入唯一编码" /> +
+
+ + onChange({ sortOrder: Number(event.target.value || 0) })} /> +
+
+ + +

+ {isRoot + ? "一级分组承载业务大类,可先创建再补绑入口模块;例如合同、卷宗、后续新增业务。" + : "二级分组通常继承上级入口模块,用来承接该大类下的具体业务类型。"} +

+
+
+ + +

+ {isRoot + ? "一级分组默认代表业务大类容器;这个字段只用于兼容旧数据,新建业务大类通常可留空。" + : "二级分组对应实际业务类型,如建设工程合同、处罚-一般程序、许可-停业办理,再往下绑定运行规则集。"} +

+
+
+ + -
详细描述有助于其他用户了解该分组的用途
-
- - {/* 状态 */} -
- - -
禁用状态的分组及其下的评查点将不会参与评查
-
- - {/* 排序 */} -
- - -
用于设置分组在列表中的显示顺序,默认为0
-
-
- - -
+
+

旧分组编辑入口已下线

+

+ `/rule-groups/new` 原来对应的是老评查点分组维护语义,当前系统已改为新规则导航视图, + 不再在这里维护老分组树。 +

+
+
+

现在请改用以下入口:

+

1. `规则导航`:查看 入口模块 → 文档类型 → 规则集 / 版本

+

2. `文档类型管理`:调整文档类型与规则集绑定关系

+

3. `规则管理`:维护规则集版本与 YAML 内容

+
+
+ + + + + + +
+
+ ); -} \ No newline at end of file +} diff --git a/app/routes/rule-groups.tsx b/app/routes/rule-groups.tsx index a387191..b448d3d 100644 --- a/app/routes/rule-groups.tsx +++ b/app/routes/rule-groups.tsx @@ -1,12 +1,12 @@ import { Outlet } from "@remix-run/react"; /** - * 评查点分组管理 - 父级路由 + * 规则导航 - 父级路由 * 仅作为嵌套路由的容器,不包含具体内容 */ export const handle = { - breadcrumb: "评查点分组" + breadcrumb: "规则导航" } export default function RuleGroups() { return ; -} \ No newline at end of file +} diff --git a/app/routes/rules.list.tsx b/app/routes/rules.list.tsx index ba6bc95..ffb233c 100644 --- a/app/routes/rules.list.tsx +++ b/app/routes/rules.list.tsx @@ -349,6 +349,8 @@ export default function RulesIndex() { const [filteredTotalCount, setFilteredTotalCount] = useState(initialTotalCount); const [ruleTypes, setRuleTypes] = useState(initialRuleTypes); const [attributeTypes, setAttributeTypes] = useState>([]); + const [selectedModuleName, setSelectedModuleName] = useState(''); + const [moduleScopeReady, setModuleScopeReady] = useState(false); // 添加一个状态来跟踪是否执行了删除操作 const [isDeleting, setIsDeleting] = useState(false); @@ -398,7 +400,7 @@ 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' + const currentModuleName = typeof window !== 'undefined' ? sessionStorage.getItem('selectedModuleName') || '' : ''; @@ -456,7 +458,7 @@ export default function RulesIndex() { let effectiveAttributeType = searchParams.get('documentAttributeType') || undefined; const presetScopedAttributeTypes = resolveScopedSubtypeOptions({ documentTypeIds, - selectedModuleName, + selectedModuleName: currentModuleName, ruleTypeName: ruleTypeNameParam, apiOptions: apiAttributeTypes, ruleOptions: [] @@ -497,7 +499,7 @@ export default function RulesIndex() { ).map(value => ({ code: value, label: value })); const scopedAttributeTypes = resolveScopedSubtypeOptions({ documentTypeIds, - selectedModuleName, + selectedModuleName: currentModuleName, ruleTypeName: ruleTypeNameParam, apiOptions: apiAttributeTypes, ruleOptions: ruleAttributeTypes @@ -656,6 +658,8 @@ export default function RulesIndex() { const typeIdsStr = sessionStorage.getItem('documentTypeIds'); const documentTypeIds = typeIdsStr ? JSON.parse(typeIdsStr) : null; console.log("📋 组件挂载,从 sessionStorage 获取 documentTypeIds:", documentTypeIds); + setSelectedModuleName(sessionStorage.getItem('selectedModuleName') || ''); + setModuleScopeReady(true); // 如果有 documentTypeIds,加载数据 if (documentTypeIds && documentTypeIds.length > 0) { @@ -667,6 +671,7 @@ export default function RulesIndex() { } } catch (error) { console.error('获取 sessionStorage 中的 documentTypeIds 失败:', error); + setModuleScopeReady(true); } }, [initialLoad, fetchData]); @@ -1118,7 +1123,14 @@ export default function RulesIndex() { {/* 页面头部 */}
-

评查点管理

+
+

评查点管理

+

+ {selectedModuleName + ? `当前仅展示“${selectedModuleName}”入口关联文档类型的规则点。` + : '请先从首页进入具体入口模块,再查看该入口对应的规则点。'} +

+
{loading ? (
@@ -1171,6 +1183,20 @@ export default function RulesIndex() { )}
+ + {moduleScopeReady && !selectedModuleName && ( + +
+ +
+
当前未绑定业务入口上下文
+
+ 评查点列表按入口模块显示对应文档类型规则。请先从首页进入具体入口,再查看这里。 +
+
+
+
+ )} {/* 筛选区域 */} { return [ @@ -280,28 +280,6 @@ export default function RuleNew() { setInstanceKey(`new_${Date.now()}`); }, []); - - /** - * 从API响应中提取数据 - * @param responseData - API响应数据 - * @returns 提取的数据或null - */ - function extractApiData(responseData: unknown): T | null { - if (!responseData) return null; - - // 格式1: { code: number, msg: string, data: T } - if (typeof responseData === 'object' && responseData !== null && - 'code' in responseData && - 'data' in responseData && - (responseData as { data: unknown }).data) { - return (responseData as { data: T }).data; - } - - // 格式2: 直接是数据对象 - return responseData as T; - } - - /** * 获取评查点数据 * 编辑模式下从API获取指定ID的评查点数据 @@ -386,32 +364,66 @@ export default function RuleNew() { */ const fetchEvaluationPointGroups = useCallback(async () => { try { - // console.log("🔍 [fetchEvaluationPointGroups] 开始获取评查点组数据"); - const response = await postgrestGet('/api/postgrest/proxy/evaluation_point_groups', { token: frontendJWT }); + const storedIds = typeof window !== 'undefined' ? sessionStorage.getItem('documentTypeIds') : null; + if (!storedIds) { + setEvaluationPointGroups([]); + return; + } - // console.log("🔍 [fetchEvaluationPointGroups] API响应:", response); + const parsedIds = JSON.parse(storedIds); + if (!Array.isArray(parsedIds) || parsedIds.length === 0) { + setEvaluationPointGroups([]); + return; + } - if (response.data) { - // 使用 extractApiData 提取数据(处理可能的包装格式) - const extractedData = extractApiData(response.data); - // console.log("🔍 [fetchEvaluationPointGroups] 提取后的数据:", extractedData); + const documentTypeIds = parsedIds + .map(item => Number(item)) + .filter(item => Number.isFinite(item) && item > 0); - if (extractedData && Array.isArray(extractedData) && extractedData.length > 0) { - setEvaluationPointGroups(extractedData); - // console.log(`✅ [fetchEvaluationPointGroups] 成功加载 ${extractedData.length} 个评查点组`); - } else { - console.warn("⚠️ [fetchEvaluationPointGroups] 提取的数据为空或格式不正确"); - setEvaluationPointGroups([]); - } - } else if (response.error) { + const response = await getEvaluationPointGroupsByDocumentTypes(documentTypeIds, frontendJWT); + if (response.error) { console.error('❌ [fetchEvaluationPointGroups] API返回错误:', response.error); setEvaluationPointGroups([]); + return; } + + const flattenGroups = (groups: ApiRuleGroupTree[]): EvaluationPointGroup[] => { + const items: EvaluationPointGroup[] = []; + groups.forEach(group => { + items.push({ + id: Number(group.id), + pid: group.pid === '0' ? 0 : Number(group.pid), + code: group.code || '', + name: group.name, + description: group.description, + is_enabled: group.is_enabled, + created_at: group.createdAt || '', + updated_at: group.createdAt || '' + }); + + if (group.children && group.children.length > 0) { + group.children.forEach(child => { + items.push({ + id: Number(child.id), + pid: child.pid === '0' ? 0 : Number(child.pid), + code: child.code || '', + name: child.name, + description: child.description, + is_enabled: child.is_enabled, + created_at: child.createdAt || '', + updated_at: child.createdAt || '' + }); + }); + } + }); + return items; + }; + + setEvaluationPointGroups(flattenGroups(response.data || [])); } catch (error) { console.error('❌ [fetchEvaluationPointGroups] 获取评查点组数据失败:', error); setEvaluationPointGroups([]); - // 显示错误提示但不影响应用继续使用 - toastService.error(`获取评查点组数据失败: ${error instanceof Error ? error.message : '未知错误'}\n将使用默认数据`); + toastService.error(`获取评查点组数据失败: ${error instanceof Error ? error.message : '未知错误'}`); } }, [frontendJWT]); diff --git a/app/routes/rules.sets.tsx b/app/routes/rules.sets.tsx new file mode 100644 index 0000000..b4d0b49 --- /dev/null +++ b/app/routes/rules.sets.tsx @@ -0,0 +1,25 @@ +import { redirect, type LoaderFunctionArgs, type MetaFunction } from "@remix-run/node"; + +export const meta: MetaFunction = () => { + return [ + { title: "规则管理 - 中国烟草AI合同及卷宗审核系统" }, + { + name: "rules-sets", + content: "兼容旧版规则管理入口,自动跳转到新版规则维护页" + } + ]; +}; + +export const handle = { + hideBreadcrumb: true, +}; + +export async function loader({ request }: LoaderFunctionArgs) { + const url = new URL(request.url); + const query = url.searchParams.toString(); + throw redirect(query ? `/rulesTest/list?${query}` : "/rulesTest/list"); +} + +export default function RulesSetsRedirect() { + return null; +} diff --git a/app/routes/rules.tsx b/app/routes/rules.tsx index b67aa6a..faad5d1 100644 --- a/app/routes/rules.tsx +++ b/app/routes/rules.tsx @@ -1,13 +1,12 @@ import { Outlet } from "@remix-run/react"; -import { type MetaFunction } from "@remix-run/node"; - +import { redirect, type LoaderFunctionArgs, type MetaFunction } from "@remix-run/node"; export const meta: MetaFunction = () => { return [ { title: "评查规则管理 - 中国烟草AI合同及卷宗审核系统" }, - { - name: "rules", - content: "评查规则管理模块,包括评查点列表、创建和编辑功能" + { + name: "rules", + content: "评查规则管理模块,包括评查点列表、创建和编辑功能" } ]; }; @@ -17,9 +16,20 @@ export const handle = { to: "/rulesTest/list" // 新版规则维护入口;旧版可从新版页面内返回 }; +export async function loader({ request }: LoaderFunctionArgs) { + const url = new URL(request.url); + + if (url.pathname === '/rules') { + const query = url.searchParams.toString(); + throw redirect(query ? `/rulesTest/list?${query}` : '/rulesTest/list'); + } + + return null; +} + /** * 规则管理路由布局 */ -export default function RulesLayout() { +export default function RulesLayout() { return ; } diff --git a/app/routes/rulesTest.detail.tsx b/app/routes/rulesTest.detail.tsx index 538230c..4c484ac 100644 --- a/app/routes/rulesTest.detail.tsx +++ b/app/routes/rulesTest.detail.tsx @@ -1,13 +1,16 @@ -import { type LoaderFunctionArgs, type MetaFunction } from '@remix-run/node'; -import { Link, useLoaderData } from '@remix-run/react'; +import { json, type ActionFunctionArgs, type LoaderFunctionArgs, type MetaFunction } from '@remix-run/node'; +import { Link, useFetcher, useLoaderData, useRevalidator } from '@remix-run/react'; import type React from 'react'; import { useEffect, useMemo, useRef, 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 { getUserSession } from '~/api/login/auth.server'; +import { API_BASE_URL } from '~/config/api-config'; +import { loadRuleConfigPack, loadRuleConfigPacks, loadRuleConfigVersions, type RuleVersionItem } from '~/utils/rules-config-packs.server'; +import { buildRuleYamlPreview, buildYamlPreview, collectDependencyOptions, getDefaultExpandedDependencyGroups, validateEditableRuleConfig, type DependencyOption, type EditableRuleConfig, type ValidationIssue } from '~/utils/rules-config-editor'; +import type { RuleSummary, RuleYamlPack } from '~/utils/rules-yaml-mock.server'; import styles from '~/styles/pages/rules_test.css?url'; export const links = () => [ @@ -21,6 +24,15 @@ export const meta: MetaFunction = () => [ type LoaderData = { pack: RuleYamlPack; requestedRuleId: string; + versions: RuleVersionItem[]; +}; + +type ActionData = { + success: boolean; + message: string; + intent: 'save' | 'publish' | 'rollback'; + versionId?: number; + versionNo?: string; }; type EditorState = { kind: 'rule'; mode: 'create' | 'edit'; id?: string } | null; @@ -304,18 +316,96 @@ 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]; + const packs = await loadRuleConfigPacks(request); + const pack = (packId ? await loadRuleConfigPack(request, packId) : undefined) || packs[0]; if (!pack) { throw new Response('未找到 YAML 配置', { status: 404 }); } - return Response.json({ pack, requestedRuleId } satisfies LoaderData); + const versions = await loadRuleConfigVersions(request, pack.metadata.typeId || ''); + + return Response.json({ pack, requestedRuleId, versions } satisfies LoaderData); +} + +export async function action({ request }: ActionFunctionArgs) { + const { frontendJWT, userInfo } = await getUserSession(request); + if (!frontendJWT) { + return json({ success: false, intent: 'save', message: '登录已失效,请重新登录后再保存。' }, { status: 401 }); + } + + const formData = await request.formData(); + const intent = String(formData.get('intent') || 'save').trim() as ActionData['intent']; + const ruleType = String(formData.get('ruleType') || '').trim(); + const yamlText = String(formData.get('yamlText') || ''); + const changeNote = String(formData.get('changeNote') || '').trim() || 'rulesTest.detail 保存评查点草稿'; + const versionId = Number(formData.get('versionId') || 0); + + if (!ruleType) { + return json({ success: false, intent, message: '当前规则类型缺失,无法保存。' }, { status: 400 }); + } + + if (intent === 'save' && !yamlText.trim()) { + return json({ success: false, intent, message: '当前 YAML 内容为空,无法保存。' }, { status: 400 }); + } + + if ((intent === 'publish' || intent === 'rollback') && (!Number.isFinite(versionId) || versionId <= 0)) { + return json({ success: false, intent, message: '目标版本缺失,无法继续操作。' }, { status: 400 }); + } + + try { + const apiPath = intent === 'save' + ? `/api/rule-sets/${encodeURIComponent(ruleType)}/versions` + : `/api/rule-sets/${encodeURIComponent(ruleType)}/${intent}`; + const requestBody = intent === 'save' + ? { + yamlText, + changeNote, + editorUserId: userInfo?.user_id ? Number(userInfo.user_id) : undefined, + } + : { + versionId, + operatorUserId: userInfo?.user_id ? Number(userInfo.user_id) : undefined, + }; + + const response = await fetch(`${API_BASE_URL}${apiPath}`, { + method: 'POST', + headers: { + Authorization: `Bearer ${frontendJWT}`, + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify(requestBody), + }); + + const payload = await response.json(); + const data = payload?.data ?? null; + const message = String(payload?.message || payload?.msg || (response.ok ? '规则草稿保存成功' : '规则草稿保存失败')); + + if (!response.ok || !data) { + return json({ success: false, intent, message }, { status: response.status || 500 }); + } + + return json({ + success: true, + intent, + message, + versionId: Number(data.id), + versionNo: String(data.versionNo || ''), + }); + } catch (error) { + return json({ + success: false, + intent, + message: error instanceof Error ? error.message : '规则草稿保存失败', + }, { status: 500 }); + } } export default function RulesTestDetail() { - const { pack, requestedRuleId } = useLoaderData() as LoaderData; + const { pack, requestedRuleId, versions } = useLoaderData() as LoaderData; + const saveFetcher = useFetcher(); + const revalidator = useRevalidator(); const initialRuleKey = requestedRuleId || ruleKey(pack.rules[0] || { id: '', ruleId: '' }); const [rules, setRules] = useState(pack.rules); const [selectedRuleKey, setSelectedRuleKey] = useState(initialRuleKey); @@ -328,6 +418,8 @@ export default function RulesTestDetail() { const [showValidation, setShowValidation] = useState(false); const [showYamlPreview, setShowYamlPreview] = useState(false); const [draftSaved, setDraftSaved] = useState(false); + const [saveMessage, setSaveMessage] = useState(''); + const [saveError, setSaveError] = useState(''); const promptEditorRef = useRef(null); useEffect(() => { @@ -341,6 +433,8 @@ export default function RulesTestDetail() { setShowValidation(false); setShowYamlPreview(false); setDraftSaved(false); + setSaveMessage(''); + setSaveError(''); }, [pack.id, requestedRuleId]); const currentRule = useMemo(() => { @@ -409,9 +503,53 @@ export default function RulesTestDetail() { }, [dialogDependencyOptions, dependencySelection]); const dependencyDialogEmptyText = dependencySearch.trim() ? '没有匹配的字段。' : '当前文档类型暂无可追加字段。'; const hasErrors = validationIssues.some(issue => issue.severity === 'error'); + const configValidationIssues = useMemo(() => validateEditableRuleConfig(editableConfig), [editableConfig]); + const hasConfigErrors = configValidationIssues.some(issue => issue.severity === 'error'); + const fullYamlText = useMemo(() => buildYamlPreview(editableConfig), [editableConfig]); 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 saveButtonBusy = saveFetcher.state !== 'idle'; + const latestDraftVersion = useMemo( + () => versions.find((item) => !['published', 'rollback'].includes(item.status)), + [versions], + ); + const rollbackTargetVersion = useMemo( + () => versions.find((item) => ['published', 'rollback'].includes(item.status) && item.id !== pack.currentVersionId), + [versions, pack.currentVersionId], + ); + const currentResolvedVersion = useMemo( + () => versions.find((item) => item.id === pack.currentVersionId || item.id === pack.fallbackVersionId) || null, + [pack.currentVersionId, pack.fallbackVersionId, versions], + ); + const versionStatusLabel = (status: string | undefined) => { + const normalized = String(status || '').trim().toLowerCase(); + if (normalized === 'published') return '已发布'; + if (normalized === 'rollback') return '回滚版本'; + if (normalized === 'draft') return '草稿'; + if (normalized === 'deprecated') return '已废弃'; + return status || '-'; + }; + + useEffect(() => { + if (!saveFetcher.data) return; + if (saveFetcher.data.success) { + setDraftSaved(saveFetcher.data.intent === 'save'); + setSaveError(''); + if (saveFetcher.data.intent === 'save') { + setSaveMessage(saveFetcher.data.versionNo + ? `规则草稿已保存为版本 ${saveFetcher.data.versionNo}` + : saveFetcher.data.message || '规则草稿已保存'); + } else { + setSaveMessage(saveFetcher.data.message || (saveFetcher.data.intent === 'publish' ? '规则版本已发布' : '规则版本已回滚')); + } + revalidator.revalidate(); + return; + } + setDraftSaved(false); + setSaveMessage(''); + setSaveError(saveFetcher.data.message || '规则草稿保存失败'); + }, [revalidator, saveFetcher.data]); const openRuleEditor = (rule?: RuleSummary) => { setRuleDraft(rule ? { ...rule } : emptyRuleDraft(ruleGroups[0])); @@ -493,10 +631,56 @@ export default function RulesTestDetail() { ? current.map(rule => rule.id === editor.id ? normalizedRule : rule) : [...current, normalizedRule]); setSelectedRuleKey(ruleKey(normalizedRule)); - setDraftSaved(true); + setDraftSaved(false); + setSaveMessage(''); + setSaveError(''); setEditor(null); }; + const saveDraftToServer = () => { + if (hasConfigErrors) { + setShowValidation(true); + setSaveError('当前规则配置仍有必改问题,请先处理后再保存。'); + setSaveMessage(''); + return; + } + + const formData = new FormData(); + formData.append('ruleType', pack.metadata.typeId || ''); + formData.append('yamlText', fullYamlText); + formData.append('changeNote', `保存 ${pack.documentType}/${pack.mainType}/${pack.subtype} 规则配置`); + formData.append('intent', 'save'); + saveFetcher.submit(formData, { method: 'post' }); + }; + + const publishDraftVersion = () => { + if (!latestDraftVersion) { + setSaveError('当前没有可发布的新版本,请先保存规则配置。'); + setSaveMessage(''); + return; + } + + const formData = new FormData(); + formData.append('intent', 'publish'); + formData.append('ruleType', pack.metadata.typeId || ''); + formData.append('versionId', String(latestDraftVersion.id)); + saveFetcher.submit(formData, { method: 'post' }); + }; + + const rollbackRuleVersion = () => { + if (!rollbackTargetVersion) { + setSaveError('当前没有可回滚的历史可用版本。'); + setSaveMessage(''); + return; + } + + const formData = new FormData(); + formData.append('intent', 'rollback'); + formData.append('ruleType', pack.metadata.typeId || ''); + formData.append('versionId', String(rollbackTargetVersion.id)); + saveFetcher.submit(formData, { method: 'post' }); + }; + const resetDraft = () => { setRules(pack.rules); setSelectedRuleKey(requestedRuleId || ruleKey(pack.rules[0] || { id: '', ruleId: '' })); @@ -506,6 +690,8 @@ export default function RulesTestDetail() { setShowValidation(false); setShowYamlPreview(false); setDraftSaved(false); + setSaveMessage(''); + setSaveError(''); }; const dependencyColumns = [ @@ -545,6 +731,9 @@ export default function RulesTestDetail() {
{pack.documentType} / {pack.mainType} / {pack.subtype} / {currentRule?.ruleId || '-'}
+
+ 当前版本:{currentResolvedVersion?.versionNo || '-'} / 当前状态:{versionStatusLabel(currentResolvedVersion?.status)} +
@@ -559,6 +748,16 @@ export default function RulesTestDetail() { + + + @@ -570,6 +769,18 @@ export default function RulesTestDetail() { 草稿已保存。
)} + {saveMessage && ( +
+ + {saveMessage} +
+ )} + {saveError && ( +
+ + {saveError} +
+ )} {showValidation && ( diff --git a/app/routes/rulesTest.list.tsx b/app/routes/rulesTest.list.tsx index 7f2e1a9..5cb4c95 100644 --- a/app/routes/rulesTest.list.tsx +++ b/app/routes/rulesTest.list.tsx @@ -7,7 +7,8 @@ import { FilterPanel, FilterSelect, SearchFilter } from '~/components/ui/FilterP import { Pagination } from '~/components/ui/Pagination'; import { Table } from '~/components/ui/Table'; import { Tag, type TagColor } from '~/components/ui/Tag'; -import { loadRuleYamlPacks, type RuleSummary, type RuleYamlPack } from '~/utils/rules-yaml-mock.server'; +import { loadRuleConfigPacks } from '~/utils/rules-config-packs.server'; +import type { RuleSummary, RuleYamlPack } from '~/utils/rules-yaml-mock.server'; import styles from '~/styles/pages/rules_test.css?url'; export const links = () => [ @@ -84,7 +85,7 @@ export async function loader({ request }: LoaderFunctionArgs) { pageSize: [10, 20, 30, 50].includes(requestedPageSize) ? requestedPageSize : 10 }; - const packs = await loadRuleYamlPacks(); + const packs = await loadRuleConfigPacks(request); const documentTypes = unique(packs.map(pack => pack.documentType)); const inferredDocumentType = packs.find(pack => pack.mainType === requestedFilters.mainType)?.documentType || ''; const currentDocumentType = documentTypes.includes(requestedFilters.documentType) diff --git a/app/styles/pages/document-types_new.css b/app/styles/pages/document-types_new.css index 4c0b77a..5d3db43 100644 --- a/app/styles/pages/document-types_new.css +++ b/app/styles/pages/document-types_new.css @@ -270,6 +270,47 @@ @apply mt-1 block text-xs leading-5 text-amber-700; } +.document-type-new-page .readonly-guide-card { + @apply mt-4 flex flex-col gap-3 rounded-lg border border-gray-200 bg-white px-4 py-3 md:flex-row md:items-center md:justify-between; +} + +.document-type-new-page .readonly-guide-card strong { + @apply block text-sm font-semibold text-gray-900; +} + +.document-type-new-page .readonly-guide-card span { + @apply mt-1 block text-xs leading-5 text-gray-500; +} + +.document-type-new-page .selected-rule-sets-panel { + @apply mt-5 rounded-lg border border-gray-200 bg-gray-50 p-4; +} + +.document-type-new-page .selected-panel-header strong { + @apply block text-sm font-semibold text-gray-900; +} + +.document-type-new-page .selected-panel-header span { + @apply mt-1 block text-xs leading-5 text-gray-500; +} + +.document-type-new-page .selected-rule-grid { + @apply mt-4 grid gap-3; +} + +.document-type-new-page .selected-rule-card { + @apply flex items-start justify-between gap-3 rounded-lg border border-gray-200 bg-white px-4 py-3; +} + +.document-type-new-page .selected-rule-main strong { + @apply block text-sm font-semibold text-gray-900; +} + +.document-type-new-page .selected-rule-main span, +.document-type-new-page .selected-rule-meta { + @apply mt-1 block text-xs leading-5 text-gray-500; +} + .document-type-new-page .rule-set-checklist { @apply mt-5 grid max-h-[520px] gap-3 overflow-y-auto pr-1; } @@ -310,6 +351,10 @@ accent-color: #00684a; } +.document-type-new-page .rule-set-checkbox-wrap input:disabled { + @apply cursor-not-allowed opacity-60; +} + .document-type-new-page .rule-set-content { @apply min-w-0 flex-1; } diff --git a/app/styles/pages/rule-groups_index.css b/app/styles/pages/rule-groups_index.css index 4c682ed..d874634 100644 --- a/app/styles/pages/rule-groups_index.css +++ b/app/styles/pages/rule-groups_index.css @@ -1,272 +1,738 @@ -/* app/styles/pages/rule-groups_index.css */ -/* 使用命名空间限制样式作用范围,避免覆盖全局样式 */ -.rule-groups-page a.badge.bg-primary.text-white { - color: white; - text-decoration: none; - transition: color 0.2s; +.rule-groups-page { + padding: 24px; + min-height: 100vh; + background: transparent; } -.rule-groups-page a.badge.bg-primary.text-white:hover { - color: white; - opacity: 0.9; +.rule-groups-page .page-header, +.rule-groups-page .summary-metrics, +.rule-groups-page .logic-strip, +.rule-groups-page .filter-row, +.rule-groups-page .rg-modal-header, +.rule-groups-page .rg-modal-footer, +.rule-groups-page .preview-header-row, +.rule-groups-page .action-links, +.rule-groups-page .switch-row, +.rule-groups-page .binding-card-header, +.rule-groups-page .binding-card-actions, +.rule-groups-page .detail-card-header, +.rule-groups-page .selected-group-overview { + display: flex; + align-items: center; +} + +.rule-groups-page .page-header, +.rule-groups-page .rg-modal-header, +.rule-groups-page .rg-modal-footer, +.rule-groups-page .preview-header-row { + justify-content: space-between; +} + +.rule-groups-page .page-header { + margin-bottom: 12px; + gap: 12px; +} + +.rule-groups-page .page-title { + margin: 0; + font-size: 22px; + font-weight: 500; + color: #111827; + display: flex; + align-items: center; + gap: 10px; + line-height: 1.2; +} + +.rule-groups-page .page-subtitle { + margin: 4px 0 0; + color: #6b7280; + font-size: 13px; +} + +.rule-groups-page .page-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.rule-groups-page .filter-card, +.rule-groups-page .table-card { + border: 1px solid #e5e7eb; + box-shadow: none; + background: #fff; +} + +.rule-groups-page .page-count { + display: inline-flex; + align-items: center; + font-size: 14px; + font-weight: 400; + color: #6b7280; +} + +.rule-groups-page .filter-card { + margin-bottom: 12px; +} + +.rule-groups-page .summary-metrics, +.rule-groups-page .selected-group-overview { + gap: 12px; + flex-wrap: wrap; +} + +.rule-groups-page .metric-box, +.rule-groups-page .overview-box { + flex: 1 1 180px; + min-width: 180px; + padding: 14px 16px; + border: 1px solid #e5e7eb; + border-radius: 6px; + background: #fafafa; +} + +.rule-groups-page .metric-box.warning { + background: #fffaf2; + border-color: #f3d39a; +} + +.rule-groups-page .metric-label { + display: block; + margin-bottom: 6px; + color: #6b7280; + font-size: 12px; +} + +.rule-groups-page .metric-box strong, +.rule-groups-page .overview-box strong { + display: block; + font-size: 20px; + color: #111827; + font-weight: 600; +} + +.rule-groups-page .metric-box em, +.rule-groups-page .overview-box em, +.rule-groups-page .binding-card-title span, +.rule-groups-page .detail-card-header p { + display: block; + margin-top: 4px; + color: #6b7280; + font-size: 12px; + font-style: normal; + line-height: 1.5; +} + +.rule-groups-page .logic-strip { + margin-top: 14px; + gap: 10px; + flex-wrap: wrap; + padding: 12px 14px; + border: 1px solid #e5e7eb; + border-radius: 6px; + background: #f9fafb; +} + +.rule-groups-page .logic-item { + flex: 1 1 220px; +} + +.rule-groups-page .logic-item strong { + display: block; + margin-bottom: 4px; + color: #111827; + font-size: 13px; +} + +.rule-groups-page .logic-item span, +.rule-groups-page .meta-cell strong, +.rule-groups-page .meta-cell span, +.rule-groups-page .detail-alert span { + display: block; + line-height: 1.5; +} + +.rule-groups-page .logic-item span, +.rule-groups-page .meta-cell span, +.rule-groups-page .detail-alert span, +.rule-groups-page .warning-inline, +.rule-groups-page .hint-inline { + color: #6b7280; + font-size: 12px; +} + +.rule-groups-page .logic-arrow { + color: #00684a; + font-weight: 700; +} + +.rule-groups-page .filter-row { + gap: 12px; + flex-wrap: wrap; +} + +.rule-groups-page .filter-item { + flex: 1 1 210px; +} + +.rule-groups-page .filter-small { + flex: 0 0 128px; +} + +.rule-groups-page .filter-item label, +.rule-groups-page .form-item label { + display: block; + margin-bottom: 6px; + font-size: 12px; + font-weight: 500; + color: #374151; +} + +.rule-groups-page .filter-item input, +.rule-groups-page .filter-item select, +.rule-groups-page .form-item input, +.rule-groups-page .form-item select, +.rule-groups-page .form-item textarea { + width: 100%; + border: 1px solid #d1d5db; + border-radius: 4px; + padding: 8px 10px; + font-size: 13px; + background: #fff; + color: #111827; + transition: border-color 0.2s, box-shadow 0.2s; +} + +.rule-groups-page .filter-item input:focus, +.rule-groups-page .filter-item select:focus, +.rule-groups-page .form-item input:focus, +.rule-groups-page .form-item select:focus, +.rule-groups-page .form-item textarea:focus { + border-color: #00684a; + box-shadow: 0 0 0 1px rgba(0, 104, 74, 0.18); + outline: 2px solid transparent; + outline-offset: 2px; +} + +.rule-groups-page .filter-actions-row { + display: flex; + align-items: flex-end; +} + +.rule-groups-page .table-wrap { + overflow-x: auto; +} + +.rule-groups-page .group-table { + width: 100%; + border-collapse: separate; + border-spacing: 0; + min-width: 1080px; +} + +.rule-groups-page .group-table th, +.rule-groups-page .group-table td { + padding: 10px 12px; + border-bottom: 1px solid #f0f2f5; + vertical-align: middle; + text-align: left; +} + +.rule-groups-page .group-table thead th { + font-size: 12px; + font-weight: 500; + color: #4b5563; + background: #f9fafb; + white-space: nowrap; +} + +.rule-groups-page .group-row.level-root { + background: #fafafa; +} + +.rule-groups-page .group-row.level-child { + background: #fcfcfd; + cursor: pointer; +} + +.rule-groups-page .group-row.level-binding { + background: #fff; +} + +.rule-groups-page .group-row.level-child.selected { + background: #f2fbf7; +} + +.rule-groups-page .name-cell-button { + display: inline-flex; + align-items: center; + gap: 8px; + border: 0; + background: transparent; + padding: 0; + color: inherit; + cursor: pointer; + text-align: left; +} + +.rule-groups-page .name-cell-button.static { + cursor: default; +} + +.rule-groups-page .name-cell-button.child { + padding-left: 12px; +} + +.rule-groups-page .level-binding .name-cell-button.child { + padding-left: 24px; +} + +.rule-groups-page .expand-icon { + width: 14px; + color: #6b7280; +} + +.rule-groups-page .expand-icon.placeholder { + visibility: hidden; +} + +.rule-groups-page .name-cell-text { + display: inline-flex; + flex-direction: column; + gap: 2px; +} + +.rule-groups-page .name-cell-text strong { + font-size: 13px; + color: #111827; + font-weight: 500; +} + +.rule-groups-page .name-cell-text em, +.rule-groups-page .meta-cell span, +.rule-groups-page .rule-name-cell span { + font-size: 11px; + color: #6b7280; + font-style: normal; + line-height: 1.35; +} + +.rule-groups-page .status-badge, +.rule-groups-page .mini-badge { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 1px 8px; + border-radius: 999px; + font-size: 11px; + font-weight: 500; + border: 1px solid transparent; + white-space: nowrap; +} + +.rule-groups-page .status-badge, +.rule-groups-page .mini-badge { + margin-right: 6px; + margin-bottom: 4px; +} + +.rule-groups-page .mini-badge { + color: #4b5563; + background: #f3f4f6; + border-color: #e5e7eb; +} + +.rule-groups-page .status-badge.enabled, +.rule-groups-page .mini-badge.ok { + color: #047857; + background: #ecfdf5; + border-color: #a7f3d0; +} + +.rule-groups-page .status-badge.disabled { + color: #6b7280; + background: #f3f4f6; + border-color: #e5e7eb; +} + +.rule-groups-page .status-badge.warning, +.rule-groups-page .mini-badge.warn { + color: #b45309; + background: #fffbeb; + border-color: #fcd34d; +} + +.rule-groups-page .action-links { + gap: 10px; + flex-wrap: wrap; + white-space: nowrap; +} + +.rule-groups-page .detail-card { + margin-top: 12px; +} + +.rule-groups-page .detail-alert { + margin: 12px 0; + padding: 12px 14px; + border-radius: 6px; + border: 1px solid #e5e7eb; +} + +.rule-groups-page .detail-alert strong { + display: block; + margin-bottom: 4px; + font-size: 13px; +} + +.rule-groups-page .detail-alert.warning { + background: #fffaf2; + border-color: #f3d39a; +} + +.rule-groups-page .detail-alert.warning strong, +.rule-groups-page .warning-inline { + color: #b45309; +} + +.rule-groups-page .detail-alert.success { + background: #f2fbf7; + border-color: #b7e4cf; +} + +.rule-groups-page .detail-alert.success strong, +.rule-groups-page .hint-inline { + color: #047857; +} + +.rule-groups-page .detail-alert.info { + background: #f8fafc; + border-color: #dbe3ee; +} + +.rule-groups-page .detail-alert.info strong { + color: #1f2937; +} + +.rule-groups-page .warning-inline, +.rule-groups-page .hint-inline { + margin-top: 6px; + line-height: 1.45; +} + +.rule-groups-page .detail-card-header, +.rule-groups-page .binding-card-header, +.rule-groups-page .binding-card-actions { + justify-content: space-between; + gap: 12px; +} + +.rule-groups-page .detail-card-header h3 { + margin: 0; + font-size: 16px; + color: #111827; +} + +.rule-groups-page .detail-card-header p { + margin: 4px 0 0; +} + +.rule-groups-page .detail-card-tags, +.rule-groups-page .binding-card-tags { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.rule-groups-page .binding-list { + display: flex; + flex-direction: column; + gap: 12px; +} + +.rule-groups-page .binding-card { + border: 1px solid #e5e7eb; + border-radius: 6px; + background: #fff; + overflow: hidden; +} + +.rule-groups-page .binding-card-header, +.rule-groups-page .binding-card-actions { + padding: 12px 14px; +} + +.rule-groups-page .binding-card-actions { + border-top: 1px solid #f3f4f6; + background: #fcfcfd; +} + +.rule-groups-page .binding-card-title strong, +.rule-groups-page .meta-cell strong { + color: #111827; + font-size: 13px; + font-weight: 600; +} + +.rule-groups-page .action-links .action-link + .action-link::before, +.rule-groups-page .action-links .button-link + .button-link::before { + content: ""; + margin-right: 0; +} + +.rule-groups-page .action-link { + color: #00684a; + text-decoration: none; + font-size: 12px; + font-weight: 400; + line-height: 1; +} + +.rule-groups-page .button-link { + background: transparent; + border: 0; + padding: 0; + cursor: pointer; +} + +.rule-groups-page .action-link:hover { text-decoration: underline; } -/* 修改分组名称链接颜色为绿色 */ -.rule-groups-page .tree-table a.text-primary { - color: #00684a; - text-decoration: none; +.rule-groups-page .action-link.danger { + color: #dc2626; } -.rule-groups-page .tree-table a.text-primary:hover { - text-decoration: underline; - color: #005a3f; +.rule-groups-page .preview-row td { + padding-top: 0; + background: #fff; } -/* 分组名称链接样式 */ -.rule-groups-page .group-name-link { - color: #00684a; - text-decoration: none; - transition: all 0.2s; +.rule-groups-page .preview-panel { + margin: 0 12px 12px; + border: 1px solid #e5e7eb; + border-radius: 6px; + background: #fff; + overflow: hidden; } -.rule-groups-page .group-name-link:hover { - text-decoration: underline; - color: #005a3f; +.rule-groups-page .preview-header-row { + padding: 10px 12px; + border-bottom: 1px solid #f0f2f5; + background: #f9fafb; + font-size: 12px; } -/* 展开/收起图标颜色 */ -.rule-groups-page .expand-icon i { - color: #00684a; +.rule-groups-page .preview-table-wrap { + max-height: 320px; + overflow: auto; } -/* 树形结构样式 */ -.rule-groups-page .tree-table .group-row { - transition: all 0.2s; - font-weight: 400; /* 减少文字粗细度 */ - } - - .rule-groups-page .tree-table .group-row:hover { - background-color: rgba(0, 104, 74, 0.05); - } - - .rule-groups-page .tree-table .parent-row { - background-color: #f9f9f9; - font-weight: 400; /* 减少文字粗细度 */ - } - - .rule-groups-page .tree-table .child-row { - border-left: 3px solid #f0f0f0; - } - - .rule-groups-page .expand-icon { - width: 22px; - height: 22px; - border-radius: 4px; - display: inline-flex; - align-items: center; - justify-content: center; - cursor: pointer; - transition: all 0.2s; - } - - .rule-groups-page .expand-icon:hover { - background-color: rgba(0, 104, 74, 0.1); - } - - .rule-groups-page .group-badge { - display: inline-flex; - align-items: center; - padding: 2px 8px; - border-radius: 12px; - font-size: 12px; - margin-left: 8px; - } - - .rule-groups-page .parent-badge { - background-color: rgba(0, 104, 74, 0.1); - color: var(--color-primary); - } - - .rule-groups-page .child-badge { - background-color: rgba(0, 104, 74, 0.05); - color: var(--color-primary); - } - - /* 表单样式 */ - .rule-groups-page .form-label { - display: block; - margin-bottom: 8px; - color: #495057; - font-size: 14px; - } - - .rule-groups-page .form-input, - .rule-groups-page .form-select { - width: 100%; - height: 38px; - padding: 8px 12px; - border: 1px solid #dee2e6; - border-radius: 4px; - font-size: 14px; - transition: all 0.2s; - } - - .rule-groups-page .form-input:focus, - .rule-groups-page .form-select:focus { - border-color: #00684a; - box-shadow: 0 0 0 2px rgba(0, 104, 74, 0.2); - outline: none; - } - - /* 特定链接样式 */ - .rule-groups-page .badge { - display: inline-flex; - align-items: center; - justify-content: center; - padding: 0.25rem 0.5rem; - border-radius: 0.75rem; - font-size: 0.75rem; - font-weight: 400; /* 减少文字粗细度 */ +.rule-groups-page .preview-table { + width: 100%; + border-collapse: collapse; +} + +.rule-groups-page .preview-table th, +.rule-groups-page .preview-table td { + padding: 10px 12px; + border-bottom: 1px solid #f3f4f6; + font-size: 12px; +} + +.rule-groups-page .preview-empty, +.rule-groups-page .empty-cell, +.rule-groups-page .empty-block { + padding: 16px; + text-align: center; + color: #6b7280; + font-size: 12px; +} + +.rule-groups-page .rule-name-cell { + display: flex; + flex-direction: column; + gap: 4px; +} + +.rule-groups-page .rg-modal-backdrop { + position: fixed; + inset: 0; + background: rgba(17, 24, 39, 0.45); + display: flex; + align-items: center; + justify-content: center; + z-index: 60; + padding: 24px; +} + +.rule-groups-page .rg-modal { + width: min(760px, 100%); + max-height: calc(100vh - 48px); + background: #fff; + border-radius: 8px; + box-shadow: 0 20px 40px rgba(0, 0, 0, 0.12); + overflow: hidden; + display: flex; + flex-direction: column; +} + +.rule-groups-page .rg-modal.large { + width: min(900px, 100%); +} + +.rule-groups-page .rg-modal-header, +.rule-groups-page .rg-modal-footer { + padding: 14px 18px; + border-bottom: 1px solid #e5e7eb; +} + +.rule-groups-page .rg-modal-footer { + border-top: 1px solid #e5e7eb; + border-bottom: 0; + gap: 12px; +} + +.rule-groups-page .rg-modal-body { + padding: 18px; + overflow: auto; +} + +.rule-groups-page .grid-form { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; +} + +.rule-groups-page .form-item-span-2 { + grid-column: 1 / -1; +} + +.rule-groups-page .switch-row { + gap: 10px; + font-size: 13px; + font-weight: 500; + color: #374151; +} + +.rule-groups-page .field-tip { + margin: 6px 0 0; + font-size: 11px; + line-height: 1.5; + color: #6b7280; +} + +.rule-groups-page .modal-tip { + display: flex; + flex-direction: column; + gap: 4px; + padding: 8px 10px; + border-radius: 6px; + background: #f9fafb; + border: 1px solid #e5e7eb; +} + +.rule-groups-page .modal-tip strong { + font-size: 13px; + color: #111827; +} + +.rule-groups-page .modal-tip span { + font-size: 11px; + color: #6b7280; +} + +.rule-groups-page .rule-set-scroll-box { + max-height: 280px; + overflow: auto; + border: 1px solid #d1d5db; + border-radius: 6px; + background: #fff; + padding: 6px; +} + +.rule-groups-page .rule-set-option { + width: 100%; + border: 1px solid transparent; + border-radius: 6px; + background: #fff; + padding: 10px 12px; + display: flex; + justify-content: space-between; + gap: 10px; + text-align: left; + cursor: pointer; +} + +.rule-groups-page .rule-set-option + .rule-set-option { + margin-top: 8px; +} + +.rule-groups-page .rule-set-option strong, +.rule-groups-page .rule-set-option em { + display: block; +} + +.rule-groups-page .rule-set-option em { + margin-top: 4px; + font-style: normal; + color: #6b7280; + font-size: 11px; +} + +.rule-groups-page .rule-set-option.active { + border-color: rgba(0, 104, 74, 0.28); + background: rgba(0, 104, 74, 0.04); +} + +.rule-groups-page .icon-button { + width: 32px; + height: 32px; + border-radius: 6px; + border: 0; + background: #f3f4f6; + cursor: pointer; + color: #374151; +} + +@media (max-width: 1024px) { + .rule-groups-page .grid-form { + grid-template-columns: 1fr; } - /* 添加badge链接的特殊悬停样式 */ - .rule-groups-page a.badge.bg-primary.text-white:hover { - color: white; - opacity: 0.9; + .rule-groups-page .form-item-span-2 { + grid-column: auto; } +} - /* 仅在rule-groups页面内生效的样式 */ - .rule-groups-page .bg-primary { - background-color: #00684a; - } - - .rule-groups-page .text-white { - color: white; - } - - .rule-groups-page .text-secondary { - color: #6c757d; - } - - .rule-groups-page .text-error { - color: #f5222d; - } - - /* 文本按钮样式 */ - .rule-groups-page .ant-btn-text { - background-color: transparent; - border: none; - padding: 4px 8px; - font-size: 14px; - cursor: pointer; - transition: color 0.2s; - } - - .rule-groups-page .ant-btn-text.text-primary { - color: #00684a; - } - - .rule-groups-page .operations-cell { - @apply flex space-x-2; - } - - .rule-groups-page .operation-btn { - @apply text-sm flex items-center text-[--color-primary] bg-transparent hover:underline p-2; - } - - .rule-groups-page .ant-btn-text.text-primary:hover { - color: #00684a; - text-decoration: underline; - } - - .rule-groups-page .ant-btn-text.text-error { - color: #f5222d; - } - - .rule-groups-page .ant-btn-text.text-error:hover { - color: #f5222d; - text-decoration: underline; - } - - @media (max-width: 768px) { - .rule-groups-page .flex-wrap { - flex-wrap: wrap; - } - - .rule-groups-page .flex-1 { - flex: 1 1 100%; - } - - .rule-groups-page .ant-table { - font-size: 12px; - } - } - - /* 搜索框样式 */ - .rule-groups-page .search-box { - display: flex; - align-items: center; - } - - .rule-groups-page .search-box .form-input { - flex: 1; - border-top-right-radius: 0; - border-bottom-right-radius: 0; - } - - .rule-groups-page .search-box .ant-btn { - border-top-left-radius: 0; - border-bottom-left-radius: 0; - } - - /* 无按钮搜索框样式 */ - .rule-groups-page .search-box.form-input-only .form-input { - border-radius: 0.25rem; - width: 100%; - } - - /* 表格组件样式兼容 - 确保Table组件渲染的表格保持树形结构样式 */ - .rule-groups-page .tree-table tr:nth-child(odd) { - background-color: rgba(0, 104, 74, 0.02); - } - - .rule-groups-page .tree-table tr:hover td { - background-color: rgba(0, 104, 74, 0.05); - } - - /* 确保父子关系行高一致 */ - .rule-groups-page .tree-table tr td { - padding: 12px 16px; - vertical-align: middle; - } - - /* 父行背景色 */ - .rule-groups-page .tree-table tr:has(.parent-badge) { - background-color: #f9f9f9; - } - - /* 确保链接样式一致 */ - .rule-groups-page .tree-table a { - /* color: inherit; */ - text-decoration: none; - } - - .rule-groups-page .tree-table a:hover { - text-decoration: underline; - } - - /* 搜索筛选面板样式调整 */ - .rule-groups-page .filter-panel { +@media (max-width: 768px) { + .rule-groups-page { padding: 16px; } - - .rule-groups-page .filter-item { - margin-bottom: 0; - } - - .rule-groups-page .filter-actions { - margin-top: 8px; - padding-top: 8px; + + .rule-groups-page .page-header, + .rule-groups-page .filter-row, + .rule-groups-page .summary-metrics, + .rule-groups-page .logic-strip, + .rule-groups-page .selected-group-overview, + .rule-groups-page .detail-card-header, + .rule-groups-page .binding-card-header, + .rule-groups-page .binding-card-actions { + flex-direction: column; + align-items: stretch; } + .rule-groups-page .page-actions, + .rule-groups-page .filter-actions-row { + width: 100%; + } + + .rule-groups-page .page-actions > *, + .rule-groups-page .filter-actions-row > * { + width: 100%; + } + + .rule-groups-page .rg-modal-backdrop { + padding: 12px; + } +} diff --git a/app/styles/pages/rules_test.css b/app/styles/pages/rules_test.css index df5da7a..46b71a5 100644 --- a/app/styles/pages/rules_test.css +++ b/app/styles/pages/rules_test.css @@ -373,6 +373,12 @@ font-size: 13px; } +.rules-test-page .draft-tip.danger { + border-color: #f0c9c1; + background: #fff6f4; + color: #9f3f2d; +} + .rules-test-page .validation-card .card-body { display: flex; flex-direction: column; diff --git a/app/utils/route-alias.shared.js b/app/utils/route-alias.shared.js index 08d759a..a18f53b 100644 --- a/app/utils/route-alias.shared.js +++ b/app/utils/route-alias.shared.js @@ -35,26 +35,34 @@ export const permissionRouteAliasGroups = [ entries: [ { source: '^/reviewsTest(?=/|$)', - target: '/reviews', - note: '旧版评查测试页复用正式评查页权限。', + target: '/documents', + note: '文档评查详情页复用文档模块权限,兼容从文档列表与上传页进入详情的场景。', examples: [ - { input: '/reviewsTest', output: '/reviews' }, + { input: '/reviewsTest', output: '/documents' }, + ], + }, + { + source: '^/reviews(?=/|$)', + target: '/documents', + note: '旧版评查详情页同样归入文档模块权限,避免历史链接访问被拒绝。', + examples: [ + { input: '/reviews', output: '/documents' }, ], }, { source: '^/rulesTest/list(?=/|$)', - target: '/rules/list', - note: '旧版规则列表页复用新版规则列表权限。', + target: '/rules', + note: '旧版规则列表页归入规则管理主菜单权限。', examples: [ - { input: '/rulesTest/list', output: '/rules/list' }, + { input: '/rulesTest/list', output: '/rules' }, ], }, { source: '^/rulesTest/detail(?=/|$)', - target: '/rules/list', - note: '旧版规则详情页归入规则列表权限体系。', + target: '/rules', + note: '旧版规则详情页归入规则管理主菜单权限。', examples: [ - { input: '/rulesTest/detail', output: '/rules/list' }, + { input: '/rulesTest/detail', output: '/rules' }, ], }, ], @@ -98,17 +106,41 @@ export const permissionRouteAliasGroups = [ { source: '^/rule-groups/new(?=/|$)', target: '/rule-groups', - note: '评查点分组新建/编辑页复用分组列表权限。', + note: '旧分组新建/编辑入口已下线,统一回到规则导航页。', examples: [ { input: '/rule-groups/new', output: '/rule-groups' }, ], }, { - source: '^/rules/new(?=/|$)', - target: '/rules/list', - note: '评查点新建/编辑/复制页复用评查点列表权限。', + source: '^/rules/list(?=/|$)', + target: '/rules', + note: '评查点列表页优先复用规则管理主菜单权限,兼容后端尚未细分子路由授权的场景。', examples: [ - { input: '/rules/new', output: '/rules/list' }, + { input: '/rules/list', output: '/rules' }, + ], + }, + { + source: '^/rules/new(?=/|$)', + target: '/rules', + note: '评查点新建/编辑/复制页复用规则管理主菜单权限。', + examples: [ + { input: '/rules/new', output: '/rules' }, + ], + }, + { + source: '^/rules/sets(?=/|$)', + target: '/rules', + note: '旧版规则管理入口复用规则管理主菜单权限,并跳转到新版规则维护页。', + examples: [ + { input: '/rules/sets', output: '/rules' }, + ], + }, + { + source: '^/documents/list(?=/|$)', + target: '/documents', + note: '文档列表子页复用文档模块主菜单权限,兼容 Remix 嵌套路由地址。', + examples: [ + { input: '/documents/list', output: '/documents' }, ], }, { @@ -135,18 +167,34 @@ export const permissionRouteAliasGroups = [ }, { source: '^/contract-template/detail(?=/|$)', - target: '/contract-template', - note: '合同模板详情页归属到合同模板模块。', + target: '/contract-template/list', + note: '合同模板详情页复用合同列表权限,兼容父菜单本身不直接授权的场景。', examples: [ - { input: '/contract-template/detail/123', output: '/contract-template/123' }, + { input: '/contract-template/detail/123', output: '/contract-template/list/123' }, ], }, { source: '^/contract-draft(?=/|$)', - target: '/contract-template', - note: '合同起草页作为合同模板模块的延伸能力。', + target: '/contract-template/list', + note: '合同起草页复用合同列表权限,保持与老入口的访问语义一致。', examples: [ - { input: '/contract-draft/1', output: '/contract-template/1' }, + { input: '/contract-draft/1', output: '/contract-template/list/1' }, + ], + }, + { + source: '^/chat-with-llm/chat(?=/|$)', + target: '/chat-with-llm', + note: 'AI 对话实际工作台复用 AI 对话主菜单权限。', + examples: [ + { input: '/chat-with-llm/chat', output: '/chat-with-llm' }, + ], + }, + { + source: '^/chat-with-llm/dataset-manager(?=/|$)', + target: '/chat-with-llm', + note: '知识库管理页归属 AI 对话模块,避免隐藏子页权限断层。', + examples: [ + { input: '/chat-with-llm/dataset-manager', output: '/chat-with-llm' }, ], }, ], diff --git a/app/utils/rule-yaml-parser.ts b/app/utils/rule-yaml-parser.ts new file mode 100644 index 0000000..6a4033e --- /dev/null +++ b/app/utils/rule-yaml-parser.ts @@ -0,0 +1,241 @@ +export type RuleSummary = { + id: string; + ruleId: string; + name: string; + group: string; + risk: string; + score: string; + type: string; + checkTypes: string[]; + logic: string; + subRules: Array<{ + id: string; + check: string; + content: string; + }>; + subRuleIds: string[]; + scope: string[]; + dependencies: string[]; + stageCount: number; + appliesIn: string[]; + prompt: string; + description: string; +}; + +function getTopLevelSection(source: string, key: string): string { + const lines = source.split("\n"); + const start = lines.findIndex((line) => line === `${key}:`); + if (start === -1) return ""; + const end = lines.findIndex((line, index) => index > start && /^[a-zA-Z_][\w-]*:/.test(line)); + return lines.slice(start + 1, end === -1 ? undefined : end).join("\n"); +} + +function stripYamlValue(value = ""): string { + return value.trim().replace(/^['"]|['"]$/g, "").replace(/\u0000/g, ""); +} + +function splitBlocks(section: string, marker: RegExp): string[] { + const lines = section.split("\n"); + const starts = lines.reduce((indexes, line, index) => { + if (marker.test(line)) indexes.push(index); + return indexes; + }, []); + + return starts.map((start, index) => lines.slice(start, starts[index + 1]).join("\n")); +} + +export function parseRuleSummariesFromYaml(source: string): RuleSummary[] { + const section = getTopLevelSection(source, "rules"); + const groups = splitBlocks(section, /^-\s+group:\s*/); + + const readExplicitDependencies = (block: string): string[] => { + const lines = block.split("\n"); + const start = lines.findIndex((line) => /^\s{4}dependencies:\s*$/.test(line)); + if (start === -1) return []; + + const dependencies: string[] = []; + for (let index = start + 1; index < lines.length; index += 1) { + const line = lines[index]; + if (/^\s{4}[a-zA-Z_][^:]*:\s*/.test(line)) break; + const match = line.match(/^\s{4}-\s+(.+)$/); + if (match) dependencies.push(stripYamlValue(match[1])); + } + return dependencies; + }; + + const normalizeDependency = (value: string) => { + const normalized = stripYamlValue(value); + if (normalized === "cross_page_seal") return "骑缝章"; + if (normalized === "seal") return "印章"; + if (normalized === "signature") return "签名"; + return normalized; + }; + + const readPrompts = (block: string): string[] => { + const lines = block.split("\n"); + const prompts: string[] = []; + for (let index = 0; index < lines.length; index += 1) { + const match = lines[index].match(/^(\s*)prompt:\s*(.*)$/); + if (!match) continue; + + const indent = match[1].length; + const parts = [match[2]]; + for (let nextIndex = index + 1; nextIndex < lines.length; nextIndex += 1) { + const line = lines[nextIndex]; + const nextIndent = line.match(/^\s*/)?.[0].length || 0; + const trimmed = line.trim(); + if (trimmed && nextIndent <= indent) break; + if (trimmed && nextIndent === indent + 2 && /^[a-zA-Z_][\w-]*:\s*/.test(trimmed)) break; + parts.push(line); + } + + prompts.push( + parts + .join("\n") + .replace(/^['"]/, "") + .replace(/['"]\s*$/, "") + .split("\n") + .map((line) => line.replace(/^\s{8}/, "")) + .join("\n") + .trim(), + ); + } + return prompts.filter(Boolean); + }; + + const readList = (block: string, key: string, indent = 4): string[] => { + const lines = block.split("\n"); + const start = lines.findIndex((line) => new RegExp(`^\\s{${indent}}${key}:\\s*$`).test(line)); + if (start === -1) return []; + + const values: string[] = []; + for (let index = start + 1; index < lines.length; index += 1) { + const line = lines[index]; + if (new RegExp(`^\\s{${indent}}[a-zA-Z_][^:]*:\\s*`).test(line)) break; + const match = line.match(new RegExp(`^\\s{${indent}}-\\s+(.+)$`)); + if (match) values.push(stripYamlValue(match[1])); + } + return values; + }; + + const readFlexibleList = (block: string, key: string): string[] => { + const lines = block.split("\n"); + const start = lines.findIndex((line) => new RegExp(`^(\\s*)${key}:\\s*$`).test(line)); + if (start === -1) return []; + const indent = lines[start].match(/^\s*/)?.[0].length || 0; + const values: string[] = []; + + for (let index = start + 1; index < lines.length; index += 1) { + const line = lines[index]; + const lineIndent = line.match(/^\s*/)?.[0].length || 0; + const match = line.match(/^\s*-\s+(.+)$/); + if (match) { + values.push(stripYamlValue(match[1])); + continue; + } + if (line.trim() && lineIndent <= indent) break; + } + return values; + }; + + const readStageList = (block: string, key: string): string[] => { + const lines = block.split("\n"); + const start = lines.findIndex((line) => new RegExp(`^\\s{6}${key}:\\s*$`).test(line)); + if (start === -1) return []; + + const values: string[] = []; + for (let index = start + 1; index < lines.length; index += 1) { + const line = lines[index]; + if (/^\s{6}[a-zA-Z_][^:]*:\s*/.test(line)) break; + const match = line.match(/^\s{6}-\s+(.+)$/); + if (match) values.push(stripYamlValue(match[1])); + } + return values; + }; + + const readStageScalar = (block: string, key: string): string => + stripYamlValue(block.match(new RegExp(`^\\s{6}${key}:\\s*(.+)$`, "m"))?.[1] || ""); + + const summarizeStage = (stageBlock: string): string => { + const fields = readStageList(stageBlock, "fields"); + const field = readStageScalar(stageBlock, "field"); + const left = readStageScalar(stageBlock, "left") || readStageScalar(stageBlock, "left_field"); + const op = readStageScalar(stageBlock, "op"); + const right = readStageScalar(stageBlock, "right") || readStageScalar(stageBlock, "right_field"); + const value = readStageScalar(stageBlock, "value"); + const prompt = readStageScalar(stageBlock, "prompt"); + const element = readStageScalar(stageBlock, "element") || readStageScalar(stageBlock, "seal_id") || readStageScalar(stageBlock, "signature_id"); + + if (fields.length > 0) return fields.join("、"); + if (left || right) return [left, op, right].filter(Boolean).join(" "); + if (field && value) return `${field} = ${value}`; + if (field) return field; + if (element) return element; + if (prompt) return prompt.slice(0, 80); + return ( + stageBlock + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) + .slice(1, 4) + .join(";") || "未配置内容" + ); + }; + + const readSubRules = (block: string) => + splitBlocks(block, /^\s{4}-\s+id:\s*/) + .map((stageBlock) => { + const id = stripYamlValue(stageBlock.match(/^\s{4}-\s+id:\s*(.+)$/m)?.[1] || ""); + const check = readStageScalar(stageBlock, "check") || readStageScalar(stageBlock, "type") || "-"; + return { + id, + check, + content: summarizeStage(stageBlock), + }; + }) + .filter((stage) => stage.id); + + return groups.flatMap((groupBlock) => { + const group = stripYamlValue(groupBlock.match(/^-\s+group:\s*(.+)$/m)?.[1] || "未分组"); + return splitBlocks(groupBlock, /^\s{2}-\s+rule_id:\s*/).map((ruleBlock) => { + const ruleId = stripYamlValue(ruleBlock.match(/^\s{2}-\s+rule_id:\s*(.+)$/m)?.[1] || ""); + const name = stripYamlValue(ruleBlock.match(/^\s{4}name:\s*(.+)$/m)?.[1] || "未命名规则"); + const checkTypes = Array.from( + new Set(Array.from(ruleBlock.matchAll(/^\s{6,}(?:check|type):\s*(.+)$/gm)).map((match) => stripYamlValue(match[1]))), + ); + const stageDependencies = Array.from( + ruleBlock.matchAll(/^\s{6,}(?:field|number|chinese|left|right|left_field|right_field|target|element|seal_id|signature_id):\s*(.+)$/gm), + ).map((match) => normalizeDependency(match[1])); + const dependencies = Array.from(new Set([...readExplicitDependencies(ruleBlock), ...stageDependencies])); + const scope = Array.from( + new Set( + Array.from(ruleBlock.matchAll(/^\s{4,}-\s*([^:\n]+)$/gm)) + .map((match) => stripYamlValue(match[1])) + .filter((value) => !/^\d+$/.test(value)), + ), + ); + const prompts = readPrompts(ruleBlock); + const subRules = readSubRules(ruleBlock); + + return { + id: ruleId || `${group}-${name}`, + ruleId, + name, + group, + risk: stripYamlValue(ruleBlock.match(/^\s{4}risk:\s*(.+)$/m)?.[1] || "medium"), + score: stripYamlValue(ruleBlock.match(/^\s{4}score:\s*(.+)$/m)?.[1] || "-"), + type: stripYamlValue(ruleBlock.match(/^\s{4}type:\s*(.+)$/m)?.[1] || "deterministic"), + checkTypes, + logic: stripYamlValue(ruleBlock.match(/^\s{4}logic:\s*(.+)$/m)?.[1] || ""), + subRules, + subRuleIds: readList(ruleBlock, "rules"), + scope: scope.slice(0, 8), + dependencies: dependencies.slice(0, 8), + stageCount: subRules.length, + appliesIn: readFlexibleList(ruleBlock, "applies_in"), + prompt: prompts.join("\n\n"), + description: stripYamlValue(ruleBlock.match(/^\s{4}desc:\s*(.+)$/m)?.[1] || ""), + } satisfies RuleSummary; + }); + }); +} diff --git a/app/utils/rules-config-packs.server.ts b/app/utils/rules-config-packs.server.ts new file mode 100644 index 0000000..e7c5ce9 --- /dev/null +++ b/app/utils/rules-config-packs.server.ts @@ -0,0 +1,115 @@ +import { API_BASE_URL } from '~/config/api-config'; +import { getUserSession } from '~/api/login/auth.server'; +import { + buildRuleYamlPack, + EMPTY_RULE_YAML, + type RuleYamlPack, +} from './rules-yaml-mock.server'; + +type RuleConfigPackApi = { + packId: number; + groupId: number; + rootGroupId?: number | null; + bindingId?: number | null; + ruleSetId?: number | null; + ruleType?: string | null; + ruleName?: string | null; + currentVersionId?: number | null; + fallbackVersionId?: number | null; + resolvedVersionId?: number | null; + hasUsableVersion?: boolean; + usableRuleCount?: number; + documentTypeId?: number | null; + documentType?: string; + moduleType?: string; + mainType?: string; + subtype?: string; + yamlText?: string; + sourceStatus?: 'ready' | 'empty' | 'missing'; +}; + +type ApiEnvelope = { + code?: number; + message?: string; + msg?: string; + data?: T; +}; + +export type RuleVersionItem = { + id: number; + ruleSetId: number; + versionNo: string; + status: string; + ossUrl: string; + changeNote?: string | null; + publishedAt?: string | null; +}; + +function getMessage(payload: unknown, fallback: string): string { + if (!payload || typeof payload !== 'object') { + return fallback; + } + return String((payload as ApiEnvelope).message || (payload as ApiEnvelope).msg || fallback); +} + +function mapApiPackToRuleYamlPack(item: RuleConfigPackApi): RuleYamlPack { + const yamlSource = (item.yamlText || '').trim() ? String(item.yamlText) : EMPTY_RULE_YAML; + const sourceStatus = item.sourceStatus || ((item.yamlText || '').trim() ? 'ready' : 'empty'); + + return buildRuleYamlPack( + { + id: String(item.packId), + yamlPath: null, + documentType: item.documentType || '', + moduleType: item.moduleType || (item.documentType ? `${item.documentType}评查` : '规则配置'), + mainType: item.mainType || item.documentType || '', + subtype: item.subtype || '通用', + }, + yamlSource, + sourceStatus, + ); +} + +async function fetchRuleConfigPayload(request: Request, path: string): Promise { + const { frontendJWT } = await getUserSession(request); + if (!frontendJWT) { + throw new Response('未登录或会话已失效', { status: 401 }); + } + + const response = await fetch(`${API_BASE_URL}${path}`, { + method: 'GET', + headers: { + Authorization: `Bearer ${frontendJWT}`, + Accept: 'application/json', + }, + }); + + const payload = (await response.json()) as ApiEnvelope; + if (!response.ok || typeof payload.data === 'undefined' || payload.data === null) { + throw new Response(getMessage(payload, '规则配置加载失败'), { status: response.status || 500 }); + } + + return payload.data; +} + +export async function loadRuleConfigPacks(request: Request): Promise { + const items = await fetchRuleConfigPayload(request, '/api/v3/rule-config-packs'); + return items.map(mapApiPackToRuleYamlPack); +} + +export async function loadRuleConfigPack(request: Request, packId: string): Promise { + if (!packId) { + const packs = await loadRuleConfigPacks(request); + return packs[0]; + } + + const item = await fetchRuleConfigPayload(request, `/api/v3/rule-config-packs/${encodeURIComponent(packId)}`); + return mapApiPackToRuleYamlPack(item); +} + +export async function loadRuleConfigVersions(request: Request, ruleType: string): Promise { + if (!ruleType) { + return []; + } + return fetchRuleConfigPayload(request, `/api/rule-sets/${encodeURIComponent(ruleType)}/versions`); +} diff --git a/app/utils/rules-yaml-mock.server.ts b/app/utils/rules-yaml-mock.server.ts index 74e942d..f26dd93 100644 --- a/app/utils/rules-yaml-mock.server.ts +++ b/app/utils/rules-yaml-mock.server.ts @@ -89,7 +89,7 @@ export type RuleYamlPack = RulePackScope & { }>; }; -const EMPTY_YAML = `metadata: +export const EMPTY_RULE_YAML = `metadata: type_id: pending.internal.document name: 内部公文规则配置 version: '0.1' @@ -377,6 +377,10 @@ function parseRules(source: string): RuleSummary[] { }); } +export function parseRuleSummariesFromYaml(source: string): RuleSummary[] { + return parseRules(source); +} + function parseTopLevelFields(source: string): ExtractFieldSummary[] { const section = getTopLevelSection(source, 'extract'); const extractedFields = splitBlocks(section, /^-\s+group:\s*/).flatMap(groupBlock => { @@ -513,7 +517,11 @@ function parseVisualElements(source: string): RuleYamlPack['visualElements'] { }).filter(item => item.id); } -function buildPack(config: RulePackScope & { id: string; yamlPath: string | null }, yamlSource: string, sourceStatus: RuleYamlPack['sourceStatus']): RuleYamlPack { +export function buildRuleYamlPack( + config: RulePackScope & { id: string; yamlPath: string | null }, + yamlSource: string, + sourceStatus: RuleYamlPack['sourceStatus'] +): RuleYamlPack { const metadata = parseMetadata(yamlSource); const fields = parseTopLevelFields(yamlSource); const subDocuments = parseSubDocuments(yamlSource); @@ -547,14 +555,14 @@ export async function loadRuleYamlPacks(): Promise { // 2. 后端读取 OSS YAML 正文并返回元数据和内容; // 3. 前端仍消费 buildPack 之后的结构化数据,页面不直接关心 OSS 实现。 if (!config.yamlPath) { - return buildPack(config, EMPTY_YAML, 'empty'); + return buildRuleYamlPack(config, EMPTY_RULE_YAML, 'empty'); } try { const yamlSource = await readFile(config.yamlPath, 'utf8'); - return buildPack(config, yamlSource, 'ready'); + return buildRuleYamlPack(config, yamlSource, 'ready'); } catch { - return buildPack(config, EMPTY_YAML, 'missing'); + return buildRuleYamlPack(config, EMPTY_RULE_YAML, 'missing'); } })); } diff --git a/mock-data/leaudit-rules/packs/yc/contract_loan/rules.yaml b/mock-data/leaudit-rules/packs/yc/contract_loan/rules.yaml index a615a7c..5b59915 100644 --- a/mock-data/leaudit-rules/packs/yc/contract_loan/rules.yaml +++ b/mock-data/leaudit-rules/packs/yc/contract_loan/rules.yaml @@ -246,8 +246,7 @@ rules: name: 利率不超过法定上限(LPR × 4 倍) risk: high score: 20 - depends_on: - - when: derived.LPR_4x != null + activate_if: derived.LPR_4x != null stages: - id: '1' check: required