Files
leaudit-platform-frontend/app/routes/rule-groups._index.tsx
T
2026-05-06 20:06:41 +08:00

1501 lines
67 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, 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<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, userInfo } = await getUserSession(request);
const { requireRoutePermission } = await import("~/api/auth/check-route-permission.server");
await requireRoutePermission("/rule-groups", userInfo?.role || "", 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<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[];
};
type RuleDraftFormState = {
groupId: number;
yamlText: string;
changeNote: string;
template: RuleTemplatePayload | null;
loading: boolean;
saving: boolean;
error: string | null;
success: RuleDraftCreateResult | null;
};
function unwrapApiData<T>(payload: any): T {
return (payload?.data ?? payload) as T;
}
function normalizeRuleTemplatePayload(payload: any): RuleTemplatePayload {
const item = unwrapApiData<any>(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<any>(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<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>
);
}
function RuleDraftModal({
visible,
group,
form,
onClose,
onChange,
onSubmit,
onOpenRules,
}: {
visible: boolean;
group: RuleGroupNode | null;
form: RuleDraftFormState;
onClose: () => void;
onChange: (patch: Partial<RuleDraftFormState>) => void;
onSubmit: () => void;
onOpenRules: () => void;
}) {
if (!visible || !group) return null;
const template = form.template;
return (
<div className="rg-modal-backdrop">
<div className="rg-modal large">
<div className="rg-modal-header">
<h3> / YAML</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>{getSubtypeGroupDisplayName(group)}</strong>
<span>
{template?.parentGroupName || "-"} · {group.document_type_name || template?.documentTypeName || "-"}
{` · 入口模块:${group.entry_module_name || template?.entryModuleName || "-"}`}
</span>
</div>
</div>
<div className="form-item form-item-span-2">
<div className="detail-alert info compact">
<strong> YAML 稿</strong>
<span></span>
</div>
</div>
<div className="form-item">
<label></label>
<input value={template?.ruleType || ""} readOnly placeholder={form.loading ? "模板加载中..." : "等待后端返回"} />
</div>
<div className="form-item">
<label></label>
<input value={template?.ruleName || ""} readOnly placeholder={form.loading ? "模板加载中..." : "等待后端返回"} />
</div>
<div className="form-item form-item-span-2">
<label>OSS </label>
<input value={template?.ossPreviewKey || ""} readOnly placeholder={form.loading ? "模板加载中..." : "等待后端返回"} />
<p className="field-tip"></p>
</div>
<div className="form-item form-item-span-2">
<label></label>
<input
value={form.changeNote}
onChange={(event) => onChange({ changeNote: event.target.value, success: null })}
placeholder="例如:初始化建设工程合同规则草稿"
/>
</div>
<div className="form-item form-item-span-2">
<label> YAML</label>
<textarea
rows={24}
value={form.yamlText}
onChange={(event) => onChange({ yamlText: event.target.value, error: null, success: null })}
placeholder={form.loading ? "模板加载中..." : "这里显示后端生成的完整 YAML 模板"}
/>
<p className="field-tip"> YAML </p>
</div>
{form.error ? (
<div className="form-item form-item-span-2">
<div className="detail-alert danger compact">
<strong></strong>
<span>{form.error}</span>
</div>
</div>
) : null}
{form.success ? (
<div className="form-item form-item-span-2">
<div className="detail-alert success compact">
<strong>稿</strong>
<span>
{form.success.ruleName || form.success.ruleSetName || template?.ruleName || "规则集"} ·
{` 版本 ${form.success.versionNo || form.success.versionId || "已创建"} `}
{form.success.bindingId ? `· 已同步绑定 #${form.success.bindingId}` : "· 绑定结果以后端返回为准"}
</span>
</div>
</div>
) : null}
</div>
<div className="rg-modal-footer">
<Button type="default" onClick={onClose}></Button>
{form.success ? (
<Button type="primary" onClick={onOpenRules}></Button>
) : (
<Button type="primary" onClick={onSubmit} disabled={form.loading || form.saving}>
{form.loading ? "模板加载中..." : form.saving ? "保存中..." : "保存规则草稿"}
</Button>
)}
</div>
</div>
</div>
);
}
export default function RuleGroupsIndex() {
const { groups, docTypes, entryModules, ruleSets, frontendJWT } = useLoaderData<LoaderData>();
const navigate = useNavigate();
const { hasAnyPermission } = usePermission();
const [searchParams, setSearchParams] = useSearchParams();
const [groupTree, setGroupTree] = useState<RuleGroupNode[]>(groups);
const topGroups = useMemo(() => toTopGroups(groupTree), [groupTree]);
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 [draftModalOpen, setDraftModalOpen] = 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 [activeDraftGroup, setActiveDraftGroup] = useState<RuleGroupNode | null>(null);
const [draftForm, setDraftForm] = useState<RuleDraftFormState>({
groupId: 0,
yamlText: "",
changeNote: "",
template: null,
loading: false,
saving: false,
error: null,
success: null,
});
const mountedRef = useRef(false);
const childGroupStats = useMemo(() => buildChildGroupStats(topGroups), [topGroups]);
const canManageGroups = hasAnyPermission(["evaluation_group:create:write", "evaluation_group:update:write"]);
const canManageBindings = hasAnyPermission(["evaluation_group:update:write"]);
const canCreateRuleDraft = hasAnyPermission(["evaluation_group:update:write", "rules:create:write"]);
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 = async (): Promise<RuleGroupNode[]> => {
const nextGroups = await fetchGroupTree(frontendJWT);
setGroupTree(nextGroups);
setActiveBindingGroup((current) => (current ? findGroupNode(nextGroups, current.id) : null));
setActiveDraftGroup((current) => (current ? findGroupNode(nextGroups, current.id) : null));
return nextGroups;
};
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 openCreateRuleDraft = async (group: RuleGroupNode) => {
setActiveDraftGroup(group);
setDraftForm({
groupId: group.id,
yamlText: "",
changeNote: "",
template: null,
loading: true,
saving: false,
error: null,
success: null,
});
setDraftModalOpen(true);
try {
const response = await axios.get(`${API_BASE_URL}/api/v3/evaluation-point-groups/${group.id}/rule-template`, {
headers: authHeaders(frontendJWT),
});
const template = normalizeRuleTemplatePayload(response?.data);
const yamlText = String(template?.yamlTemplate || template?.yamlText || "");
setDraftForm((prev) => ({
...prev,
loading: false,
template,
yamlText,
changeNote: prev.changeNote || `初始化 ${getSubtypeGroupDisplayName(group)} 规则草稿`,
error: yamlText.trim() ? null : "后端未返回可编辑的 YAML 模板",
}));
} catch (error: any) {
const message = error?.response?.data?.msg || error?.response?.data?.detail || error?.message || "模板加载失败";
setDraftForm((prev) => ({ ...prev, loading: false, error: String(message) }));
}
};
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) } });
}
await reloadPage();
setGroupModalOpen(false);
} 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) } });
}
await reloadPage();
setBindingModalOpen(false);
} 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 || "删除失败");
await 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) });
await reloadPage();
} catch (error) {
handleApiError(error);
}
};
const submitRuleDraft = async () => {
if (!activeDraftGroup) return;
if (!draftForm.yamlText.trim()) {
setDraftForm((prev) => ({ ...prev, error: "请先确认 YAML 内容后再保存" }));
return;
}
setDraftForm((prev) => ({ ...prev, saving: true, error: null }));
try {
const payload = {
yaml_text: draftForm.yamlText,
change_note: draftForm.changeNote.trim() || null,
};
const response = await axios.post(
`${API_BASE_URL}/api/v3/evaluation-point-groups/${activeDraftGroup.id}/rule-drafts`,
payload,
{ headers: authHeaders(frontendJWT) },
);
const result = normalizeRuleDraftCreateResult(response?.data);
const nextGroups = await reloadPage();
const refreshedGroup = findGroupNode(nextGroups, activeDraftGroup.id) || activeDraftGroup;
setActiveDraftGroup(refreshedGroup);
setDraftForm((prev) => ({ ...prev, saving: false, success: result }));
} catch (error: any) {
const message = error?.response?.data?.msg || error?.response?.data?.detail || error?.message || "规则草稿保存失败";
setDraftForm((prev) => ({ ...prev, saving: false, error: String(message) }));
}
};
const openRuleDraftResult = () => {
const target = buildRuleDraftJumpUrl(activeDraftGroup, draftForm.success, draftForm.template);
navigate(target);
};
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>
{canManageGroups ? (
<Button type="primary" icon="ri-add-line" onClick={openCreateRoot}></Button>
) : null}
</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">
{canManageGroups ? (
<button type="button" className="action-link button-link" onClick={() => openCreateChild(root)}></button>
) : null}
{canManageGroups ? (
<button type="button" className="action-link button-link" onClick={() => openEditGroup(root)}></button>
) : null}
{canManageGroups ? (
<button type="button" className="action-link danger button-link" onClick={() => deleteGroup(root)}></button>
) : null}
</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>
{canCreateRuleDraft ? (
<button type="button" className="action-link button-link" onClick={() => openCreateRuleDraft(child)}>/YAML</button>
) : null}
{canManageBindings ? (
<button type="button" className="action-link button-link" onClick={() => openCreateBinding(child)}></button>
) : null}
{canManageGroups ? (
<button type="button" className="action-link button-link" onClick={() => openEditGroup(child)}></button>
) : null}
{canManageGroups ? (
<button type="button" className="action-link danger button-link" onClick={() => deleteGroup(child)}></button>
) : null}
</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>
{canManageBindings ? (
<button type="button" className="action-link button-link" onClick={() => openEditBinding(child, binding)}></button>
) : null}
{canManageBindings ? (
<button type="button" className="action-link danger button-link" onClick={() => deleteBinding(binding)}></button>
) : null}
</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}
/>
<RuleDraftModal
visible={draftModalOpen}
group={activeDraftGroup}
form={draftForm}
onClose={() => setDraftModalOpen(false)}
onChange={(patch) => setDraftForm((prev) => ({ ...prev, ...patch }))}
onSubmit={submitRuleDraft}
onOpenRules={openRuleDraftResult}
/>
</div>
);
}