Files
UHD_Media_Tool/apps/web/app/components/rules/tabs.tsx
T
rootandClaude Opus 5.5 57ce40168d refactor(web): 技术信息折叠铺到媒体库 / 审计 / 运行状态,清理正常态徽章与解释型页描述(DEAI-2)
- 媒体库详情:作品 UUID 进技术信息;「实际路径与库根」改「文件位置」,正常态(已核验、
  可访问、主位置)不加徽章;外部身份显示「TMDB 90228 · 你确认的 / 自动匹配」,修订号进悬停;
  元数据字段来源默认收起(「显示来源」,跟随界面设置);别名默认只列中英文与手动加的;
  「内容单元」→「季与集」,「临时单元」→「编号待确认」;侧栏与页标题统一「媒体库」
- 审计:动作列不再重复英文码,对象列显示中文类型 / 能力名,ID 只在详情;补 capability.denied
- 运行状态:进程、已下线实例、趋势图与最大表收进折叠区;图例同名序列加序号,不再拼接
- 站点卡:账号正常与登录方式不加徽章,健康不显示毫秒,限流移出卡片
- 文件管理:目录「正常」徽章与重复的剩余空间去掉;已登记 → 已入库,临时文件 → 未完成
- 规则:「修订」列改「最后修改」;不支持的关键字合成一行;页签改「默认规则 / 自动收新作品 / 已忽略」;
  默认规则说明去掉「带层」等算法描述
- 站点 Passkey 与登录用通行密钥区分命名;命令面板提示去重
- 删解释型页描述(审计、搜索、下载、下载计划、RSS、规则、媒体库、运行状态、站点、任务、计划、整理)

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

580 lines
23 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 { RuleAtom, RuleAtomCreate, RuleDefaults, RuleGroup, RuleGroupKind } from "@umt/contracts";
import { ArrowDown, ArrowUp, Ban, FlaskConical, Plus, Save, Trash2, X } from "lucide-react";
import { useEffect, useId, useMemo, useState } from "react";
import { toast } from "sonner";
import {
useCreateRuleAtom,
useDeleteRuleAtom,
useLiftRuleSuppression,
useRuleAtoms,
useRuleDefaults,
useRuleGroups,
useRuleKeywords,
useRuleSuppressions,
useSaveRuleDefaults,
useUpdateRuleAtom,
} from "~/api/rules.ts";
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
import { type DataColumn, DataTable } from "~/components/console/data-table.tsx";
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { FilterSelect } from "~/components/console/filter-bar.tsx";
import { FormField } from "~/components/console/form-field.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { Chip, ChipToggleGroup, Panel } from "~/components/library/parts.tsx";
import { Button } from "~/components/ui/button.tsx";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog.tsx";
import { Input } from "~/components/ui/input.tsx";
import { Switch } from "~/components/ui/switch.tsx";
import { Textarea } from "~/components/ui/textarea.tsx";
import { errorMessage } from "~/lib/api.ts";
import { formatDateTime } from "~/lib/utils.ts";
import { DEFAULT_CHAIN_LABEL, RULE_KIND, suppressReason } from "./labels.ts";
// 规则页的其余 Tab:自定义条件、默认值、抑制记录
// ─── 自定义条件 ───
const MATCH_FIELD = { title: "标题", subtitle: "副标题", labels: "标签" } as const;
type MatchField = keyof typeof MATCH_FIELD;
type AtomForm = {
key: string;
name: string;
description: string;
include: string;
exclude: string;
matchFields: MatchField[];
sizeMin: string;
sizeMax: string;
minSeeders: string;
delayMin: string;
delayMax: string;
free: boolean;
};
const lines = (s: string) =>
s
.split("\n")
.map((x) => x.trim())
.filter(Boolean);
const num = (s: string) => (s.trim() === "" ? undefined : Number(s));
const range = (min: string, max: string) =>
num(min) === undefined && num(max) === undefined ? null : { min: num(min), max: num(max) };
function atomForm(a: RuleAtom | null): AtomForm {
return {
key: a?.key ?? "",
name: a?.name ?? "",
description: a?.description ?? "",
include: (a?.include ?? []).join("\n"),
exclude: (a?.exclude ?? []).join("\n"),
matchFields: a?.matchFields ?? [],
sizeMin: a?.unitSizeMb?.min?.toString() ?? "",
sizeMax: a?.unitSizeMb?.max?.toString() ?? "",
minSeeders: a?.minSeeders?.toString() ?? "",
delayMin: a?.publishDelayMinutes?.min?.toString() ?? "",
delayMax: a?.publishDelayMinutes?.max?.toString() ?? "",
free: a?.free ?? false,
};
}
function AtomDialog({
open,
atom,
onOpenChange,
}: {
open: boolean;
atom: RuleAtom | null;
onOpenChange: (o: boolean) => void;
}) {
const uid = useId();
const create = useCreateRuleAtom();
const update = useUpdateRuleAtom();
const [f, setF] = useState<AtomForm>(() => atomForm(atom));
const [error, setError] = useState<string | null>(null);
// biome-ignore lint/correctness/useExhaustiveDependencies: 打开时按当前条件重置
useEffect(() => {
if (open) {
setF(atomForm(atom));
setError(null);
}
}, [open, atom?.id]);
const set = <K extends keyof AtomForm>(k: K, v: AtomForm[K]) => setF((x) => ({ ...x, [k]: v }));
const submit = async () => {
const body: RuleAtomCreate = {
key: f.key.trim().toUpperCase(),
name: f.name.trim() || null,
description: f.description.trim() || null,
include: lines(f.include),
exclude: lines(f.exclude),
matchFields: f.matchFields,
unitSizeMb: range(f.sizeMin, f.sizeMax),
minSeeders: num(f.minSeeders) ?? null,
publishDelayMinutes: range(f.delayMin, f.delayMax),
free: f.free,
};
try {
if (atom) {
const { key: _k, ...rest } = body;
await update.mutateAsync({ id: atom.id, ...rest });
} else await create.mutateAsync(body);
toast.success(atom ? "已保存" : `已新建条件 ${body.key}`);
onOpenChange(false);
} catch (err) {
setError(errorMessage(err, "保存失败"));
}
};
const pending = create.isPending || update.isPending;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-xl">
<DialogHeader>
<DialogTitle>{atom ? `编辑条件 ${atom.key}` : "新建自定义条件"}</DialogTitle>
<DialogDescription>字段同 MoviePilot 自定义规则。正则不区分大小写,多条之间是「或」。</DialogDescription>
</DialogHeader>
<div className="grid gap-x-4 sm:grid-cols-2">
<FormField label="关键字" htmlFor={`${uid}-key`} required hint="大写字母和数字;不能与内置关键字同名">
<Input
id={`${uid}-key`}
value={f.key}
disabled={!!atom}
maxLength={32}
onChange={(e) => set("key", e.target.value.toUpperCase())}
className="font-mono"
/>
</FormField>
<FormField label="名称" htmlFor={`${uid}-name`}>
<Input id={`${uid}-name`} value={f.name} onChange={(e) => set("name", e.target.value)} />
</FormField>
<FormField label="说明" htmlFor={`${uid}-desc`} className="sm:col-span-2">
<Input id={`${uid}-desc`} value={f.description} onChange={(e) => set("description", e.target.value)} />
</FormField>
<FormField label="包含(正则,每行一条)" htmlFor={`${uid}-inc`}>
<Textarea
id={`${uid}-inc`}
rows={3}
value={f.include}
onChange={(e) => set("include", e.target.value)}
className="font-mono text-meta"
/>
</FormField>
<FormField label="排除(正则,每行一条)" htmlFor={`${uid}-exc`}>
<Textarea
id={`${uid}-exc`}
rows={3}
value={f.exclude}
onChange={(e) => set("exclude", e.target.value)}
className="font-mono text-meta"
/>
</FormField>
<FormField label="匹配字段" hint="不选 = 全部" className="sm:col-span-2">
<ChipToggleGroup
label="匹配字段"
options={(Object.keys(MATCH_FIELD) as MatchField[]).map((k) => ({ value: k, label: MATCH_FIELD[k] }))}
value={f.matchFields}
onChange={(v) => set("matchFields", v)}
/>
</FormField>
<FormField label="大小(MB,剧集按每集)">
<div className="flex items-center gap-2">
<Input
inputMode="numeric"
aria-label="最小 MB"
placeholder="最小"
value={f.sizeMin}
onChange={(e) => set("sizeMin", e.target.value)}
/>
<span className="text-muted-foreground">–</span>
<Input
inputMode="numeric"
aria-label="最大 MB"
placeholder="最大"
value={f.sizeMax}
onChange={(e) => set("sizeMax", e.target.value)}
/>
</div>
</FormField>
<FormField label="发布后(分钟)">
<div className="flex items-center gap-2">
<Input
inputMode="numeric"
aria-label="最少分钟"
placeholder="最少"
value={f.delayMin}
onChange={(e) => set("delayMin", e.target.value)}
/>
<span className="text-muted-foreground">–</span>
<Input
inputMode="numeric"
aria-label="最多分钟"
placeholder="最多"
value={f.delayMax}
onChange={(e) => set("delayMax", e.target.value)}
/>
</div>
</FormField>
<FormField label="最少做种" htmlFor={`${uid}-seed`}>
<Input
id={`${uid}-seed`}
inputMode="numeric"
value={f.minSeeders}
onChange={(e) => set("minSeeders", e.target.value)}
/>
</FormField>
<div className="flex items-center justify-between gap-3 pb-5">
<span className="text-caption font-medium text-foreground">只要免费</span>
<Switch checked={f.free} onCheckedChange={(v) => set("free", v)} aria-label="只要免费" />
</div>
</div>
{error ? <p className="text-caption text-destructive">{error}</p> : null}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
取消
</Button>
<Button onClick={submit} disabled={pending || !f.key.trim()}>
{pending ? "保存中…" : atom ? "保存" : "新建"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function atomSummary(a: RuleAtom): string {
const p: string[] = [];
if (a.include.length) p.push(`包含 ${a.include.join(" | ")}`);
if (a.exclude.length) p.push(`排除 ${a.exclude.join(" | ")}`);
if (a.unitSizeMb) p.push(`大小 ${a.unitSizeMb.min ?? 0}–${a.unitSizeMb.max ?? "∞"} MB`);
if (a.minSeeders !== null) p.push(`做种 ≥ ${a.minSeeders}`);
if (a.publishDelayMinutes) p.push(`发布 ${a.publishDelayMinutes.min ?? 0}–${a.publishDelayMinutes.max ?? "∞"} 分钟`);
if (a.free) p.push("免费");
return p.join(";") || "没有条件";
}
export function AtomsTab() {
const atoms = useRuleAtoms();
const keywords = useRuleKeywords();
const del = useDeleteRuleAtom();
const [dialog, setDialog] = useState<{ open: boolean; atom: RuleAtom | null }>({ open: false, atom: null });
const builtins = (keywords.data ?? []).filter((k) => k.builtin);
const columns: DataColumn<RuleAtom>[] = [
{
key: "key",
header: "关键字",
cell: (a) => (
<div>
<span className="font-mono font-medium text-foreground">{a.key}</span>
{a.name ? <span className="ml-2 text-meta text-muted-foreground">{a.name}</span> : null}
</div>
),
},
{
key: "cond",
header: "条件",
cell: (a) => (
<span className="line-clamp-2 font-mono text-meta break-all text-muted-foreground">{atomSummary(a)}</span>
),
},
{
key: "act",
header: "",
align: "right",
cell: (a) => (
// biome-ignore lint/a11y/noStaticElementInteractions: 阻止行点击冒泡
// biome-ignore lint/a11y/useKeyWithClickEvents: 同上
<span onClick={(e) => e.stopPropagation()}>
<ConfirmDialog
trigger={
<Button variant="ghost" size="icon-xs" aria-label={`删除 ${a.key}`} className="hover:text-destructive">
<Trash2 />
</Button>
}
title={`删除条件 ${a.key}?`}
description="被规则组引用时不能删除。"
confirmLabel="删除"
destructive
onConfirm={async () => {
try {
await del.mutateAsync(a.id);
toast.success("已删除");
} catch (err) {
toast.error(errorMessage(err, "删除失败"));
throw err;
}
}}
/>
</span>
),
},
];
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-caption text-muted-foreground">
内置关键字不够用时,用正则、大小、做种数等定义新条件,在规则串里按关键字引用。
</p>
<Button size="sm" onClick={() => setDialog({ open: true, atom: null })}>
<Plus />
新建条件
</Button>
</div>
<DataTable
columns={columns}
rows={atoms.data?.items}
rowKey={(a) => a.id}
loading={atoms.isPending}
error={atoms.error}
onRetry={() => atoms.refetch()}
onRowClick={(a) => setDialog({ open: true, atom: a })}
empty={{ icon: FlaskConical, title: "还没有自定义条件", description: "内置关键字覆盖不到时再新建。" }}
/>
<Panel title="内置关键字">
{keywords.isPending ? (
<LoadingState rows={3} />
) : keywords.isError ? (
<ErrorState plain error={keywords.error} onRetry={() => keywords.refetch()} />
) : (
<ul className="grid gap-x-6 gap-y-2 sm:grid-cols-2">
{builtins
.filter((k) => k.supported)
.map((k) => (
<li key={k.id} className="flex min-w-0 items-baseline gap-2 text-caption">
<span className="w-16 shrink-0 font-mono font-medium text-foreground">{k.id}</span>
<span className="min-w-0 text-muted-foreground">{k.description || k.name}</span>
</li>
))}
{builtins.some((k) => !k.supported) ? (
<li className="text-meta text-muted-foreground sm:col-span-2">
另有 {builtins.filter((k) => !k.supported).length} 个关键字暂不支持,写进规则串会报错
</li>
) : null}
</ul>
)}
</Panel>
<AtomDialog open={dialog.open} atom={dialog.atom} onOpenChange={(o) => setDialog((d) => ({ ...d, open: o }))} />
</div>
);
}
// ─── 默认值 ───
const KINDS: RuleGroupKind[] = ["acquisition", "upgrade", "search"];
export function DefaultsTab({ groups }: { groups: RuleGroup[] | undefined }) {
const q = useRuleDefaults();
const save = useSaveRuleDefaults();
const [draft, setDraft] = useState<RuleDefaults | null>(null);
useEffect(() => {
if (q.data) setDraft(q.data);
}, [q.data]);
const byId = useMemo(() => new Map((groups ?? []).map((g) => [g.id, g])), [groups]);
const dirty = !!draft && !!q.data && JSON.stringify(draft) !== JSON.stringify(q.data);
if (q.isPending || !draft) return <LoadingState rows={3} className="pg-slab rounded-xl p-4" />;
if (q.isError) return <ErrorState error={q.error} onRetry={() => q.refetch()} />;
const setKind = (k: RuleGroupKind, ids: string[]) => setDraft((d) => (d ? { ...d, [k]: ids } : d));
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-caption text-muted-foreground">
订阅和收录版本没指定规则组时用这里的链。按顺序检查,任一组不通过就不下载。
</p>
<div className="flex items-center gap-2">
{dirty ? <StatusBadge tone="warning">未保存</StatusBadge> : null}
{dirty ? (
<Button variant="ghost" size="sm" onClick={() => setDraft(q.data)}>
还原
</Button>
) : null}
<Button
size="sm"
disabled={!dirty || save.isPending}
onClick={async () => {
try {
await save.mutateAsync(draft);
toast.success("默认值已保存");
} catch (err) {
toast.error(errorMessage(err, "保存失败"));
}
}}
>
<Save />
{save.isPending ? "保存中…" : "保存"}
</Button>
</div>
</div>
<div className="grid gap-4 lg:grid-cols-3">
{KINDS.map((k) => {
const ids = draft[k];
const candidates = (groups ?? []).filter((g) => g.kind === k && !ids.includes(g.id));
const lastLadder = [...ids].reverse().find((id) => (byId.get(id)?.layers.length ?? 0) > 0);
return (
<Panel key={k} title={DEFAULT_CHAIN_LABEL[k]} description={RULE_KIND[k].hint}>
{ids.length === 0 ? (
<p className="text-meta text-muted-foreground">链为空:不按规则组过滤。</p>
) : (
<ol className="space-y-1.5">
{ids.map((id, i) => {
const g = byId.get(id);
return (
<li key={id} className="flex items-center gap-2 rounded-lg border border-border/70 px-2.5 py-1.5">
<span className="text-meta text-muted-foreground tabular-nums">{i + 1}</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-caption text-foreground">
{g?.name ?? "已删除的规则组"}
</span>
{id === lastLadder ? (
<span className="text-micro text-muted-foreground">决定优先级</span>
) : null}
{g && !g.enabled ? <span className="text-micro text-warning">已停用,评估时跳过</span> : null}
</span>
<Button
variant="ghost"
size="icon-xs"
aria-label="上移"
disabled={i === 0}
onClick={() => {
const a = [...ids];
[a[i - 1], a[i]] = [a[i] as string, a[i - 1] as string];
setKind(k, a);
}}
>
<ArrowUp />
</Button>
<Button
variant="ghost"
size="icon-xs"
aria-label="下移"
disabled={i === ids.length - 1}
onClick={() => {
const a = [...ids];
[a[i + 1], a[i]] = [a[i] as string, a[i + 1] as string];
setKind(k, a);
}}
>
<ArrowDown />
</Button>
<Button
variant="ghost"
size="icon-xs"
aria-label="移出"
onClick={() =>
setKind(
k,
ids.filter((x) => x !== id),
)
}
>
<X />
</Button>
</li>
);
})}
</ol>
)}
{candidates.length ? (
<div className="mt-3">
<FilterSelect
label="加入"
value=""
allLabel="选择规则组"
onChange={(v) => v && setKind(k, [...ids, v])}
options={candidates.map((g) => ({ value: g.id, label: g.name }))}
className="w-full"
/>
</div>
) : null}
</Panel>
);
})}
</div>
</div>
);
}
// ─── 抑制记录 ───
const actorName = (s: string | null) => (s ? s.replace(/^[a-z_]+:/, "") || s : "—");
export function SuppressionsTab() {
const sups = useRuleSuppressions();
const groups = useRuleGroups({ includeDeleted: "true" }).data?.items;
const lift = useLiftRuleSuppression();
const byId = useMemo(() => new Map((groups ?? []).map((g) => [g.id, g])), [groups]);
const active = (sups.data?.items ?? []).filter((s) => s.active).length;
return (
<Panel title="抑制记录" description={sups.data ? `${active} 条生效中` : undefined}>
<p className="mb-3 text-caption text-muted-foreground">
停用、删除,或改掉对话 / 智能体提出的规则,都会留下抑制记录。智能体
再提出同样的规则会被直接拒绝,解除后才能再提。
</p>
{sups.isPending ? (
<LoadingState rows={2} />
) : sups.isError ? (
<ErrorState plain error={sups.error} onRetry={() => sups.refetch()} />
) : sups.data.items.length === 0 ? (
<EmptyState plain icon={Ban} title="没有抑制记录" className="py-8" />
) : (
<ul className="divide-y divide-border/60">
{sups.data.items.map((s) => {
const gid = s.ruleGroupId ?? s.preferenceTagId;
const g = gid ? byId.get(gid) : undefined;
return (
<li key={s.id} className="flex flex-wrap items-center gap-x-3 gap-y-1 py-2.5">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="min-w-0 font-medium break-words text-foreground">
{g?.name ?? "已删除的规则组"}
</span>
<Chip>{suppressReason(s.reason)}</Chip>
{s.active ? <StatusBadge tone="warning">抑制中</StatusBadge> : <StatusBadge>已解除</StatusBadge>}
</div>
<p className="mt-0.5 text-meta text-muted-foreground tabular-nums">
{actorName(s.suppressedBy)} · {formatDateTime(s.createdAt)}
{s.liftedAt ? ` · ${actorName(s.liftedBy)} 于 ${formatDateTime(s.liftedAt)} 解除` : ""}
</p>
</div>
{s.active ? (
<ConfirmDialog
trigger={
<Button variant="outline" size="sm">
解除抑制
</Button>
}
title="解除这条抑制?"
description="解除后智能体可以再次提出这条规则,仍需审批。规则组本身不会恢复。"
confirmLabel="解除"
onConfirm={async () => {
try {
await lift.mutateAsync({ id: s.id, reason: "用户明确解除" });
toast.success("已解除抑制");
} catch (err) {
toast.error(errorMessage(err));
throw err;
}
}}
/>
) : null}
</li>
);
})}
</ul>
)}
</Panel>
);
}