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

@@ -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<string, string> = {
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<RulePreviewResult | null>(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 (
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
{pasting ? null : (
<FilterSelect
label="样本"
value={limit}
onChange={setLimit}
allLabel="最近 50 条"
options={[
{ value: "100", label: "最近 100 条" },
{ value: "200", label: "最近 200 条" },
]}
/>
)}
<Button type="button" variant="ghost" size="sm" onClick={() => setPasting((v) => !v)}>
{pasting ? "用最近资源" : "粘贴标题"}
</Button>
<Button
type="button"
size="sm"
className="ml-auto"
onClick={run}
disabled={preview.isPending || !!blocked}
title={blocked ?? undefined}
>
<FlaskConical />
{preview.isPending ? "试算中…" : "试算"}
</Button>
</div>
{pasting ? (
<Textarea
value={titles}
onChange={(e) => setTitles(e.target.value)}
placeholder={"每行一个资源标题,最多 50 行\nDune.Part.Two.2024.2160p.UHD.BluRay.REMUX.HDR.DV-FraMeSToR"}
aria-label="资源标题"
rows={4}
className="font-mono text-meta"
/>
) : null}
{blocked ? <p className="text-meta text-muted-foreground">{blocked},改好后再试算。</p> : null}
{result ? <PreviewResult result={result} /> : null}
</div>
);
}
function PreviewResult({ result }: { result: RulePreviewResult }) {
const hist = [...result.priHistogram].sort((a, b) => (b.pri ?? -1) - (a.pri ?? -1));
return (
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2 text-caption tabular-nums">
<span className="text-muted-foreground">样本 {result.sampleSize} 条</span>
<StatusBadge tone="success">通过 {result.passed}</StatusBadge>
<StatusBadge tone={result.rejected ? "destructive" : "neutral"}>拒绝 {result.rejected}</StatusBadge>
{hist.map((h) => (
<span key={String(h.pri)} className="text-meta text-muted-foreground">
{h.pri === null ? "拒绝" : `pri ${h.pri}`} × {h.count}
</span>
))}
</div>
{result.items.length === 0 ? (
<p className="text-meta text-muted-foreground">没有可试算的资源。站点抓到并识别过资源后再试。</p>
) : (
<div className="overflow-x-auto rounded-lg border border-border/70">
<table className="w-full text-left text-caption">
<thead>
<tr className="border-b border-border text-meta text-muted-foreground">
<th className="px-3 py-2 font-medium">资源</th>
<th className="px-3 py-2 font-medium whitespace-nowrap">结果</th>
<th className="hidden px-3 py-2 font-medium sm:table-cell">原因</th>
</tr>
</thead>
<tbody>
{result.items.map((it, i) => (
<tr
key={`${it.sourceReleaseId ?? it.title}-${i}`}
className="border-b border-border/60 align-top last:border-b-0"
>
<td className="max-w-0 px-3 py-2">
<p className="break-all text-foreground">{it.title}</p>
{it.subtitle ? <p className="line-clamp-1 text-meta text-muted-foreground">{it.subtitle}</p> : null}
{it.trace.length ? (
<details className="mt-1">
<summary className="cursor-pointer text-meta text-muted-foreground hover:text-foreground">
依据
</summary>
<ul className="mt-1 space-y-0.5">
{it.trace.map((t, k) => (
<li key={k} className="text-meta break-words text-muted-foreground">
{t}
</li>
))}
</ul>
</details>
) : null}
<p className="mt-1 text-meta text-muted-foreground sm:hidden">
{[...it.excludedBy.map((x) => `排除 ${x}`), ...it.reasons.map(reasonText)].join(";")}
</p>
</td>
<td className="px-3 py-2 whitespace-nowrap">
{it.passed ? (
<StatusBadge tone="success">
pri {it.pri}
{it.level !== null ? ` · 第 ${it.level + 1} 层` : ""}
</StatusBadge>
) : (
<StatusBadge tone="destructive">拒绝</StatusBadge>
)}
</td>
<td className={cn("hidden px-3 py-2 text-meta text-muted-foreground sm:table-cell")}>
{[...it.excludedBy.map((x) => `排除 ${x}`), ...it.reasons.map(reasonText)].join(";") || "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}