Files
UHD_Media_Tool/apps/web/app/components/rules/preview-panel.tsx
T
rootandClaude Opus 5.5 52402864d6 style(copy): punctuation rules and site-wide fix with a CI check (QA U11)
- glossary: 10 punctuation rules (full-width in Chinese, CJK/Latin spacing,
  full-width parens around Chinese, single ellipsis, 「」 quotes, period rules)
- scripts/check-copy-punctuation.mjs: checks string literals, templates and JSX
  text (not comments, types, keys, match/compare strings); --fix, --strict,
  --by-file; wired into deno task ci as check:copy in warn mode
- --fix applied to web, server and contracts; files other lanes were editing
  (subscriptions, sites, browser, agent, auth/account, inventory) left for them
- tests updated for the changed messages

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-28 20:09:29 +08:00

178 lines
7.0 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 { 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>
);
}