diff --git a/apps/web/app/api/rules.ts b/apps/web/app/api/rules.ts new file mode 100644 index 00000000..4d822be7 --- /dev/null +++ b/apps/web/app/api/rules.ts @@ -0,0 +1,235 @@ +import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + AcquisitionSlot, + cursorPage, + PreferenceSuppression, + RuleAtom, + type RuleAtomCreate, + type RuleAtomUpdate, + RuleDefaults, + RuleGroup, + type RuleGroupCreate, + type RuleGroupKind, + RuleGroupRevisionList, + type RuleGroupSource, + type RuleGroupUpdate, + RuleKeywordList, + type RuleLayer, + RuleParseResult, + type RulePreviewRequest, + RulePreviewResult, + Subscription, +} from "@umt/contracts"; +import { api } from "~/lib/api.ts"; +import { getParsed, parseOrThrow } from "./library.ts"; + +// 规则(W9-D,docs/runbook/rules.md):规则组 = 收录过滤条件 + 偏好标签合一;自定义条件;三套默认链;抑制记录。 +// 写入给 ruleString(优先)或 layers;PATCH 带 expectedRevision,不匹配 409。 + +export const ruleKeys = { + all: ["rules"] as const, + groups: (f: RuleGroupFilters) => ["rules", "groups", f] as const, + group: (id: string) => ["rules", "group", id] as const, + revisions: (id: string) => ["rules", "revisions", id] as const, + keywords: ["rules", "keywords"] as const, + atoms: ["rules", "atoms"] as const, + defaults: ["rules", "defaults"] as const, + suppressions: ["rules", "suppressions"] as const, + refs: ["rules", "refs"] as const, +}; + +export type RuleGroupFilters = { + kind?: RuleGroupKind; + source?: RuleGroupSource; + enabled?: "true" | "false"; + includeDeleted?: "true"; + q?: string; +}; + +const GroupPage = cursorPage(RuleGroup); +const AtomPage = cursorPage(RuleAtom); +const SupPage = cursorPage(PreferenceSuppression); + +export function useRuleGroups(filters: RuleGroupFilters = {}) { + return useQuery({ + queryKey: ruleKeys.groups(filters), + queryFn: ({ signal }) => getParsed(GroupPage, "/rules", { ...filters, limit: 200 }, signal), + placeholderData: keepPreviousData, + }); +} + +export function useRuleGroupRevisions(id: string | null | undefined) { + return useQuery({ + queryKey: ruleKeys.revisions(id ?? ""), + enabled: !!id, + queryFn: async ({ signal }) => + (await getParsed(RuleGroupRevisionList, `/rules/${id}/revisions`, undefined, signal)).items, + }); +} + +export function useRuleKeywords() { + return useQuery({ + queryKey: ruleKeys.keywords, + queryFn: async ({ signal }) => (await getParsed(RuleKeywordList, "/rules/keywords", undefined, signal)).items, + staleTime: 60_000, + }); +} + +export function useRuleAtoms() { + return useQuery({ + queryKey: ruleKeys.atoms, + queryFn: ({ signal }) => getParsed(AtomPage, "/rules/atoms", { limit: 200 }, signal), + }); +} + +export function useRuleDefaults() { + return useQuery({ + queryKey: ruleKeys.defaults, + queryFn: ({ signal }) => getParsed(RuleDefaults, "/rules/defaults", undefined, signal), + }); +} + +export function useRuleSuppressions() { + return useQuery({ + queryKey: ruleKeys.suppressions, + queryFn: ({ signal }) => getParsed(SupPage, "/rules/suppressions", { limit: 200 }, signal), + }); +} + +export type RuleRefs = { + /** 规则组 id → 引用它的订阅(params.ruleGroupIds 或私有组) */ + subscriptions: Map; + /** 规则组 id → 引用它的版本目标 */ + slots: Map; +}; + +const SubPage = cursorPage(Subscription); +const SlotPage = cursorPage(AcquisitionSlot); + +/** 引用关系:列表接口不带引用计数,前端从订阅与版本目标反查(各取前 200 条) */ +export function useRuleRefs() { + return useQuery({ + queryKey: ruleKeys.refs, + queryFn: async ({ signal }): Promise => { + const [subs, slots] = await Promise.all([ + getParsed(SubPage, "/subscriptions", { limit: 200 }, signal), + getParsed(SlotPage, "/acquisition-slots", { limit: 200 }, signal), + ]); + const subscriptions = new Map(); + for (const s of subs.items) { + const ids = Array.isArray(s.params?.ruleGroupIds) ? (s.params.ruleGroupIds as string[]) : []; + const title = + (s as { mediaEntityTitle?: string | null }).mediaEntityTitle ?? s.titleQuery ?? `订阅 ${s.id.slice(0, 8)}`; + for (const id of ids) subscriptions.set(id, [...(subscriptions.get(id) ?? []), { id: s.id, title }]); + } + const slotRefs = new Map(); + for (const sl of slots.items) + for (const id of sl.ruleGroupIds ?? []) + slotRefs.set(id, [...(slotRefs.get(id) ?? []), { id: sl.id, name: sl.name }]); + return { subscriptions, slots: slotRefs }; + }, + staleTime: 30_000, + }); +} + +/** 规则串 → 层(实时解析;错误带位置与相近名) */ +export function parseRuleString(ruleString: string) { + return api + .post("/rules/parse", { ruleString }) + .then((d) => parseOrThrow(RuleParseResult, d, "/rules/parse")); +} + +export function usePreviewRuleGroup() { + return useMutation({ + mutationFn: ({ id, ...body }: RulePreviewRequest & { id: string }) => + api.post(`/rules/${id}/preview`, body).then((d) => parseOrThrow(RulePreviewResult, d, "/rules/preview")), + }); +} + +function useInvalidate() { + const qc = useQueryClient(); + return () => { + void qc.invalidateQueries({ queryKey: ruleKeys.all }); + // 旧偏好标签页与订阅/版本目标的规则组显示同源 + void qc.invalidateQueries({ queryKey: ["preferences"] }); + }; +} + +const parseGroup = (d: unknown) => parseOrThrow(RuleGroup, d, "/rules"); + +export function useCreateRuleGroup() { + const done = useInvalidate(); + return useMutation({ + mutationFn: (body: RuleGroupCreate) => api.post("/rules", body).then(parseGroup), + onSuccess: done, + }); +} + +/** 编辑 / 停用(enabled=false 写抑制记录)/ 重新启用;带 expectedRevision 防覆盖 */ +export function useUpdateRuleGroup() { + const done = useInvalidate(); + return useMutation({ + mutationFn: ({ id, ...body }: RuleGroupUpdate & { id: string }) => + api.patch(`/rules/${id}`, body).then(parseGroup), + onSettled: done, + }); +} + +/** 删除:标记删除并写抑制记录;被版本目标/订阅引用时 409 */ +export function useDeleteRuleGroup() { + const done = useInvalidate(); + return useMutation({ + mutationFn: (id: string) => api.del(`/rules/${id}`), + onSuccess: done, + }); +} + +export function useSaveRuleDefaults() { + const done = useInvalidate(); + return useMutation({ + mutationFn: (body: RuleDefaults) => + api.put("/rules/defaults", body).then((d) => parseOrThrow(RuleDefaults, d, "/rules/defaults")), + onSuccess: done, + }); +} + +const parseAtom = (d: unknown) => parseOrThrow(RuleAtom, d, "/rules/atoms"); + +export function useCreateRuleAtom() { + const done = useInvalidate(); + return useMutation({ + mutationFn: (body: RuleAtomCreate) => api.post("/rules/atoms", body).then(parseAtom), + onSuccess: done, + }); +} + +export function useUpdateRuleAtom() { + const done = useInvalidate(); + return useMutation({ + mutationFn: ({ id, ...body }: RuleAtomUpdate & { id: string }) => + api.patch(`/rules/atoms/${id}`, body).then(parseAtom), + onSuccess: done, + }); +} + +export function useDeleteRuleAtom() { + const done = useInvalidate(); + return useMutation({ + mutationFn: (id: string) => api.del(`/rules/atoms/${id}`), + onSuccess: done, + }); +} + +/** 解除抑制:之后 Agent 可以再次提出同一规则(仍需审批) */ +export function useLiftRuleSuppression() { + const done = useInvalidate(); + return useMutation({ + mutationFn: (v: { id: string; reason?: string }) => + api + .patch(`/rules/suppressions/${v.id}`, { active: false, reason: v.reason }) + .then((d) => parseOrThrow(PreferenceSuppression, d, "/rules/suppressions")), + onSuccess: done, + }); +} + +export type { RuleLayer }; diff --git a/apps/web/app/components/rules/expr.test.ts b/apps/web/app/components/rules/expr.test.ts new file mode 100644 index 00000000..f7b32900 --- /dev/null +++ b/apps/web/app/components/rules/expr.test.ts @@ -0,0 +1,43 @@ +import type { RuleExpr, RuleLayer } from "@umt/contracts"; +import { describe, expect, it } from "vitest"; +import { cardsOf, cardToLayer, exprToString, layersToString } from "./expr.ts"; + +const a = (id: string): RuleExpr => ({ op: "atom", id }); +const not = (arg: RuleExpr): RuleExpr => ({ op: "not", arg }); +const and = (...args: RuleExpr[]): RuleExpr => ({ op: "and", args }); +const or = (...args: RuleExpr[]): RuleExpr => ({ op: "or", args }); + +describe("规则串序列化", () => { + it("只加必要括号,原子大写", () => { + expect(exprToString(and(a("specsub"), a("CNSUB")))).toBe("SPECSUB & CNSUB"); + expect(exprToString(or(and(a("4K"), a("HDR")), a("1080P")))).toBe("4K & HDR | 1080P"); + expect(exprToString(and(or(a("4K"), a("1080P")), not(a("BLU"))))).toBe("(4K | 1080P) & !BLU"); + expect(exprToString(not(or(a("REMUX"), a("BLU"))))).toBe("!(REMUX | BLU)"); + }); + + it("多层用 > 连接", () => { + const layers: RuleLayer[] = [{ expr: and(a("SPECSUB"), a("CNSUB")) }, { expr: a("BLU") }]; + expect(layersToString(layers)).toBe("SPECSUB & CNSUB > BLU"); + }); +}); + +describe("层卡片往返", () => { + it("两层以内的表达式可转卡片再转回", () => { + const layers: RuleLayer[] = [ + { expr: and(or(a("4K"), a("1080P")), not(a("BLU"))) }, + { expr: not(a("REMUX")) }, + { expr: or(a("CNSUB"), not(and(a("HDR"), a("DOLBY")))) }, + ]; + const cards = cardsOf(layers); + expect(cards).not.toBeNull(); + expect(cards?.map((c) => cardToLayer(c))).toEqual(layers); + }); + + it("嵌套太深时返回 null,只能在规则串里改", () => { + expect(cardsOf([{ expr: and(a("A"), or(a("B"), and(a("C"), a("D")))) }])).toBeNull(); + }); + + it("空层转不出表达式", () => { + expect(cardToLayer({ op: "and", items: [] })).toBeNull(); + }); +}); diff --git a/apps/web/app/components/rules/expr.ts b/apps/web/app/components/rules/expr.ts new file mode 100644 index 00000000..eaa11160 --- /dev/null +++ b/apps/web/app/components/rules/expr.ts @@ -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()): Set { + 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 = { and: "且", or: "或" }; diff --git a/apps/web/app/components/rules/group-editor.tsx b/apps/web/app/components/rules/group-editor.tsx new file mode 100644 index 00000000..488a1a28 --- /dev/null +++ b/apps/web/app/components/rules/group-editor.tsx @@ -0,0 +1,420 @@ +import { + type RuleGroup, + type RuleGroupCreate, + RuleGroupKind, + type RuleGroupScope, + SubscriptionMediaKind, +} from "@umt/contracts"; +import { Plus, RotateCcw, Trash2 } from "lucide-react"; +import { useEffect, useId, useState } from "react"; +import { toast } from "sonner"; +import { useCreateRuleGroup, useDeleteRuleGroup, useRuleGroupRevisions, useUpdateRuleGroup } from "~/api/rules.ts"; +import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx"; +import { DetailSection, DetailSheet } from "~/components/console/detail-sheet.tsx"; +import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx"; +import { FormField } from "~/components/console/form-field.tsx"; +import { StatusBadge } from "~/components/console/status-badge.tsx"; +import { MEDIA_KIND } from "~/components/library/labels.ts"; +import { ChipToggleGroup } from "~/components/library/parts.tsx"; +import { Button } from "~/components/ui/button.tsx"; +import { Input } from "~/components/ui/input.tsx"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx"; +import { Switch } from "~/components/ui/switch.tsx"; +import { ApiRequestError, errorMessage } from "~/lib/api.ts"; +import { formatDateTime } from "~/lib/utils.ts"; +import { exprToString } from "./expr.ts"; +import { DEFAULT_CHAIN_LABEL, RULE_KIND, RULE_SOURCE, SUPPRESS_NOTE, scopeText } from "./labels.ts"; +import { LadderEditor, type LadderState, ladderFrom, ladderProblem, RuleStringField } from "./ladder-editor.tsx"; +import { RulePreviewPanel } from "./preview-panel.tsx"; + +// 规则组编辑抽屉:基本信息 → 优先级阶梯(规则串 ⇄ 层卡片)→ 硬排除 → 试算 → 修订历史。 +// 保存给 ruleString(后端优先用它);PATCH 带 expectedRevision,别处先改了会 409。 + +type Form = { + name: string; + description: string; + kind: RuleGroupKind; + mediaTypes: SubscriptionMediaKind[]; + ladderRequired: boolean; + enabled: boolean; + ladder: LadderState; + excludes: { key: number; state: LadderState }[]; +}; + +let excludeSeq = 0; +function formOf(g: RuleGroup | null, kind: RuleGroupKind): Form { + const scope = (g?.scope ?? {}) as RuleGroupScope & { mediaKinds?: SubscriptionMediaKind[] }; + return { + name: g?.name ?? "", + description: g?.description ?? "", + kind: g?.kind ?? kind, + mediaTypes: scope.mediaTypes ?? scope.mediaKinds ?? [], + ladderRequired: g?.ladderRequired ?? true, + enabled: g?.enabled ?? true, + ladder: ladderFrom(g?.layers ?? []), + excludes: (g?.hardExcludes ?? []).map((h) => ({ + key: ++excludeSeq, + state: { ...ladderFrom([{ expr: h }]), text: exprToString(h) }, + })), + }; +} + +export function RuleGroupEditor({ + open, + group, + defaultKind = "acquisition", + onOpenChange, +}: { + open: boolean; + /** null = 新建 */ + group: RuleGroup | null; + defaultKind?: RuleGroupKind; + onOpenChange: (o: boolean) => void; +}) { + const uid = useId(); + const create = useCreateRuleGroup(); + const update = useUpdateRuleGroup(); + const del = useDeleteRuleGroup(); + const [form, setForm] = useState
(() => formOf(group, defaultKind)); + const [conflict, setConflict] = useState(null); + const [nameError, setNameError] = useState(null); + /** 表单载入时的修订号:保存带它做 expectedRevision,后台刷新到新修订不覆盖正在编辑的内容 */ + const [baseRevision, setBaseRevision] = useState(group?.revision ?? null); + + const load = (g: RuleGroup | null) => { + setForm(formOf(g, defaultKind)); + setBaseRevision(g?.revision ?? null); + setConflict(null); + setNameError(null); + }; + + // 只在打开或换组时重置 + // biome-ignore lint/correctness/useExhaustiveDependencies: 只在打开/换组时重置 + useEffect(() => { + if (open) load(group); + }, [open, group?.id]); + + const set = (k: K, v: Form[K]) => setForm((f) => ({ ...f, [k]: v })); + + const problems = [ + ladderProblem(form.ladder), + ...form.excludes.map((e, i) => { + const p = ladderProblem(e.state); + return p ? `第 ${i + 1} 条硬排除:${p}` : null; + }), + ].filter((p): p is string => !!p); + const blocked = problems[0] ?? null; + const excludeStrings = form.excludes.map((e) => e.state.text.trim()).filter(Boolean); + + const save = async () => { + if (!form.name.trim()) { + setNameError("名称必填"); + return; + } + const scope: RuleGroupScope = { + ...((group?.scope ?? {}) as RuleGroupScope), + mediaTypes: form.mediaTypes.length ? form.mediaTypes : undefined, + }; + delete (scope as { mediaKinds?: unknown }).mediaKinds; + if (!scope.mediaTypes) delete scope.mediaTypes; + const hasLadder = form.ladder.text.trim().length > 0; + const body: RuleGroupCreate = { + name: form.name.trim(), + description: form.description.trim() || null, + kind: form.kind, + scope, + ...(hasLadder ? { ruleString: form.ladder.text.trim() } : { layers: [] }), + hardExcludes: excludeStrings, + ladderRequired: form.ladderRequired, + enabled: form.enabled, + }; + try { + if (group) { + const saved = await update.mutateAsync({ + id: group.id, + ...body, + expectedRevision: baseRevision ?? group.revision, + }); + load(saved); + toast.success("已保存"); + } else { + await create.mutateAsync({ ...body, source: "manual" }); + toast.success("已新建规则组"); + onOpenChange(false); + } + } catch (err) { + if (err instanceof ApiRequestError && err.code === "conflict") { + if (/revision/.test(err.message)) setConflict("这个规则组刚被别处改过,没有保存。载入最新版本后再改。"); + else setNameError(err.message); + } else toast.error(errorMessage(err, "保存失败")); + } + }; + + const inDefault = group?.defaultOrder !== null && group?.defaultOrder !== undefined; + const pending = create.isPending || update.isPending; + + return ( + + + {RULE_KIND[group.kind].label} · {RULE_SOURCE[group.source]} · r{baseRevision ?? group.revision} · 更新于{" "} + {formatDateTime(group.updatedAt)} + + {inDefault ? ( + + {DEFAULT_CHAIN_LABEL[group.kind]} #{(group.defaultOrder ?? 0) + 1} + + ) : null} + + ) : ( + "层从上到下依次匹配,先命中的层优先级高。" + ) + } + footer={ + <> + {group ? ( + + + 删除 + + } + title={`删除「${group.name}」?`} + description={`${SUPPRESS_NOTE}被版本目标或订阅引用时不能删除。`} + confirmLabel="删除" + destructive + onConfirm={async () => { + try { + await del.mutateAsync(group.id); + toast.success("已删除"); + onOpenChange(false); + } catch (err) { + toast.error(errorMessage(err, "删除失败")); + throw err; + } + }} + /> + ) : null} + + + + } + > + {conflict ? ( +
+ {conflict} + +
+ ) : null} + {inDefault ? ( +

在默认链里的组不能删除,先在“默认值”里移出。

+ ) : null} + +
+ + { + set("name", e.target.value); + setNameError(null); + }} + /> + + + + + + set("description", e.target.value)} + /> + + + ({ value: k, label: MEDIA_KIND[k] }))} + value={form.mediaTypes} + onChange={(v) => set("mediaTypes", v)} + /> + +
+
+

一层都不中时拒绝

+

+ {form.ladderRequired ? "资源至少要命中一层" : "只影响排序,不中的资源 pri 为 0"} +

+
+ set("ladderRequired", v)} + aria-label="一层都不中时拒绝" + /> +
+
+
+

启用

+

{SUPPRESS_NOTE}

+
+ set("enabled", v)} aria-label="启用" /> +
+
+ + + set("ladder", s)} /> + + + +

任一条命中即拒绝,每条写一层条件。

+
    + {form.excludes.map((e, i) => ( +
  • + + setForm((f) => ({ + ...f, + excludes: f.excludes.map((x) => (x.key === e.key ? { ...x, state: s } : x)), + })) + } + /> + +
  • + ))} +
+ +
+ + + {group ? ( + + ) : ( +

保存后可以用最近的资源试算。

+ )} +
+ + {group ? ( + + + + ) : null} +
+ ); +} + +const REV_ACTION: Record = { + created: "新建", + updated: "修改", + deleted: "删除", + disabled: "停用", + enabled: "启用", +}; +const FIELD_LABEL: Record = { + name: "名称", + description: "说明", + kind: "类型", + scope: "作用范围", + layers: "阶梯", + hardExcludes: "硬排除", + ladderRequired: "一层不中拒绝", + enabled: "启用", + label: "名称", + rule: "规则", + status: "状态", + autoFill: "自动补全", + evidence: "证据", +}; + +function Revisions({ id }: { id: string }) { + const q = useRuleGroupRevisions(id); + if (q.isPending) return ; + if (q.isError) return q.refetch()} />; + if (q.data.length === 0) return

没有修订记录

; + return ( +
    + {q.data.slice(0, 20).map((r) => { + const action = r.action.split(".").pop() ?? r.action; + return ( +
  • +
    + {REV_ACTION[action] ?? action} + {r.revision ? r{r.revision} : null} + + {r.actorType === "agent" ? "Agent" : r.actorType === "api_key" ? "API Key" : "用户"} + + {formatDateTime(r.at)} +
    + {r.changedFields.length ? ( +

    + 改动:{r.changedFields.map((f) => FIELD_LABEL[f] ?? f).join("、")} +

    + ) : null} +
  • + ); + })} +
+ ); +} diff --git a/apps/web/app/components/rules/group-list.tsx b/apps/web/app/components/rules/group-list.tsx new file mode 100644 index 00000000..984b9548 --- /dev/null +++ b/apps/web/app/components/rules/group-list.tsx @@ -0,0 +1,260 @@ +import type { RuleGroup } from "@umt/contracts"; +import { ListChecks, ShieldOff, Tags, Trash2 } from "lucide-react"; +import { toast } from "sonner"; +import { type RuleRefs, useDeleteRuleGroup, useUpdateRuleGroup } from "~/api/rules.ts"; +import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx"; +import { type DataColumn, DataTable } from "~/components/console/data-table.tsx"; +import { EmptyState } from "~/components/console/empty-state.tsx"; +import { StatusBadge } from "~/components/console/status-badge.tsx"; +import { Chip, Panel, relTime } from "~/components/library/parts.tsx"; +import { Button } from "~/components/ui/button.tsx"; +import { Switch } from "~/components/ui/switch.tsx"; +import { errorMessage } from "~/lib/api.ts"; +import { cn } from "~/lib/utils.ts"; +import { DEFAULT_CHAIN_LABEL, RULE_KIND, RULE_SOURCE, ruleSummary, SUPPRESS_NOTE, scopeText } from "./labels.ts"; + +// 规则组列表(表格)与单条规则的标签形态(原偏好标签)。停用/删除都写抑制记录。 + +function useToggle() { + const update = useUpdateRuleGroup(); + return { + pending: update.isPending, + toggle: async (g: RuleGroup) => { + try { + await update.mutateAsync({ id: g.id, enabled: !g.enabled, expectedRevision: g.revision }); + toast.success(g.enabled ? "已停用,Agent 不会自动加回" : "已启用"); + } catch (err) { + toast.error(errorMessage(err)); + } + }, + }; +} + +function RefsCell({ g, refs }: { g: RuleGroup; refs: RuleRefs | undefined }) { + const subs = refs?.subscriptions.get(g.id) ?? []; + const slots = refs?.slots.get(g.id) ?? []; + const parts: string[] = []; + if (g.defaultOrder !== null) parts.push("默认链"); + if (subs.length) parts.push(`${subs.length} 个订阅`); + if (slots.length) parts.push(`${slots.length} 个版本目标`); + if (!parts.length) return {refs ? "未引用" : "—"}; + const title = [...subs.map((s) => `订阅:${s.title}`), ...slots.map((s) => `版本目标:${s.name}`)].join("\n"); + return ( + + {parts.join(" · ")} + + ); +} + +export function RuleGroupTable({ + rows, + loading, + error, + onRetry, + refs, + filtered, + onOpen, + onCreate, +}: { + rows: RuleGroup[] | undefined; + loading: boolean; + error: unknown; + onRetry: () => void; + refs: RuleRefs | undefined; + filtered: boolean; + onOpen: (g: RuleGroup) => void; + onCreate: () => void; +}) { + const { toggle, pending } = useToggle(); + const columns: DataColumn[] = [ + { + key: "name", + header: "名称", + cell: (g) => ( +
+
+ + {g.name} + + {g.defaultOrder !== null ? ( + + {DEFAULT_CHAIN_LABEL[g.kind]} #{g.defaultOrder + 1} + + ) : null} +
+ {g.ruleString || g.hardExcludes.length ? ( +

{ruleSummary(g)}

+ ) : g.description ? ( +

{g.description}

+ ) : null} +
+ ), + }, + { key: "kind", header: "类型", cell: (g) => RULE_KIND[g.kind].label, className: "whitespace-nowrap" }, + { key: "scope", header: "作用范围", cell: (g) => scopeText(g.scope), hideBelow: "md" }, + { + key: "source", + header: "来源", + cell: (g) => RULE_SOURCE[g.source], + hideBelow: "sm", + className: "whitespace-nowrap", + }, + { key: "refs", header: "引用", cell: (g) => , hideBelow: "lg" }, + { + key: "rev", + header: "修订", + cell: (g) => ( + + r{g.revision} + + ), + hideBelow: "md", + }, + { + key: "enabled", + header: "启用", + align: "right", + cell: (g) => ( + // biome-ignore lint/a11y/noStaticElementInteractions: 阻止行点击冒泡 + // biome-ignore lint/a11y/useKeyWithClickEvents: 同上,开关本身可键盘操作 + e.stopPropagation()}> + toggle(g)} + aria-label={`${g.enabled ? "停用" : "启用"}「${g.name}」`} + /> + + ), + }, + ]; + return ( + g.id} + loading={loading} + error={error} + onRetry={onRetry} + onRowClick={onOpen} + empty={{ + icon: ListChecks, + title: filtered ? "没有符合条件的规则组" : "还没有规则组", + description: filtered ? undefined : "新建一个规则组,或在 Agent 会话里说明长期偏好。", + action: filtered ? undefined : ( + + ), + }} + /> + ); +} + +/** 单条规则的标签:点开进编辑;停用/删除直接在卡片上做 */ +function RuleTag({ g, onOpen }: { g: RuleGroup; onOpen: () => void }) { + const { toggle, pending } = useToggle(); + const del = useDeleteRuleGroup(); + return ( +
  • + +
    + + r{g.revision} · {relTime(g.updatedAt)} + + + {g.enabled ? ( + + + 停用 + + } + title={`停用「${g.name}」?`} + description={SUPPRESS_NOTE} + confirmLabel="停用" + onConfirm={() => toggle(g)} + /> + ) : ( + + )} + + + + } + title={`删除「${g.name}」?`} + description={`${SUPPRESS_NOTE}被版本目标或订阅引用时不能删除。`} + confirmLabel="删除" + destructive + onConfirm={async () => { + try { + await del.mutateAsync(g.id); + toast.success("已删除,Agent 不会自动加回"); + } catch (err) { + toast.error(errorMessage(err, "删除失败")); + throw err; + } + }} + /> + +
    +
  • + ); +} + +export function RuleTags({ rows, onOpen }: { rows: RuleGroup[]; onOpen: (g: RuleGroup) => void }) { + return ( + {rows.length} 条} + > + {rows.length === 0 ? ( + + ) : ( +
      + {rows.map((g) => ( + onOpen(g)} /> + ))} +
    + )} +
    + ); +} diff --git a/apps/web/app/components/rules/labels.ts b/apps/web/app/components/rules/labels.ts new file mode 100644 index 00000000..a8d5feb7 --- /dev/null +++ b/apps/web/app/components/rules/labels.ts @@ -0,0 +1,63 @@ +import type { RuleGroup, RuleGroupKind, RuleGroupScope, RuleGroupSource } from "@umt/contracts"; +import { MEDIA_KIND } from "~/components/library/labels.ts"; +import { exprToString } from "./expr.ts"; + +// 规则页文案:类型 / 来源 / 作用范围 / 默认链 / 抑制原因 + +export const RULE_KIND: Record = { + acquisition: { label: "订阅", hint: "订阅与版本目标挑资源时用" }, + upgrade: { label: "洗版", hint: "版本目标开了升级时额外叠加" }, + search: { label: "搜索", hint: "手动搜索与 Agent 搜索资源时用" }, +}; + +export const SUPPRESS_NOTE = "停用或删除会留下抑制记录,Agent 不会自动加回;重新启用或手动新建同样的规则后解除。"; + +export const RULE_SOURCE: Record = { + manual: "手动", + chat: "对话", + agent: "Agent", +}; + +export const DEFAULT_CHAIN_LABEL: Record = { + acquisition: "订阅默认", + upgrade: "洗版默认", + search: "搜索默认", +}; + +export const SUPPRESS_REASON: Record = { + deleted: "删除", + disabled: "停用", + "status:disabled": "停用", + "status:deleted": "删除", + edited: "修改语义", +}; +export const suppressReason = (r: string | null) => (r ? (SUPPRESS_REASON[r] ?? r) : "未注明"); + +/** 作用范围摘要:空 = 全部;旧数据里的 mediaKinds 同 mediaTypes */ +export function scopeText(scope: RuleGroupScope): string { + const s = scope as RuleGroupScope & { mediaKinds?: string[] }; + const parts: string[] = []; + const kinds = s.mediaTypes ?? s.mediaKinds; + if (kinds?.length) parts.push(kinds.map((k) => MEDIA_KIND[k as keyof typeof MEDIA_KIND] ?? k).join("、")); + if (s.entityIds?.length) parts.push(`${s.entityIds.length} 个作品`); + if (s.siteIds?.length) parts.push(`${s.siteIds.length} 个站点`); + if (s.subscriptionId) parts.push("订阅私有"); + return parts.length ? parts.join(" · ") : "全部"; +} + +/** 单条规则(偏好标签形态):不在默认链、不是订阅私有,层 + 硬排除合计一条;旧偏好标签迁来的都算 */ +export function isTagLike(g: RuleGroup): boolean { + if (g.defaultOrder !== null || g.scope.subscriptionId) return false; + if (g.legacyKind) return true; + return g.layers.length + g.hardExcludes.length === 1; +} + +/** 标签上的一句规则摘要 */ +export function ruleSummary(g: RuleGroup): string { + const parts: string[] = []; + if (g.layers.length) + parts.push(`${g.ladderRequired ? "要求" : "偏好"} ${g.layers.map((l) => exprToString(l.expr)).join(" > ")}`); + for (const h of g.hardExcludes) parts.push(`排除 ${exprToString(h)}`); + if (parts.length) return parts.join(";"); + return g.legacyKind ? "旧偏好标签,没有可评估的条件" : "没有条件"; +} diff --git a/apps/web/app/components/rules/ladder-editor.tsx b/apps/web/app/components/rules/ladder-editor.tsx new file mode 100644 index 00000000..cbe765b1 --- /dev/null +++ b/apps/web/app/components/rules/ladder-editor.tsx @@ -0,0 +1,578 @@ +import type { RuleKeyword, RuleLayer, RuleParseError } from "@umt/contracts"; +import { ArrowDown, ArrowUp, Brackets, LoaderCircle, Plus, Search, Trash2, X } from "lucide-react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { parseRuleString, useRuleKeywords } from "~/api/rules.ts"; +import { Button } from "~/components/ui/button.tsx"; +import { Input } from "~/components/ui/input.tsx"; +import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover.tsx"; +import { errorMessage } from "~/lib/api.ts"; +import { cn } from "~/lib/utils.ts"; +import { + type AtomItem, + cardsOf, + cardToLayer, + type LayerCard, + type LayerItem, + layersToString, + OP_LABEL, + type Op, +} from "./expr.ts"; + +// 优先级阶梯编辑器:规则串输入与层卡片双向同步。 +// 改卡片 → 本地规范化出规则串;改规则串 → 防抖调 /rules/parse,无错误时画回卡片,有错误逐条给位置与相近名。 + +const BASIS_LABEL: Record = { + field: "解析字段", + regex: "正则", + site: "站点信息", + custom: "自定义条件", +}; + +export type LadderState = { + /** 输入框里的规则串(可能有错) */ + text: string; + /** 最近一次解析成功的层 */ + layers: RuleLayer[]; + /** null = 嵌套太深,卡片画不出来,只能改规则串 */ + cards: LayerCard[] | null; + errors: RuleParseError[]; + parsing: boolean; + /** 解析请求本身失败 */ + failure: string | null; +}; + +export function ladderFrom(layers: RuleLayer[]): LadderState { + return { text: layersToString(layers), layers, cards: cardsOf(layers), errors: [], parsing: false, failure: null }; +} + +/** 保存用:有错误 / 解析中 / 有空层时不可保存 */ +export function ladderProblem(s: LadderState): string | null { + if (s.parsing) return "规则串还在解析"; + if (s.failure) return s.failure; + if (s.errors.length) return "规则串有错误"; + if (s.cards?.some((c) => c.items.length === 0)) return "有空层"; + return null; +} + +// ─── 关键字选择 ─── + +function useKeywordMap() { + const q = useRuleKeywords(); + const map = useMemo(() => new Map((q.data ?? []).map((k) => [k.id.toUpperCase(), k])), [q.data]); + return { list: q.data ?? [], map, loading: q.isPending }; +} + +export function KeywordPicker({ + onPick, + label = "条件", + size = "xs", +}: { + onPick: (id: string) => void; + label?: string; + size?: "xs" | "sm"; +}) { + const { list, loading } = useKeywordMap(); + const [open, setOpen] = useState(false); + const [q, setQ] = useState(""); + const shown = useMemo(() => { + const t = q.trim().toLowerCase(); + return list.filter( + (k) => !t || k.id.toLowerCase().includes(t) || k.name.toLowerCase().includes(t) || k.description.includes(t), + ); + }, [list, q]); + const groups = useMemo(() => { + const g = new Map(); + for (const k of shown) { + const key = k.builtin ? BASIS_LABEL[k.basis] : "自定义条件"; + g.set(key, [...(g.get(key) ?? []), k]); + } + return [...g.entries()]; + }, [shown]); + + return ( + { + setOpen(o); + if (!o) setQ(""); + }} + > + + + + +
    + + setQ(e.target.value)} + placeholder="搜索关键字" + aria-label="搜索关键字" + className="h-8 pl-8 text-caption" + /> +
    +
    + {loading ?

    加载中…

    : null} + {!loading && shown.length === 0 ? ( +

    没有匹配的关键字

    + ) : null} + {groups.map(([title, items]) => ( +
    +

    {title}

    + {items.map((k) => ( + + ))} +
    + ))} +
    +
    +
    + ); +} + +// ─── 规则串输入 ─── + +/** 防抖解析规则串;single = 硬排除行,不允许多层 */ +export function useRuleTextParser( + state: LadderState, + onChange: (s: LadderState) => void, + { single = false }: { single?: boolean } = {}, +) { + const seq = useRef(0); + const latest = useRef(state); + latest.current = state; + const onChangeRef = useRef(onChange); + onChangeRef.current = onChange; + const [pendingText, setPendingText] = useState(null); + + useEffect(() => { + if (pendingText === null) return; + const text = pendingText; + const id = ++seq.current; + const timer = setTimeout(async () => { + if (!text.trim()) { + onChangeRef.current({ ...latest.current, layers: [], cards: [], errors: [], parsing: false, failure: null }); + return; + } + try { + const r = await parseRuleString(text); + if (id !== seq.current) return; + const errors = [...r.errors]; + if (single && r.layers.length > 1) + errors.push({ + code: "unexpected_token", + message: "硬排除只能写一层,不能用 >", + position: text.indexOf(">"), + layer: 1, + }); + onChangeRef.current({ + ...latest.current, + layers: errors.length ? latest.current.layers : r.layers, + cards: errors.length ? latest.current.cards : cardsOf(r.layers), + errors, + parsing: false, + failure: null, + }); + } catch (err) { + if (id !== seq.current) return; + onChangeRef.current({ ...latest.current, parsing: false, failure: errorMessage(err, "规则串解析失败") }); + } + }, 300); + return () => clearTimeout(timer); + }, [pendingText, single]); + + return (text: string) => { + onChangeRef.current({ ...latest.current, text, parsing: true }); + setPendingText(text); + }; +} + +/** 规则串输入框 + 逐条错误(位置指示、相近名一键替换) */ +export function RuleStringField({ + state, + onChange, + single = false, + placeholder, + ariaLabel, + className, +}: { + state: LadderState; + onChange: (s: LadderState) => void; + single?: boolean; + placeholder?: string; + ariaLabel: string; + className?: string; +}) { + const setText = useRuleTextParser(state, onChange, { single }); + const replaceAt = (e: RuleParseError, s: string) => { + if (!e.atom) return; + const at = state.text.toUpperCase().indexOf(e.atom.toUpperCase(), Math.max(0, e.position - 1)); + const pos = at >= 0 ? at : e.position; + setText(state.text.slice(0, pos) + s + state.text.slice(pos + e.atom.length)); + }; + return ( +
    +
    + setText(e.target.value)} + placeholder={placeholder ?? (single ? "REMUX | BLU" : "SPECSUB & CNSUB > 4K > 1080P")} + aria-label={ariaLabel} + aria-invalid={state.errors.length > 0 || undefined} + spellCheck={false} + autoCapitalize="characters" + className="pr-8 font-mono text-caption" + /> + {state.parsing ? ( + + ) : null} +
    + {state.failure ?

    {state.failure}

    : null} + {state.errors.length ? ( +
      + {state.errors.map((e, i) => ( +
    • + + + 第 {e.position + 1} 个字符{single ? "" : `(第 ${e.layer + 1} 层)`}:{e.message} + + {e.suggestions?.length ? ( + + {e.suggestions.map((s) => ( + + ))} + + ) : null} +
    • + ))} +
    + ) : null} +
    + ); +} + +/** 出错位置:规则串原文 + 在出错字符下画标记 */ +function ErrorCaret({ text, position }: { text: string; position: number }) { + if (position < 0 || !text) return null; + const p = Math.min(position, text.length); + const start = Math.max(0, p - 24); + const end = Math.min(text.length, p + 24); + return ( + + {start > 0 ? "…" : ""} + {text.slice(start, p)} + + {text.slice(p, p + 1) || " "} + + {text.slice(p + 1, end)} + {end < text.length ? "…" : ""} + + ); +} + +// ─── 层卡片 ─── + +function OpToggle({ op, onChange, label }: { op: Op; onChange: (op: Op) => void; label: string }) { + return ( +
    + {(["and", "or"] as const).map((o) => ( + + ))} +
    + ); +} + +function AtomChip({ + item, + keyword, + onToggleNeg, + onRemove, +}: { + item: AtomItem; + keyword: RuleKeyword | undefined; + onToggleNeg: () => void; + onRemove: () => void; +}) { + const unknown = !keyword; + return ( + + + + + ); +} + +function Joiner({ op }: { op: Op }) { + return {OP_LABEL[op]}; +} + +function LayerCardView({ + card, + index, + total, + keywords, + onChange, + onMove, + onRemove, +}: { + card: LayerCard; + index: number; + total: number; + keywords: Map; + onChange: (c: LayerCard) => void; + onMove: (dir: -1 | 1) => void; + onRemove: () => void; +}) { + const setItem = (i: number, it: LayerItem | null) => + onChange({ + ...card, + items: it ? card.items.map((x, j) => (j === i ? it : x)) : card.items.filter((_, j) => j !== i), + }); + return ( +
  • +
    + + 优先级 {index + 1} · pri {100 - index} + + onChange({ ...card, name: e.target.value || undefined })} + placeholder="层名称(可选)" + aria-label={`第 ${index + 1} 层名称`} + maxLength={40} + className="h-7 w-36 text-meta" + /> + {card.items.length > 1 ? ( + onChange({ ...card, op })} label={`第 ${index + 1} 层条件关系`} /> + ) : null} + + + + + +
    +
    + {card.items.length === 0 ? 这一层还没有条件 : null} + {card.items.map((it, i) => ( + + {i > 0 ? : null} + {it.kind === "atom" ? ( + setItem(i, { ...it, neg: !it.neg })} + onRemove={() => setItem(i, null)} + /> + ) : ( + + + {it.items.map((a, k) => ( + + {k > 0 ? : null} + + setItem(i, { ...it, items: it.items.map((x, j) => (j === k ? { ...x, neg: !x.neg } : x)) }) + } + onRemove={() => { + const rest = it.items.filter((_, j) => j !== k); + setItem(i, rest.length ? { ...it, items: rest } : null); + }} + /> + + ))} + ) + {it.items.length > 1 ? ( + setItem(i, { ...it, op })} label="括号组内条件关系" /> + ) : null} + setItem(i, { ...it, items: [...it.items, { kind: "atom", id, neg: false }] })} + /> + + )} + + ))} + onChange({ ...card, items: [...card.items, { kind: "atom", id, neg: false }] })} + /> + +
    +
  • + ); +} + +/** 阶梯编辑:上面规则串,下面层卡片;任一处改动同步到另一处 */ +export function LadderEditor({ state, onChange }: { state: LadderState; onChange: (s: LadderState) => void }) { + const { map } = useKeywordMap(); + + const setCards = (cards: LayerCard[]) => { + const layers = cards.map(cardToLayer).filter((l): l is RuleLayer => !!l); + onChange({ text: layersToString(layers), layers, cards, errors: [], parsing: false, failure: null }); + }; + + return ( +
    + + {state.errors.length ? ( +

    规则串改对后同步到下面的层;现在显示的是上次解析成功的结果。

    + ) : null} + {state.cards === null ? ( +

    + 嵌套超过两层,只能在规则串里改。 +

    + ) : ( + <> + {state.cards.length ? ( +
      + {state.cards.map((c, i) => ( + setCards((state.cards ?? []).map((x, j) => (j === i ? next : x)))} + onMove={(dir) => { + const arr = [...(state.cards ?? [])]; + const [moved] = arr.splice(i, 1); + if (moved) arr.splice(i + dir, 0, moved); + setCards(arr); + }} + onRemove={() => setCards((state.cards ?? []).filter((_, j) => j !== i))} + /> + ))} +
    + ) : state.errors.length ? null : ( +

    没有层:只用硬排除过滤,不影响排序。

    + )} + + + )} +
    + ); +} diff --git a/apps/web/app/components/rules/preview-panel.tsx b/apps/web/app/components/rules/preview-panel.tsx new file mode 100644 index 00000000..9f9a23f5 --- /dev/null +++ b/apps/web/app/components/rules/preview-panel.tsx @@ -0,0 +1,177 @@ +import type { RuleLayer, RulePreviewResult } from "@umt/contracts"; +import { FlaskConical } from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; +import { usePreviewRuleGroup } from "~/api/rules.ts"; +import { FilterSelect } from "~/components/console/filter-bar.tsx"; +import { StatusBadge } from "~/components/console/status-badge.tsx"; +import { Button } from "~/components/ui/button.tsx"; +import { Textarea } from "~/components/ui/textarea.tsx"; +import { errorMessage } from "~/lib/api.ts"; +import { cn } from "~/lib/utils.ts"; + +// 试算:对最近 N 条已识别资源(或粘贴的标题)跑当前编辑中的规则组,给 pri / 命中层 / 拒绝原因与逐层依据。 + +const REASON: Record = { + hard_exclude: "命中硬排除", + "rule.hard_exclude": "命中硬排除", + no_layer_matched: "一层都没命中", + "rule.no_layer_matched": "一层都没命中", +}; +const reasonText = (r: string) => REASON[r] ?? r; + +export type PreviewDraft = { layers: RuleLayer[]; hardExcludes: string[]; ladderRequired: boolean }; + +export function RulePreviewPanel({ + groupId, + draft, + blocked, +}: { + groupId: string; + draft: PreviewDraft; + blocked: string | null; +}) { + const preview = usePreviewRuleGroup(); + const [limit, setLimit] = useState(""); + const [titles, setTitles] = useState(""); + const [pasting, setPasting] = useState(false); + const [result, setResult] = useState(null); + + const run = () => { + const list = titles + .split("\n") + .map((t) => t.trim()) + .filter(Boolean) + .slice(0, 50) + .map((title) => ({ title })); + preview.mutate( + { + id: groupId, + ...(pasting && list.length ? { titles: list } : { limit: Number(limit || 50) }), + draft: { layers: draft.layers, hardExcludes: draft.hardExcludes, ladderRequired: draft.ladderRequired }, + }, + { onSuccess: setResult, onError: (e) => toast.error(errorMessage(e, "试算失败")) }, + ); + }; + + return ( +
    +
    + {pasting ? null : ( + + )} + + +
    + {pasting ? ( +