Files

178 lines
7.0 KiB
TypeScript
Raw Permalink 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 { 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>
);
}