feat(web): rules page with layer cards, rule-string sync, preview and preferences as tags (FE-3)
- /console/rules tabs: rule groups, custom conditions, defaults, acquisition policies, suppressions - editor: rule string parsed live via /rules/parse (position + suggestions) and synced both ways with layer cards; hard excludes; expectedRevision conflicts keep local edits - preview against recent releases or pasted titles with pri / level / reasons / trace - single-rule groups shown as preference tags; disable/delete writes suppression records - /console/preferences redirects to /console/rules (origin passed through) Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
81bf010e3e
commit
b908b4a0c9
12 files changed
+2657
-427
No files matched your search
@@ -0,0 +1,115 @@
|
||||
import type { RuleExpr, RuleLayer } from "@umt/contracts";
|
||||
|
||||
// 规则组层的编辑模型:一层 = 顶层 且/或 + 若干条目;条目是条件(可取反)或一个括号组(组内同一种 且/或)。
|
||||
// 覆盖 MP 规则串的常见写法;更深的嵌套只能在规则串里改(cardsOf 返回 null)。
|
||||
|
||||
export type Op = "and" | "or";
|
||||
export type AtomItem = { kind: "atom"; id: string; neg: boolean };
|
||||
export type GroupItem = { kind: "group"; op: Op; neg: boolean; items: AtomItem[] };
|
||||
export type LayerItem = AtomItem | GroupItem;
|
||||
export type LayerCard = { name?: string; op: Op; items: LayerItem[] };
|
||||
|
||||
/** 规范化:原子大写,层内 ! > & > |,只加必要括号(与后端 /rules/serialize 同口径) */
|
||||
export function exprToString(e: RuleExpr, parent: "top" | "and" | "or" | "not" = "top"): string {
|
||||
switch (e.op) {
|
||||
case "atom":
|
||||
return e.id.toUpperCase();
|
||||
case "not":
|
||||
return `!${exprToString(e.arg, "not")}`;
|
||||
case "and": {
|
||||
const s = e.args.map((a) => exprToString(a, "and")).join(" & ");
|
||||
return parent === "not" ? `(${s})` : s;
|
||||
}
|
||||
case "or": {
|
||||
const s = e.args.map((a) => exprToString(a, "or")).join(" | ");
|
||||
return parent === "and" || parent === "not" ? `(${s})` : s;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function layersToString(layers: RuleLayer[]): string {
|
||||
return layers.map((l) => exprToString(l.expr)).join(" > ");
|
||||
}
|
||||
|
||||
function atomOf(e: RuleExpr): AtomItem | null {
|
||||
if (e.op === "atom") return { kind: "atom", id: e.id.toUpperCase(), neg: false };
|
||||
if (e.op === "not" && e.arg.op === "atom") return { kind: "atom", id: e.arg.id.toUpperCase(), neg: true };
|
||||
return null;
|
||||
}
|
||||
|
||||
function groupOf(e: RuleExpr): GroupItem | null {
|
||||
let neg = false;
|
||||
let inner = e;
|
||||
if (inner.op === "not") {
|
||||
neg = true;
|
||||
inner = inner.arg;
|
||||
}
|
||||
if (inner.op !== "and" && inner.op !== "or") return null;
|
||||
const items: AtomItem[] = [];
|
||||
for (const a of inner.args) {
|
||||
const at = atomOf(a);
|
||||
if (!at) return null;
|
||||
items.push(at);
|
||||
}
|
||||
return { kind: "group", op: inner.op, neg, items };
|
||||
}
|
||||
|
||||
/** 表达式 → 卡片;表达不了(嵌套超过两层)返回 null */
|
||||
export function cardOf(layer: RuleLayer): LayerCard | null {
|
||||
const e = layer.expr;
|
||||
const single = atomOf(e);
|
||||
if (single) return { name: layer.name, op: "and", items: [single] };
|
||||
if (e.op === "not") {
|
||||
const g = groupOf(e);
|
||||
return g ? { name: layer.name, op: "and", items: [g] } : null;
|
||||
}
|
||||
if (e.op !== "and" && e.op !== "or") return null;
|
||||
const items: LayerItem[] = [];
|
||||
for (const a of e.args) {
|
||||
const it = atomOf(a) ?? groupOf(a);
|
||||
if (!it) return null;
|
||||
items.push(it);
|
||||
}
|
||||
return { name: layer.name, op: e.op, items };
|
||||
}
|
||||
|
||||
export function cardsOf(layers: RuleLayer[]): LayerCard[] | null {
|
||||
const out: LayerCard[] = [];
|
||||
for (const l of layers) {
|
||||
const c = cardOf(l);
|
||||
if (!c) return null;
|
||||
out.push(c);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function atomExpr(a: AtomItem): RuleExpr {
|
||||
const e: RuleExpr = { op: "atom", id: a.id };
|
||||
return a.neg ? { op: "not", arg: e } : e;
|
||||
}
|
||||
|
||||
function itemExpr(it: LayerItem): RuleExpr | null {
|
||||
if (it.kind === "atom") return atomExpr(it);
|
||||
if (it.items.length === 0) return null;
|
||||
const inner: RuleExpr =
|
||||
it.items.length === 1 ? atomExpr(it.items[0] as AtomItem) : { op: it.op, args: it.items.map(atomExpr) };
|
||||
return it.neg ? { op: "not", arg: inner } : inner;
|
||||
}
|
||||
|
||||
/** 卡片 → 层;空层返回 null 的 expr(调用方据此提示) */
|
||||
export function cardToLayer(c: LayerCard): RuleLayer | null {
|
||||
const args = c.items.map(itemExpr).filter((x): x is RuleExpr => !!x);
|
||||
if (args.length === 0) return null;
|
||||
const expr: RuleExpr = args.length === 1 ? (args[0] as RuleExpr) : { op: c.op, args };
|
||||
return c.name ? { name: c.name, expr } : { expr };
|
||||
}
|
||||
|
||||
/** 表达式里引用的全部条件 id */
|
||||
export function atomsIn(e: RuleExpr, out = new Set<string>()): Set<string> {
|
||||
if (e.op === "atom") out.add(e.id.toUpperCase());
|
||||
else if (e.op === "not") atomsIn(e.arg, out);
|
||||
else for (const a of e.args) atomsIn(a, out);
|
||||
return out;
|
||||
}
|
||||
|
||||
export const OP_LABEL: Record<Op, string> = { and: "且", or: "或" };
|
||||
Reference in new issue
Block a user