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,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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user