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