Files
UHD_Media_Tool/apps/web/app/components/rules/tabs.tsx
T
rootandClaude Opus 5.5 b908b4a0c9 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]>
2026-09-28 16:06:16 +08:00

574 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { 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>
);
}