Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
178 lines
7.0 KiB
TypeScript
178 lines
7.0 KiB
TypeScript
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>
|
||
);
|
||
}
|