- 版本目标 → 收录版本(前后端用户可见字符串、能力描述、给模型的提示) - Agent 上游 / 上游 → 模型服务;界面上的 Agent / Agent 会话 → 智能体 / 智能体会话 - 身份歧义 / 识别有歧义 / 无法确定作品 → 没识别出作品;待识别(池) → 未识别 - 建档 → 加入媒体库;资产 → 文件;库根 → 媒体库目录;纳管 → 本工具 / 外部任务; 哨兵 → 标记文件 / 检查目录;站点快照 → 抓取;Feed / RSS 订阅 / 来源健康 → RSS - 代码标识符、API 字段名、迁移文件不改;glossary.md 同步;smoke 选择器跟随 Co-Authored-By: Claude Opus 5.5 <[email protected]>
575 lines
22 KiB
TypeScript
575 lines
22 KiB
TypeScript
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="内置关键字" description="对齐 MoviePilot;判定依据按解析字段优先">
|
||
{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.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.supported ? k.description || k.name : "不支持,规则串里出现会报错"}
|
||
</span>
|
||
</li>
|
||
))}
|
||
</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>
|
||
);
|
||
}
|