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:
rootandClaude Opus 5.5 committed 2026-09-28 16:06:16 +08:00
1 parent 81bf010e3e
commit b908b4a0c9
12 files changed
+2657 -427

No files matched your search

+573
View File
@@ -0,0 +1,573 @@
import type { RuleAtom, RuleAtomCreate, RuleDefaults, RuleGroup, RuleGroupKind } from "@umt/contracts";
import { ArrowDown, ArrowUp, Ban, FlaskConical, Plus, Save, Trash2, X } from "lucide-react";
import { useEffect, useId, useMemo, useState } from "react";
import { toast } from "sonner";
import {
useCreateRuleAtom,
useDeleteRuleAtom,
useLiftRuleSuppression,
useRuleAtoms,
useRuleDefaults,
useRuleGroups,
useRuleKeywords,
useRuleSuppressions,
useSaveRuleDefaults,
useUpdateRuleAtom,
} from "~/api/rules.ts";
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
import { type DataColumn, DataTable } from "~/components/console/data-table.tsx";
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { FilterSelect } from "~/components/console/filter-bar.tsx";
import { FormField } from "~/components/console/form-field.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { Chip, ChipToggleGroup, Panel } from "~/components/library/parts.tsx";
import { Button } from "~/components/ui/button.tsx";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog.tsx";
import { Input } from "~/components/ui/input.tsx";
import { Switch } from "~/components/ui/switch.tsx";
import { Textarea } from "~/components/ui/textarea.tsx";
import { errorMessage } from "~/lib/api.ts";
import { formatDateTime } from "~/lib/utils.ts";
import { DEFAULT_CHAIN_LABEL, RULE_KIND, suppressReason } from "./labels.ts";
// 规则页的其余 Tab:自定义条件、默认值、抑制记录
// ─── 自定义条件 ───
const MATCH_FIELD = { title: "标题", subtitle: "副标题", labels: "标签" } as const;
type MatchField = keyof typeof MATCH_FIELD;
type AtomForm = {
key: string;
name: string;
description: string;
include: string;
exclude: string;
matchFields: MatchField[];
sizeMin: string;
sizeMax: string;
minSeeders: string;
delayMin: string;
delayMax: string;
free: boolean;
};
const lines = (s: string) =>
s
.split("\n")
.map((x) => x.trim())
.filter(Boolean);
const num = (s: string) => (s.trim() === "" ? undefined : Number(s));
const range = (min: string, max: string) =>
num(min) === undefined && num(max) === undefined ? null : { min: num(min), max: num(max) };
function atomForm(a: RuleAtom | null): AtomForm {
return {
key: a?.key ?? "",
name: a?.name ?? "",
description: a?.description ?? "",
include: (a?.include ?? []).join("\n"),
exclude: (a?.exclude ?? []).join("\n"),
matchFields: a?.matchFields ?? [],
sizeMin: a?.unitSizeMb?.min?.toString() ?? "",
sizeMax: a?.unitSizeMb?.max?.toString() ?? "",
minSeeders: a?.minSeeders?.toString() ?? "",
delayMin: a?.publishDelayMinutes?.min?.toString() ?? "",
delayMax: a?.publishDelayMinutes?.max?.toString() ?? "",
free: a?.free ?? false,
};
}
function AtomDialog({
open,
atom,
onOpenChange,
}: {
open: boolean;
atom: RuleAtom | null;
onOpenChange: (o: boolean) => void;
}) {
const uid = useId();
const create = useCreateRuleAtom();
const update = useUpdateRuleAtom();
const [f, setF] = useState<AtomForm>(() => atomForm(atom));
const [error, setError] = useState<string | null>(null);
// biome-ignore lint/correctness/useExhaustiveDependencies: 打开时按当前条件重置
useEffect(() => {
if (open) {
setF(atomForm(atom));
setError(null);
}
}, [open, atom?.id]);
const set = <K extends keyof AtomForm>(k: K, v: AtomForm[K]) => setF((x) => ({ ...x, [k]: v }));
const submit = async () => {
const body: RuleAtomCreate = {
key: f.key.trim().toUpperCase(),
name: f.name.trim() || null,
description: f.description.trim() || null,
include: lines(f.include),
exclude: lines(f.exclude),
matchFields: f.matchFields,
unitSizeMb: range(f.sizeMin, f.sizeMax),
minSeeders: num(f.minSeeders) ?? null,
publishDelayMinutes: range(f.delayMin, f.delayMax),
free: f.free,
};
try {
if (atom) {
const { key: _k, ...rest } = body;
await update.mutateAsync({ id: atom.id, ...rest });
} else await create.mutateAsync(body);
toast.success(atom ? "已保存" : `已新建条件 ${body.key}`);
onOpenChange(false);
} catch (err) {
setError(errorMessage(err, "保存失败"));
}
};
const pending = create.isPending || update.isPending;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-xl">
<DialogHeader>
<DialogTitle>{atom ? `编辑条件 ${atom.key}` : "新建自定义条件"}</DialogTitle>
<DialogDescription>字段同 MoviePilot 自定义规则。正则不区分大小写,多条之间是“或”。</DialogDescription>
</DialogHeader>
<div className="grid gap-x-4 sm:grid-cols-2">
<FormField label="关键字" htmlFor={`${uid}-key`} required hint="大写字母和数字;不能与内置关键字同名">
<Input
id={`${uid}-key`}
value={f.key}
disabled={!!atom}
maxLength={32}
onChange={(e) => set("key", e.target.value.toUpperCase())}
className="font-mono"
/>
</FormField>
<FormField label="名称" htmlFor={`${uid}-name`}>
<Input id={`${uid}-name`} value={f.name} onChange={(e) => set("name", e.target.value)} />
</FormField>
<FormField label="说明" htmlFor={`${uid}-desc`} className="sm:col-span-2">
<Input id={`${uid}-desc`} value={f.description} onChange={(e) => set("description", e.target.value)} />
</FormField>
<FormField label="包含(正则,每行一条)" htmlFor={`${uid}-inc`}>
<Textarea
id={`${uid}-inc`}
rows={3}
value={f.include}
onChange={(e) => set("include", e.target.value)}
className="font-mono text-meta"
/>
</FormField>
<FormField label="排除(正则,每行一条)" htmlFor={`${uid}-exc`}>
<Textarea
id={`${uid}-exc`}
rows={3}
value={f.exclude}
onChange={(e) => set("exclude", e.target.value)}
className="font-mono text-meta"
/>
</FormField>
<FormField label="匹配字段" hint="不选 = 全部" className="sm:col-span-2">
<ChipToggleGroup
label="匹配字段"
options={(Object.keys(MATCH_FIELD) as MatchField[]).map((k) => ({ value: k, label: MATCH_FIELD[k] }))}
value={f.matchFields}
onChange={(v) => set("matchFields", v)}
/>
</FormField>
<FormField label="大小(MB,剧集按每集)">
<div className="flex items-center gap-2">
<Input
inputMode="numeric"
aria-label="最小 MB"
placeholder="最小"
value={f.sizeMin}
onChange={(e) => set("sizeMin", e.target.value)}
/>
<span className="text-muted-foreground">–</span>
<Input
inputMode="numeric"
aria-label="最大 MB"
placeholder="最大"
value={f.sizeMax}
onChange={(e) => set("sizeMax", e.target.value)}
/>
</div>
</FormField>
<FormField label="发布后(分钟)">
<div className="flex items-center gap-2">
<Input
inputMode="numeric"
aria-label="最少分钟"
placeholder="最少"
value={f.delayMin}
onChange={(e) => set("delayMin", e.target.value)}
/>
<span className="text-muted-foreground">–</span>
<Input
inputMode="numeric"
aria-label="最多分钟"
placeholder="最多"
value={f.delayMax}
onChange={(e) => set("delayMax", e.target.value)}
/>
</div>
</FormField>
<FormField label="最少做种" htmlFor={`${uid}-seed`}>
<Input
id={`${uid}-seed`}
inputMode="numeric"
value={f.minSeeders}
onChange={(e) => set("minSeeders", e.target.value)}
/>
</FormField>
<div className="flex items-center justify-between gap-3 pb-5">
<span className="text-caption font-medium text-foreground">只要免费</span>
<Switch checked={f.free} onCheckedChange={(v) => set("free", v)} aria-label="只要免费" />
</div>
</div>
{error ? <p className="text-caption text-destructive">{error}</p> : null}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
取消
</Button>
<Button onClick={submit} disabled={pending || !f.key.trim()}>
{pending ? "保存中…" : atom ? "保存" : "新建"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function atomSummary(a: RuleAtom): string {
const p: string[] = [];
if (a.include.length) p.push(`包含 ${a.include.join(" | ")}`);
if (a.exclude.length) p.push(`排除 ${a.exclude.join(" | ")}`);
if (a.unitSizeMb) p.push(`大小 ${a.unitSizeMb.min ?? 0}–${a.unitSizeMb.max ?? "∞"} MB`);
if (a.minSeeders !== null) p.push(`做种 ≥ ${a.minSeeders}`);
if (a.publishDelayMinutes) p.push(`发布 ${a.publishDelayMinutes.min ?? 0}–${a.publishDelayMinutes.max ?? "∞"} 分钟`);
if (a.free) p.push("免费");
return p.join(";") || "没有条件";
}
export function AtomsTab() {
const atoms = useRuleAtoms();
const keywords = useRuleKeywords();
const del = useDeleteRuleAtom();
const [dialog, setDialog] = useState<{ open: boolean; atom: RuleAtom | null }>({ open: false, atom: null });
const builtins = (keywords.data ?? []).filter((k) => k.builtin);
const columns: DataColumn<RuleAtom>[] = [
{
key: "key",
header: "关键字",
cell: (a) => (
<div>
<span className="font-mono font-medium text-foreground">{a.key}</span>
{a.name ? <span className="ml-2 text-meta text-muted-foreground">{a.name}</span> : null}
</div>
),
},
{
key: "cond",
header: "条件",
cell: (a) => (
<span className="line-clamp-2 font-mono text-meta break-all text-muted-foreground">{atomSummary(a)}</span>
),
},
{
key: "act",
header: "",
align: "right",
cell: (a) => (
// biome-ignore lint/a11y/noStaticElementInteractions: 阻止行点击冒泡
// biome-ignore lint/a11y/useKeyWithClickEvents: 同上
<span onClick={(e) => e.stopPropagation()}>
<ConfirmDialog
trigger={
<Button variant="ghost" size="icon-xs" aria-label={`删除 ${a.key}`} className="hover:text-destructive">
<Trash2 />
</Button>
}
title={`删除条件 ${a.key}?`}
description="被规则组引用时不能删除。"
confirmLabel="删除"
destructive
onConfirm={async () => {
try {
await del.mutateAsync(a.id);
toast.success("已删除");
} catch (err) {
toast.error(errorMessage(err, "删除失败"));
throw err;
}
}}
/>
</span>
),
},
];
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-caption text-muted-foreground">
内置关键字不够用时,用正则、大小、做种数等定义新条件,在规则串里按关键字引用。
</p>
<Button size="sm" onClick={() => setDialog({ open: true, atom: null })}>
<Plus />
新建条件
</Button>
</div>
<DataTable
columns={columns}
rows={atoms.data?.items}
rowKey={(a) => a.id}
loading={atoms.isPending}
error={atoms.error}
onRetry={() => atoms.refetch()}
onRowClick={(a) => setDialog({ open: true, atom: a })}
empty={{ icon: FlaskConical, title: "还没有自定义条件", description: "内置关键字覆盖不到时再新建。" }}
/>
<Panel title="内置关键字" description="对齐 MoviePilot;判定依据按解析字段优先">
{keywords.isPending ? (
<LoadingState rows={3} />
) : keywords.isError ? (
<ErrorState plain error={keywords.error} onRetry={() => keywords.refetch()} />
) : (
<ul className="grid gap-x-6 gap-y-2 sm:grid-cols-2">
{builtins.map((k) => (
<li key={k.id} className="flex min-w-0 items-baseline gap-2 text-caption">
<span className="w-16 shrink-0 font-mono font-medium text-foreground">{k.id}</span>
<span className="min-w-0 text-muted-foreground">
{k.supported ? k.description || k.name : "不支持,规则串里出现会报错"}
</span>
</li>
))}
</ul>
)}
</Panel>
<AtomDialog open={dialog.open} atom={dialog.atom} onOpenChange={(o) => setDialog((d) => ({ ...d, open: o }))} />
</div>
);
}
// ─── 默认值 ───
const KINDS: RuleGroupKind[] = ["acquisition", "upgrade", "search"];
export function DefaultsTab({ groups }: { groups: RuleGroup[] | undefined }) {
const q = useRuleDefaults();
const save = useSaveRuleDefaults();
const [draft, setDraft] = useState<RuleDefaults | null>(null);
useEffect(() => {
if (q.data) setDraft(q.data);
}, [q.data]);
const byId = useMemo(() => new Map((groups ?? []).map((g) => [g.id, g])), [groups]);
const dirty = !!draft && !!q.data && JSON.stringify(draft) !== JSON.stringify(q.data);
if (q.isPending || !draft) return <LoadingState rows={3} className="pg-slab rounded-xl p-4" />;
if (q.isError) return <ErrorState error={q.error} onRetry={() => q.refetch()} />;
const setKind = (k: RuleGroupKind, ids: string[]) => setDraft((d) => (d ? { ...d, [k]: ids } : d));
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-caption text-muted-foreground">
订阅和版本目标没指定规则组时用这里的链。链上的组按顺序串联,任一组拒绝即拒绝,优先级取最后一个带层的组。
</p>
<div className="flex items-center gap-2">
{dirty ? <StatusBadge tone="warning">未保存</StatusBadge> : null}
{dirty ? (
<Button variant="ghost" size="sm" onClick={() => setDraft(q.data)}>
还原
</Button>
) : null}
<Button
size="sm"
disabled={!dirty || save.isPending}
onClick={async () => {
try {
await save.mutateAsync(draft);
toast.success("默认值已保存");
} catch (err) {
toast.error(errorMessage(err, "保存失败"));
}
}}
>
<Save />
{save.isPending ? "保存中…" : "保存"}
</Button>
</div>
</div>
<div className="grid gap-4 lg:grid-cols-3">
{KINDS.map((k) => {
const ids = draft[k];
const candidates = (groups ?? []).filter((g) => g.kind === k && !ids.includes(g.id));
const lastLadder = [...ids].reverse().find((id) => (byId.get(id)?.layers.length ?? 0) > 0);
return (
<Panel key={k} title={DEFAULT_CHAIN_LABEL[k]} description={RULE_KIND[k].hint}>
{ids.length === 0 ? (
<p className="text-meta text-muted-foreground">链为空:不按规则组过滤。</p>
) : (
<ol className="space-y-1.5">
{ids.map((id, i) => {
const g = byId.get(id);
return (
<li key={id} className="flex items-center gap-2 rounded-lg border border-border/70 px-2.5 py-1.5">
<span className="text-meta text-muted-foreground tabular-nums">{i + 1}</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-caption text-foreground">
{g?.name ?? "已删除的规则组"}
</span>
{id === lastLadder ? (
<span className="text-micro text-muted-foreground">决定优先级</span>
) : null}
{g && !g.enabled ? <span className="text-micro text-warning">已停用,评估时跳过</span> : null}
</span>
<Button
variant="ghost"
size="icon-xs"
aria-label="上移"
disabled={i === 0}
onClick={() => {
const a = [...ids];
[a[i - 1], a[i]] = [a[i] as string, a[i - 1] as string];
setKind(k, a);
}}
>
<ArrowUp />
</Button>
<Button
variant="ghost"
size="icon-xs"
aria-label="下移"
disabled={i === ids.length - 1}
onClick={() => {
const a = [...ids];
[a[i + 1], a[i]] = [a[i] as string, a[i + 1] as string];
setKind(k, a);
}}
>
<ArrowDown />
</Button>
<Button
variant="ghost"
size="icon-xs"
aria-label="移出"
onClick={() =>
setKind(
k,
ids.filter((x) => x !== id),
)
}
>
<X />
</Button>
</li>
);
})}
</ol>
)}
{candidates.length ? (
<div className="mt-3">
<FilterSelect
label="加入"
value=""
allLabel="选择规则组"
onChange={(v) => v && setKind(k, [...ids, v])}
options={candidates.map((g) => ({ value: g.id, label: g.name }))}
className="w-full"
/>
</div>
) : null}
</Panel>
);
})}
</div>
</div>
);
}
// ─── 抑制记录 ───
const actorName = (s: string | null) => (s ? s.replace(/^[a-z_]+:/, "") || s : "—");
export function SuppressionsTab() {
const sups = useRuleSuppressions();
const groups = useRuleGroups({ includeDeleted: "true" }).data?.items;
const lift = useLiftRuleSuppression();
const byId = useMemo(() => new Map((groups ?? []).map((g) => [g.id, g])), [groups]);
const active = (sups.data?.items ?? []).filter((s) => s.active).length;
return (
<Panel title="抑制记录" description={sups.data ? `${active} 条生效中` : undefined}>
<p className="mb-3 text-caption text-muted-foreground">
停用、删除,或改掉对话 / Agent 提出的规则,都会留下抑制记录。Agent 再提出同样的规则会被直接拒绝,解除后才能再提。
</p>
{sups.isPending ? (
<LoadingState rows={2} />
) : sups.isError ? (
<ErrorState plain error={sups.error} onRetry={() => sups.refetch()} />
) : sups.data.items.length === 0 ? (
<EmptyState plain icon={Ban} title="没有抑制记录" className="py-8" />
) : (
<ul className="divide-y divide-border/60">
{sups.data.items.map((s) => {
const gid = s.ruleGroupId ?? s.preferenceTagId;
const g = gid ? byId.get(gid) : undefined;
return (
<li key={s.id} className="flex flex-wrap items-center gap-x-3 gap-y-1 py-2.5">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="min-w-0 font-medium break-words text-foreground">
{g?.name ?? "已删除的规则组"}
</span>
<Chip>{suppressReason(s.reason)}</Chip>
{s.active ? <StatusBadge tone="warning">抑制中</StatusBadge> : <StatusBadge>已解除</StatusBadge>}
</div>
<p className="mt-0.5 text-meta text-muted-foreground tabular-nums">
{actorName(s.suppressedBy)} · {formatDateTime(s.createdAt)}
{s.liftedAt ? ` · ${actorName(s.liftedBy)} 于 ${formatDateTime(s.liftedAt)} 解除` : ""}
</p>
</div>
{s.active ? (
<ConfirmDialog
trigger={
<Button variant="outline" size="sm">
解除抑制
</Button>
}
title="解除这条抑制?"
description="解除后 Agent 可以再次提出这条规则,仍需审批。规则组本身不会恢复。"
confirmLabel="解除"
onConfirm={async () => {
try {
await lift.mutateAsync({ id: s.id, reason: "用户明确解除" });
toast.success("已解除抑制");
} catch (err) {
toast.error(errorMessage(err));
throw err;
}
}}
/>
) : null}
</li>
);
})}
</ul>
)}
</Panel>
);
}