Files
leaudit-platform-frontend/app/routes/rule-groups._index.tsx
T

1163 lines
53 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, string> {
const headers: Record<string, string> = {};
if (token) headers.Authorization = `Bearer ${token}`;
return headers;
}
async function fetchGroupTree(token?: string | null): Promise<RuleGroupNode[]> {
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<number, ChildGroupStats> {
const stats: Record<number, ChildGroupStats> = {};
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<GroupFormState>) => 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 (
<div className="rg-modal-backdrop">
<div className="rg-modal">
<div className="rg-modal-header">
<h3>{form.mode === "create" ? (isRoot ? "新增一级分组" : "新增二级分组") : "编辑分组"}</h3>
<button type="button" className="icon-button" onClick={onClose}>
<i className="ri-close-line"></i>
</button>
</div>
<div className="rg-modal-body grid-form">
<div className="form-item">
<label></label>
<select
value={String(form.pid)}
onChange={(event) => {
const nextPid = Number(event.target.value);
const nextParent = topGroups.find((group) => group.id === nextPid);
onChange({
pid: nextPid,
documentTypeId: nextPid === 0 ? form.documentTypeId : String(nextParent?.document_type_id || ""),
entryModuleId: nextPid === 0 ? form.entryModuleId : String(nextParent?.entry_module_id || ""),
});
}}
>
<option value="0"></option>
{topGroups.map((group) => (
<option key={group.id} value={group.id}>{`二级分组(上级:${group.name}`}</option>
))}
</select>
</div>
<div className="form-item">
<label></label>
<input value={form.name} onChange={(event) => onChange({ name: event.target.value })} placeholder={isRoot ? "如:合同、卷宗、内部公文" : "如:建设工程合同、处罚-一般程序"} />
</div>
<div className="form-item">
<label></label>
<input value={form.code} onChange={(event) => onChange({ code: event.target.value })} placeholder="请输入唯一编码" />
</div>
<div className="form-item">
<label></label>
<input type="number" value={form.sortOrder} onChange={(event) => onChange({ sortOrder: Number(event.target.value || 0) })} />
</div>
<div className="form-item form-item-span-2">
<label></label>
<select disabled={!isRoot} value={entryModuleValue} onChange={(event) => onChange({ entryModuleId: event.target.value })}>
<option value="">{isRoot ? "可稍后绑定入口模块" : "继承上级入口模块"}</option>
{entryModules.map((item) => (
<option key={item.id} value={String(item.id)}>{item.name}</option>
))}
</select>
<p className="field-tip">
{isRoot
? "一级分组承载业务大类,可先创建再补绑入口模块;例如合同、卷宗、后续新增业务。"
: "二级分组通常继承上级入口模块,用来承接该大类下的具体业务类型。"}
</p>
</div>
<div className="form-item form-item-span-2">
<label></label>
<select disabled={isRoot ? false : !!selectedRootDocTypeId} value={docTypeValue} onChange={(event) => onChange({ documentTypeId: event.target.value })}>
<option value="">{isRoot ? "一级分组可不直接绑定文档类型" : "请选择该二级对应的具体文档类型"}</option>
{docTypes
.filter((item) => item.isEnabled)
.map((item) => (
<option key={item.id} value={String(item.id)}>{`${item.name} (${item.code})`}</option>
))}
</select>
<p className="field-tip">
{isRoot
? "一级分组默认代表业务大类容器;这个字段只用于兼容旧数据,新建业务大类通常可留空。"
: "二级分组对应实际业务类型,如建设工程合同、处罚-一般程序、许可-停业办理,再往下绑定运行规则集。"}
</p>
</div>
<div className="form-item form-item-span-2">
<label></label>
<textarea value={form.description} onChange={(event) => onChange({ description: event.target.value })} rows={4} placeholder="可选,说明这个分组的业务范围" />
</div>
<label className="switch-row form-item-span-2">
<input type="checkbox" checked={form.isEnabled} onChange={(event) => onChange({ isEnabled: event.target.checked })} />
<span></span>
</label>
</div>
<div className="rg-modal-footer">
<Button type="default" onClick={onClose}></Button>
<Button type="primary" onClick={onSubmit} disabled={saving}>{saving ? "提交中..." : "保存"}</Button>
</div>
</div>
</div>
);
}
function BindingModal({
visible,
form,
group,
allRuleSets,
siblingGroupCount,
onClose,
onChange,
onSubmit,
saving,
}: {
visible: boolean;
form: BindingFormState;
group: RuleGroupNode | null;
allRuleSets: RuleSetOption[];
siblingGroupCount: number;
onClose: () => void;
onChange: (patch: Partial<BindingFormState>) => void;
onSubmit: () => void;
saving: boolean;
}) {
const [keyword, setKeyword] = useState("");
useEffect(() => {
if (!visible) setKeyword("");
}, [visible]);
if (!visible || !group) return null;
const usedIds = new Set(group.bindings.filter((item) => item.id !== form.bindingId).map((item) => item.rule_set_id));
const filteredRuleSets = allRuleSets.filter((item) => {
if (form.mode === "create" && usedIds.has(item.id)) return false;
if (!keyword.trim()) return true;
const target = `${item.ruleName} ${item.ruleType}`.toLowerCase();
return target.includes(keyword.trim().toLowerCase());
});
return (
<div className="rg-modal-backdrop">
<div className="rg-modal large">
<div className="rg-modal-header">
<h3>{form.mode === "create" ? `为「${getSubtypeGroupDisplayName(group)}」绑定规则集` : `调整「${getSubtypeGroupDisplayName(group)}」绑定`}</h3>
<button type="button" className="icon-button" onClick={onClose}>
<i className="ri-close-line"></i>
</button>
</div>
<div className="rg-modal-body grid-form">
<div className="form-item form-item-span-2">
<div className="modal-tip">
<strong>{group.document_type_name || "未绑定文档类型"}</strong>
<span>
{group.entry_module_name || "未分配入口模块"}
{siblingGroupCount > 1 ? ` · 当前一级共 ${siblingGroupCount} 个二级分组` : ""}
{` · 当前组已绑定 ${group.bindings.length} 个规则集`}
</span>
</div>
</div>
<div className="form-item form-item-span-2">
<div className="detail-alert info compact">
<strong></strong>
<span> </span>
</div>
</div>
<div className="form-item form-item-span-2">
<label></label>
<input value={keyword} onChange={(event) => setKeyword(event.target.value)} placeholder="按规则集名称 / 类型搜索" />
</div>
<div className="form-item form-item-span-2">
<label></label>
<div className="rule-set-scroll-box">
{filteredRuleSets.length > 0 ? filteredRuleSets.map((item) => {
const selected = form.ruleSetId === String(item.id);
return (
<button
key={item.id}
type="button"
className={`rule-set-option ${selected ? "active" : ""}`}
onClick={() => onChange({ ruleSetId: String(item.id) })}
>
<span>
<strong>{item.ruleName}</strong>
<em>{item.ruleType}</em>
</span>
<span className={`mini-badge ${item.hasUsableVersion ? "ok" : "warn"}`}>
{item.hasUsableVersion ? `可用规则数 ${item.usableRuleCount || 0}` : "未发布"}
</span>
</button>
);
}) : <div className="empty-block"></div>}
</div>
</div>
<div className="form-item">
<label></label>
<input type="number" value={form.priority} onChange={(event) => onChange({ priority: Number(event.target.value || 0) })} />
</div>
<label className="switch-row form-item">
<input type="checkbox" checked={form.isActive} onChange={(event) => onChange({ isActive: event.target.checked })} />
<span></span>
</label>
<div className="form-item form-item-span-2">
<label></label>
<textarea rows={3} value={form.note} onChange={(event) => onChange({ note: event.target.value })} placeholder="可选,记录这个规则集在当前二级分组下的用途" />
</div>
</div>
<div className="rg-modal-footer">
<Button type="default" onClick={onClose}></Button>
<Button type="primary" onClick={onSubmit} disabled={saving}>{saving ? "提交中..." : "保存绑定"}</Button>
</div>
</div>
</div>
);
}
export default function RuleGroupsIndex() {
const { groups, docTypes, entryModules, ruleSets, frontendJWT } = useLoaderData<LoaderData>();
const [searchParams, setSearchParams] = useSearchParams();
const topGroups = useMemo(() => toTopGroups(groups), [groups]);
const nameValue = searchParams.get("name") || "";
const codeValue = searchParams.get("code") || "";
const statusValue = searchParams.get("status") || "";
const moduleValue = searchParams.get("module") || "";
const docTypeValue = searchParams.get("docType") || "";
const healthValue = searchParams.get("health") || "";
const [expandedGroupIds, setExpandedGroupIds] = useState<Set<number>>(new Set(topGroups.map((item) => item.id)));
const [expandedBindingIds, setExpandedBindingIds] = useState<Set<number>>(new Set());
const [rulePreviewMap, setRulePreviewMap] = useState<Record<number, RulePreviewState>>({});
const [groupModalOpen, setGroupModalOpen] = useState(false);
const [bindingModalOpen, setBindingModalOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [groupForm, setGroupForm] = useState<GroupFormState>({
mode: "create",
pid: 0,
name: "",
code: "",
description: "",
documentTypeId: "",
entryModuleId: "",
sortOrder: 0,
isEnabled: true,
});
const [bindingForm, setBindingForm] = useState<BindingFormState>({
mode: "create",
groupId: 0,
ruleSetId: "",
priority: 100,
note: "",
isActive: true,
});
const [activeBindingGroup, setActiveBindingGroup] = useState<RuleGroupNode | null>(null);
const mountedRef = useRef(false);
const childGroupStats = useMemo(() => buildChildGroupStats(topGroups), [topGroups]);
const filteredGroups = useMemo(() => {
return topGroups
.map((root) => {
const rootNameTarget = [root.name, root.entry_module_name || "", root.document_type_name || ""].join(" ").toLowerCase();
const rootCodeTarget = [root.code, root.entry_module_name || ""].join(" ").toLowerCase();
const rootMatchesName = !nameValue.trim() || rootNameTarget.includes(nameValue.trim().toLowerCase());
const rootMatchesCode = !codeValue.trim() || rootCodeTarget.includes(codeValue.trim().toLowerCase());
const rootMatchesModule = !moduleValue || String(root.entry_module_id || "") === moduleValue;
const rootMatchesStatus = statusValue === "enabled" ? root.is_enabled : statusValue === "disabled" ? !root.is_enabled : true;
const children = (root.children || []).filter((child) => {
if (moduleValue && String(child.entry_module_id || "") !== moduleValue) return false;
if (docTypeValue && String(child.document_type_id || "") !== docTypeValue) return false;
if (statusValue === "enabled" && !child.is_enabled) return false;
if (statusValue === "disabled" && child.is_enabled) return false;
if (healthValue && getChildGroupHealth(child) !== healthValue) return false;
if (nameValue.trim()) {
const target = [root.name, child.name, child.document_type_name || "", ...child.bindings.map((item) => item.rule_name || "")].join(" ").toLowerCase();
if (!target.includes(nameValue.trim().toLowerCase())) return false;
}
if (codeValue.trim()) {
const target = [root.code, child.code, child.document_type_name || "", ...child.bindings.map((item) => item.rule_type || "")].join(" ").toLowerCase();
if (!target.includes(codeValue.trim().toLowerCase())) return false;
}
return true;
});
const shouldKeepRootWithoutChildren = !docTypeValue && !healthValue && children.length === 0 && rootMatchesName && rootMatchesCode && rootMatchesModule && rootMatchesStatus;
if (!children.length && !shouldKeepRootWithoutChildren) return null;
return { ...root, children };
})
.filter(Boolean) as RuleGroupNode[];
}, [codeValue, docTypeValue, healthValue, moduleValue, nameValue, statusValue, topGroups]);
const totalVisibleRows = filteredGroups.reduce((sum, root) => {
const childRows = root.children?.length || 0;
const bindingRows = expandedGroupIds.has(root.id)
? (root.children || []).reduce((bindingSum, child) => bindingSum + child.bindings.length, 0)
: 0;
return sum + 1 + childRows + bindingRows;
}, 0);
const summaryMetrics = useMemo(() => {
const childGroups = topGroups.flatMap((root) => root.children || []);
return {
rootCount: topGroups.length,
childCount: childGroups.length,
readyCount: childGroups.filter((item) => getChildGroupHealth(item) === "ready").length,
warningCount: childGroups.filter((item) => getChildGroupHealth(item) !== "ready").length,
unboundRootCount: topGroups.filter((item) => !item.entry_module_id).length,
};
}, [topGroups]);
const handleParamChange = (key: string, value: string) => {
const next = new URLSearchParams(searchParams);
if (value) next.set(key, value);
else next.delete(key);
setSearchParams(next);
};
const resetFilters = () => setSearchParams(new URLSearchParams());
const reloadPage = () => {
if (typeof window !== "undefined") window.location.reload();
};
const handleApiError = (error: any) => {
const message = error?.response?.data?.msg || error?.response?.data?.detail || error?.message || "操作失败";
window.alert(String(message));
};
const openCreateRoot = () => {
setGroupForm({ mode: "create", pid: 0, name: "", code: "", description: "", documentTypeId: "", entryModuleId: "", sortOrder: 0, isEnabled: true });
setGroupModalOpen(true);
};
const openCreateChild = (root: RuleGroupNode) => {
setGroupForm({
mode: "create",
pid: root.id,
name: "",
code: "",
description: "",
documentTypeId: root.document_type_id ? String(root.document_type_id) : "",
entryModuleId: root.entry_module_id ? String(root.entry_module_id) : "",
sortOrder: 0,
isEnabled: true,
});
setGroupModalOpen(true);
};
const openEditGroup = (group: RuleGroupNode) => {
setGroupForm({
id: group.id,
mode: "edit",
pid: group.pid,
name: group.name,
code: group.code,
description: group.description || "",
documentTypeId: group.document_type_id ? String(group.document_type_id) : "",
entryModuleId: group.entry_module_id ? String(group.entry_module_id) : "",
sortOrder: group.sort_order || 0,
isEnabled: group.is_enabled,
});
setGroupModalOpen(true);
};
const openCreateBinding = (group: RuleGroupNode) => {
setActiveBindingGroup(group);
setBindingForm({ mode: "create", groupId: group.id, ruleSetId: "", priority: 100, note: "", isActive: true });
setBindingModalOpen(true);
};
const openEditBinding = (group: RuleGroupNode, binding: BindingItem) => {
setActiveBindingGroup(group);
setBindingForm({
mode: "edit",
groupId: group.id,
bindingId: binding.id,
ruleSetId: String(binding.rule_set_id),
priority: binding.priority,
note: binding.note || "",
isActive: binding.is_active,
});
setBindingModalOpen(true);
};
const submitGroup = async () => {
if (!groupForm.name.trim()) return window.alert("请填写分组名称");
if (!groupForm.code.trim()) return window.alert("请填写分组编码");
if (groupForm.pid !== 0 && !groupForm.documentTypeId) return window.alert("二级分组必须绑定具体文档类型");
setSaving(true);
try {
const payload = {
name: groupForm.name.trim(),
code: groupForm.code.trim(),
pid: groupForm.pid === 0 ? 0 : groupForm.pid,
description: groupForm.description.trim() || null,
document_type_id: groupForm.documentTypeId ? Number(groupForm.documentTypeId) : null,
entry_module_id: groupForm.entryModuleId ? Number(groupForm.entryModuleId) : null,
sort_order: groupForm.sortOrder,
is_enabled: groupForm.isEnabled,
};
if (groupForm.mode === "create") {
await axios.post(`${API_BASE_URL}/api/v3/evaluation-point-groups`, payload, { headers: { ...authHeaders(frontendJWT) } });
} else {
await axios.put(`${API_BASE_URL}/api/v3/evaluation-point-groups/${groupForm.id}`, payload, { headers: { ...authHeaders(frontendJWT) } });
}
reloadPage();
} catch (error) {
handleApiError(error);
} finally {
setSaving(false);
}
};
const submitBinding = async () => {
if (!bindingForm.ruleSetId) return window.alert("请选择规则集");
setSaving(true);
try {
const payload = {
rule_set_id: Number(bindingForm.ruleSetId),
priority: bindingForm.priority,
note: bindingForm.note.trim() || null,
is_active: bindingForm.isActive,
};
if (bindingForm.mode === "create") {
await axios.post(`${API_BASE_URL}/api/v3/evaluation-point-groups/${bindingForm.groupId}/bindings`, payload, { headers: { ...authHeaders(frontendJWT) } });
} else {
await axios.put(`${API_BASE_URL}/api/v3/evaluation-point-groups/bindings/${bindingForm.bindingId}`, payload, { headers: { ...authHeaders(frontendJWT) } });
}
reloadPage();
} catch (error) {
handleApiError(error);
} finally {
setSaving(false);
}
};
const deleteGroup = async (group: RuleGroupNode) => {
const label = group.pid === 0 ? `一级分组「${group.name}` : `二级分组「${group.name}`;
if (!window.confirm(`确认删除${label}吗?`)) return;
try {
const response = await axios.delete(`${API_BASE_URL}/api/v3/evaluation-point-groups/${group.id}`, { headers: authHeaders(frontendJWT) });
const payload = response?.data?.data ?? response?.data;
if (payload?.success === false) return window.alert(payload?.message || "删除失败");
reloadPage();
} catch (error) {
handleApiError(error);
}
};
const deleteBinding = async (binding: BindingItem) => {
if (!window.confirm(`确认解除规则集「${binding.rule_name || binding.rule_type || binding.rule_set_id}」吗?`)) return;
try {
await axios.delete(`${API_BASE_URL}/api/v3/evaluation-point-groups/bindings/${binding.id}`, { headers: authHeaders(frontendJWT) });
reloadPage();
} catch (error) {
handleApiError(error);
}
};
const toggleRoot = (groupId: number) => {
setExpandedGroupIds((prev) => {
const next = new Set(prev);
if (next.has(groupId)) next.delete(groupId);
else next.add(groupId);
return next;
});
};
const loadRulePreview = async (binding: BindingItem) => {
if (rulePreviewMap[binding.id]?.loaded || rulePreviewMap[binding.id]?.loading) return;
const versionId = binding.current_version_id || binding.fallback_version_id;
if (!versionId) {
setRulePreviewMap((prev) => ({ ...prev, [binding.id]: { loading: false, loaded: true, error: "当前规则集没有可读取版本", rules: [] } }));
return;
}
setRulePreviewMap((prev) => ({ ...prev, [binding.id]: { loading: true, loaded: false, error: null, rules: [] } }));
try {
const response = await axios.get(`${API_BASE_URL}/api/rule-sets/versions/${versionId}/content`, { headers: authHeaders(frontendJWT) });
const payload = response?.data?.data ?? response?.data ?? null;
const yamlText = typeof payload?.yamlText === "string" ? payload.yamlText : "";
const rules = yamlText.trim() ? parseRuleSummariesFromYaml(yamlText) : [];
setRulePreviewMap((prev) => ({ ...prev, [binding.id]: { loading: false, loaded: true, error: yamlText.trim() ? null : "规则 YAML 为空", rules } }));
} catch (error: any) {
const message = error?.response?.data?.msg || error?.message || "规则内容加载失败";
setRulePreviewMap((prev) => ({ ...prev, [binding.id]: { loading: false, loaded: true, error: String(message), rules: [] } }));
}
};
const toggleBindingPreview = (binding: BindingItem) => {
setExpandedBindingIds((prev) => {
const next = new Set(prev);
if (next.has(binding.id)) next.delete(binding.id);
else next.add(binding.id);
return next;
});
};
useEffect(() => {
if (!mountedRef.current) {
mountedRef.current = true;
return;
}
expandedBindingIds.forEach((bindingId) => {
for (const root of filteredGroups) {
const match = (root.children || []).flatMap((child) => child.bindings).find((item) => item.id === bindingId);
if (match) {
void loadRulePreview(match);
break;
}
}
});
}, [expandedBindingIds, filteredGroups]);
return (
<div className="rule-groups-page">
<div className="page-header">
<div>
<h2 className="page-title">
<span className="page-count">{totalVisibleRows}</span>
</h2>
<p className="page-subtitle"> </p>
</div>
<div className="page-actions">
<Button type="default" icon="ri-expand-up-down-line" onClick={() => setExpandedGroupIds(new Set(filteredGroups.map((item) => item.id)))}></Button>
<Button type="default" icon="ri-collapse-diagonal-line" onClick={() => { setExpandedGroupIds(new Set()); setExpandedBindingIds(new Set()); }}></Button>
<Button type="primary" icon="ri-add-line" onClick={openCreateRoot}></Button>
</div>
</div>
<Card className="filter-card">
<div className="summary-metrics">
<div className="metric-box">
<span className="metric-label"></span>
<strong>{summaryMetrics.rootCount}</strong>
<em>{summaryMetrics.unboundRootCount > 0 ? `其中 ${summaryMetrics.unboundRootCount} 个待绑定入口模块` : "业务大类容器,可绑定入口模块"}</em>
</div>
<div className="metric-box">
<span className="metric-label"></span>
<strong>{summaryMetrics.childCount}</strong>
<em></em>
</div>
<div className="metric-box">
<span className="metric-label"></span>
<strong>{summaryMetrics.readyCount}</strong>
<em></em>
</div>
<div className="metric-box warning">
<span className="metric-label"></span>
<strong>{summaryMetrics.warningCount}</strong>
<em> / / </em>
</div>
</div>
<div className="logic-strip">
<div className="logic-item">
<strong></strong>
<span></span>
</div>
<div className="logic-arrow"></div>
<div className="logic-item">
<strong></strong>
<span>-</span>
</div>
<div className="logic-arrow"></div>
<div className="logic-item">
<strong></strong>
<span></span>
</div>
</div>
</Card>
<Card className="filter-card">
<div className="filter-row">
<div className="filter-item">
<label></label>
<input value={nameValue} onChange={(event) => handleParamChange("name", event.target.value)} placeholder="搜索一级分组 / 二级分组 / 规则集" />
</div>
<div className="filter-item">
<label></label>
<input value={codeValue} onChange={(event) => handleParamChange("code", event.target.value)} placeholder="搜索分组编码或规则类型" />
</div>
<div className="filter-item">
<label></label>
<select value={moduleValue} onChange={(event) => handleParamChange("module", event.target.value)}>
<option value=""></option>
{entryModules.map((item) => <option key={item.id} value={String(item.id)}>{item.name}</option>)}
</select>
</div>
<div className="filter-item">
<label></label>
<select value={docTypeValue} onChange={(event) => handleParamChange("docType", event.target.value)}>
<option value=""></option>
{docTypes.filter((item) => item.isEnabled).map((item) => (
<option key={item.id} value={String(item.id)}>{item.name}</option>
))}
</select>
</div>
<div className="filter-item filter-small">
<label></label>
<select value={statusValue} onChange={(event) => handleParamChange("status", event.target.value)}>
<option value=""></option>
<option value="enabled"></option>
<option value="disabled"></option>
</select>
</div>
<div className="filter-item">
<label></label>
<select value={healthValue} onChange={(event) => handleParamChange("health", event.target.value)}>
<option value=""></option>
<option value="ready"></option>
<option value="partial"></option>
<option value="empty"></option>
</select>
</div>
<div className="filter-actions-row">
<Button type="default" icon="ri-refresh-line" onClick={resetFilters}></Button>
</div>
</div>
</Card>
<Card className="table-card">
<div className="table-wrap">
<table className="group-table">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{filteredGroups.length > 0 ? filteredGroups.map((root) => {
const expanded = expandedGroupIds.has(root.id);
return (
<Fragment key={root.id}>
<tr className="group-row level-root">
<td>
<button type="button" className="name-cell-button" onClick={() => toggleRoot(root.id)}>
<span className="expand-icon"><i className={expanded ? "ri-arrow-down-s-line" : "ri-arrow-right-s-line"}></i></span>
<span className="name-cell-text">
<strong>{root.name}</strong>
<em>{getRootGroupSubtitle(root)}</em>
</span>
</button>
</td>
<td>
<div className="meta-cell">
<strong>{root.entry_module_name ? `入口模块:${root.entry_module_name}` : (root.document_type_name || root.name)}</strong>
<span>{getRootGroupDescription(root)}</span>
<span> {(root.children || []).length} · {root.code}</span>
</div>
</td>
<td>
<div className="meta-cell">
<strong></strong>
<span></span>
</div>
</td>
<td>
<span className={`status-badge ${root.is_enabled ? "enabled" : "disabled"}`}>{root.is_enabled ? "启用" : "禁用"}</span>
<span className={`mini-badge ${getRootGroupHealth(root) === "ready" ? "ok" : "warn"}`}>{getRootGroupStatusText(root)}</span>
{getRootGroupWarningText(root) ? (
<div className="warning-inline">{getRootGroupWarningText(root)}</div>
) : (
<div className="hint-inline"></div>
)}
</td>
<td>{formatDateTime(root.updated_at || root.created_at)}</td>
<td>
<div className="action-links">
<button type="button" className="action-link button-link" onClick={() => openCreateChild(root)}></button>
<button type="button" className="action-link button-link" onClick={() => openEditGroup(root)}></button>
<button type="button" className="action-link danger button-link" onClick={() => deleteGroup(root)}></button>
</div>
</td>
</tr>
{expanded ? (root.children || []).map((child) => (
<Fragment key={child.id}>
<tr className="group-row level-child">
<td>
<div className="name-cell-button child static">
<span className="expand-icon placeholder"></span>
<span className="name-cell-text">
<strong>{getSubtypeGroupDisplayName(child)}</strong>
<em>
· {child.document_type_name || "未绑定文档类型"}
{child.entry_module_name ? ` · ${child.entry_module_name}` : ""}
{(childGroupStats[child.id]?.siblingCount || 0) > 1 ? ` · 第 ${childGroupStats[child.id].siblingIndex} 个二级分组` : ""}
</em>
</span>
</div>
</td>
<td>
<div className="meta-cell">
<strong>{child.document_type_name || "未绑定文档类型"}</strong>
<span>{root.name} · {child.code}</span>
</div>
</td>
<td>
<div className="meta-cell">
<strong>{getChildGroupStatusText(child)}</strong>
<span>
{child.bindings.length} · {getChildGroupReadyBindingCount(child)} · {getChildGroupUsableRuleCount(child)}
</span>
</div>
</td>
<td>
<span className={`status-badge ${child.is_enabled ? "enabled" : "disabled"}`}>{child.is_enabled ? "启用" : "禁用"}</span>
<span className={`mini-badge ${getChildGroupHealth(child) === "ready" ? "ok" : "warn"}`}>
{getChildGroupHealth(child) === "ready" ? "可运行" : getChildGroupHealth(child) === "partial" ? "待整理" : "未绑定"}
</span>
{getChildGroupWarningText(child) ? (
<div className="warning-inline">{getChildGroupWarningText(child)}</div>
) : (
<div className="hint-inline"></div>
)}
</td>
<td>{formatDateTime(child.updated_at || child.created_at)}</td>
<td>
<div className="action-links">
<button
type="button"
className="action-link button-link"
onClick={() => {
setExpandedBindingIds((prev) => {
const next = new Set(prev);
const isExpanded = child.bindings.every((item) => next.has(item.id));
child.bindings.forEach((item) => {
if (isExpanded) next.delete(item.id);
else next.add(item.id);
});
return next;
});
}}
>
{child.bindings.length ? (child.bindings.every((item) => expandedBindingIds.has(item.id)) ? "收起规则集" : "查看规则集") : "暂无规则集"}
</button>
<button type="button" className="action-link button-link" onClick={() => openCreateBinding(child)}></button>
<button type="button" className="action-link button-link" onClick={() => openEditGroup(child)}></button>
<button type="button" className="action-link danger button-link" onClick={() => deleteGroup(child)}></button>
</div>
</td>
</tr>
{child.bindings.map((binding) => {
const expandedBinding = expandedBindingIds.has(binding.id);
const previewState = rulePreviewMap[binding.id];
return (
<Fragment key={binding.id}>
<tr className="group-row level-binding">
<td>
<button type="button" className="name-cell-button child" onClick={() => toggleBindingPreview(binding)}>
<span className="expand-icon">
<i className={expandedBinding ? "ri-arrow-down-s-line" : "ri-arrow-right-s-line"}></i>
</span>
<span className="name-cell-text">
<strong>{binding.rule_name || `规则集 #${binding.rule_set_id}`}</strong>
<em>{binding.rule_type || "未标记规则类型"} · {formatVersionLabel(binding)}</em>
</span>
</button>
</td>
<td>
<div className="meta-cell">
<strong>{getSubtypeGroupDisplayName(child)}</strong>
<span> {binding.priority} · {binding.note || "未填写运行备注"}</span>
</div>
</td>
<td>
<div className="meta-cell">
<strong>{getBindingStatusText(binding)}</strong>
<span> {binding.usable_rule_count || 0} · #{binding.rule_type_binding_id || "-"}</span>
</div>
</td>
<td>
<span className={`status-badge ${binding.is_active ? "enabled" : "disabled"}`}>{binding.is_active ? "启用" : "停用"}</span>
<span className={`mini-badge ${getBindingStatusClass(binding)}`}>{getBindingStatusText(binding)}</span>
</td>
<td>-</td>
<td>
<div className="action-links">
<button type="button" className="action-link button-link" onClick={() => toggleBindingPreview(binding)}>
{expandedBinding ? "收起规则明细" : "查看组内规则"}
</button>
<button type="button" className="action-link button-link" onClick={() => openEditBinding(child, binding)}></button>
<button type="button" className="action-link danger button-link" onClick={() => deleteBinding(binding)}></button>
</div>
</td>
</tr>
{expandedBinding ? (
<tr className="preview-row">
<td colSpan={6}>
<div className="preview-panel">
<div className="preview-header-row">
<strong>{binding.rule_name || binding.rule_type || `规则集 #${binding.rule_set_id}`}</strong>
<span>{formatVersionLabel(binding)} · {binding.usable_rule_count || 0}</span>
</div>
{previewState?.loading ? (
<div className="preview-empty">...</div>
) : previewState?.error ? (
<div className="preview-empty">{previewState.error}</div>
) : previewState?.rules?.length ? (
<div className="preview-table-wrap">
<table className="preview-table">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{previewState.rules.map((rule, index) => (
<tr key={`${binding.id}-${rule.code || index}`}>
<td>{rule.code || "-"}</td>
<td>{rule.name || "-"}</td>
<td>{rule.severity || "-"}</td>
<td>{rule.description || rule.scope || "-"}</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="preview-empty"></div>
)}
</div>
</td>
</tr>
) : null}
</Fragment>
);
})}
</Fragment>
)) : null}
</Fragment>
);
}) : (
<tr>
<td colSpan={6} className="empty-cell"></td>
</tr>
)}
</tbody>
</table>
</div>
</Card>
<GroupModal
visible={groupModalOpen}
form={groupForm}
topGroups={topGroups}
docTypes={docTypes}
entryModules={entryModules}
onClose={() => setGroupModalOpen(false)}
onChange={(patch) => setGroupForm((prev) => ({ ...prev, ...patch }))}
onSubmit={submitGroup}
saving={saving}
/>
<BindingModal
visible={bindingModalOpen}
form={bindingForm}
group={activeBindingGroup}
allRuleSets={ruleSets}
siblingGroupCount={activeBindingGroup ? (childGroupStats[activeBindingGroup.id]?.siblingCount || 1) : 1}
onClose={() => setBindingModalOpen(false)}
onChange={(patch) => setBindingForm((prev) => ({ ...prev, ...patch }))}
onSubmit={submitBinding}
saving={saving}
/>
</div>
);
}