import { type LoaderFunctionArgs, type MetaFunction } from "@remix-run/node"; import { useLoaderData, useNavigate, 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 { 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 { usePermission } from "~/hooks/usePermission"; import { parseRuleSummariesFromYaml, type RuleSummary } from "~/utils/rule-yaml-parser"; export function links() { return [{ rel: "stylesheet", href: indexStyles }]; } export const meta: MetaFunction = () => { return [ { title: "评查点分组管理 - 中国烟草AI合同及卷宗审核系统" }, { name: "description", content: "维护业务大类、具体业务类型与规则集之间的运行绑定关系。" }, ]; }; 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; } 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; } interface LoaderData { groups: RuleGroupNode[]; docTypes: DocumentType[]; entryModules: EntryModuleOption[]; ruleSets: RuleSetOption[]; frontendJWT?: string | null; } interface RuleTemplatePayload { groupId?: number; groupName?: string; parentGroupName?: string; documentTypeName?: string; entryModuleName?: string; ruleType?: string; ruleName?: string; yamlTemplate?: string; yamlText?: string; ossPreviewKey?: string; } interface RuleDraftCreateResult { packId?: number | null; groupId?: number | null; groupName?: string | null; ruleSetId?: number | null; ruleSetName?: string | null; ruleName?: string | null; ruleType?: string | null; versionId?: number | null; versionNo?: string | null; bindingId?: number | null; } interface GroupFormState { id?: number; mode: "create" | "edit"; pid: number; name: string; code: string; description: string; documentTypeId: string; entryModuleId: string; sortOrder: number; isEnabled: boolean; } 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, userInfo } = await getUserSession(request); const { requireRoutePermission } = await import("~/api/auth/check-route-permission.server"); const userRole = userInfo?.role || userInfo?.user_role || ""; await requireRoutePermission("/rule-groups", userRole, frontendJWT || undefined); try { 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); } catch (error) { if (axios.isAxiosError(error) && error.response?.status === 403) { throw new Response("无权访问评查点分组页面", { status: 403 }); } throw error; } } 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[]; }; type RuleDraftFormState = { groupId: number; yamlText: string; changeNote: string; template: RuleTemplatePayload | null; loading: boolean; saving: boolean; error: string | null; success: RuleDraftCreateResult | null; }; function unwrapApiData(payload: any): T { return (payload?.data ?? payload) as T; } function normalizeRuleTemplatePayload(payload: any): RuleTemplatePayload { const item = unwrapApiData(payload) || {}; const context = item.context || {}; return { groupId: item.groupId ?? item.group_id ?? context.groupId ?? context.group_id, groupName: item.groupName ?? item.group_name ?? context.groupName ?? context.group_name, parentGroupName: item.parentGroupName ?? item.root_group_name ?? context.parentGroupName ?? context.parent_group_name, documentTypeName: item.documentTypeName ?? item.document_type_name ?? context.documentTypeName ?? context.document_type_name, entryModuleName: item.entryModuleName ?? item.entry_module_name ?? context.entryModuleName ?? context.entry_module_name, ruleType: item.ruleType ?? item.rule_type, ruleName: item.ruleName ?? item.rule_name, yamlTemplate: item.yamlTemplate ?? item.yaml_template, yamlText: item.yamlText ?? item.yaml_text, ossPreviewKey: item.ossPreviewKey ?? item.oss_preview_key, }; } function normalizeRuleDraftCreateResult(payload: any): RuleDraftCreateResult { const item = unwrapApiData(payload) || {}; const createdVersion = item.createdVersion ?? item.created_version ?? {}; const binding = item.binding ?? {}; return { packId: item.packId ?? item.pack_id ?? item.groupId ?? item.group_id, groupId: item.groupId ?? item.group_id, groupName: item.groupName ?? item.group_name, ruleSetId: item.ruleSetId ?? item.rule_set_id ?? createdVersion.ruleSetId ?? createdVersion.rule_set_id, ruleSetName: item.ruleSetName ?? item.rule_set_name ?? binding.rule_name, ruleName: item.ruleName ?? item.rule_name ?? binding.rule_name, ruleType: item.ruleType ?? item.rule_type ?? binding.rule_type, versionId: item.versionId ?? item.version_id ?? createdVersion.id, versionNo: item.versionNo ?? item.version_no ?? createdVersion.versionNo ?? createdVersion.version_no, bindingId: item.bindingId ?? item.binding_id ?? binding.id, }; } function findGroupNode(groups: RuleGroupNode[], groupId: number): RuleGroupNode | null { for (const root of groups) { if (root.id === groupId) return root; const child = (root.children || []).find((item) => item.id === groupId); if (child) return child; } return null; } function buildRuleDraftJumpUrl(group: RuleGroupNode | null, success: RuleDraftCreateResult | null, template: RuleTemplatePayload | null): string { if (success?.packId) { return `/rulesTest/detail?packId=${encodeURIComponent(String(success.packId))}`; } const params = new URLSearchParams(); const keyword = success?.ruleType || template?.ruleType || group?.code || group?.name || ""; if (keyword) params.set("keyword", keyword); return `/rulesTest/list${params.toString() ? `?${params.toString()}` : ""}`; } 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 ? "一级分组默认代表业务大类容器;这个字段只用于兼容旧数据,新建业务大类通常可留空。" : "二级分组对应实际业务类型,如建设工程合同、处罚-一般程序、许可-停业办理,再往下绑定运行规则集。"}