1502 lines
67 KiB
TypeScript
1502 lines
67 KiB
TypeScript
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");
|
||
const userRole = userInfo?.role || userInfo?.user_role || "";
|
||
|
||
await requireRoutePermission("/rule-groups", userRole, frontendJWT || undefined);
|
||
|
||
try {
|
||
const [groups, docTypesRes, entryModulesRes, ruleSetsRes] = await Promise.all([
|
||
fetchGroupTree(frontendJWT),
|
||
getDocumentTypes({ page: 1, pageSize: 500 }, frontendJWT),
|
||
getEntryModules(frontendJWT),
|
||
getRuleSets(frontendJWT),
|
||
]);
|
||
|
||
return Response.json({
|
||
groups,
|
||
docTypes: docTypesRes.data?.types || [],
|
||
entryModules: entryModulesRes.data || [],
|
||
ruleSets: ruleSetsRes.data || [],
|
||
frontendJWT,
|
||
} satisfies LoaderData);
|
||
} catch (error) {
|
||
if (axios.isAxiosError(error) && error.response?.status === 403) {
|
||
throw new Response("无权访问评查点分组页面", { status: 403 });
|
||
}
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
function formatVersionLabel(binding: BindingItem): string {
|
||
if (binding.current_version_id) return `当前 #${binding.current_version_id}`;
|
||
if (binding.fallback_version_id) return `回退 #${binding.fallback_version_id}`;
|
||
return "未配置";
|
||
}
|
||
|
||
function formatDateTime(value?: string | null): string {
|
||
if (!value) return "-";
|
||
const normalized = value.replace("T", " ").replace(/\.\d+/, "");
|
||
return normalized.slice(0, 19);
|
||
}
|
||
|
||
function toTopGroups(groups: RuleGroupNode[]): RuleGroupNode[] {
|
||
return [...groups].sort((a, b) => (a.sort_order - b.sort_order) || (a.id - b.id));
|
||
}
|
||
|
||
function buildChildGroupStats(topGroups: RuleGroupNode[]): Record<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>
|
||
);
|
||
}
|