feat(web): rules page with layer cards, rule-string sync, preview and preferences as tags (FE-3)

- /console/rules tabs: rule groups, custom conditions, defaults, acquisition policies, suppressions
- editor: rule string parsed live via /rules/parse (position + suggestions) and synced both ways with layer cards; hard excludes; expectedRevision conflicts keep local edits
- preview against recent releases or pasted titles with pri / level / reasons / trace
- single-rule groups shown as preference tags; disable/delete writes suppression records
- /console/preferences redirects to /console/rules (origin passed through)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-28 16:06:16 +08:00
1 parent 81bf010e3e
commit b908b4a0c9
12 files changed
+2657 -427

No files matched your search

+235
View File
@@ -0,0 +1,235 @@
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
AcquisitionSlot,
cursorPage,
PreferenceSuppression,
RuleAtom,
type RuleAtomCreate,
type RuleAtomUpdate,
RuleDefaults,
RuleGroup,
type RuleGroupCreate,
type RuleGroupKind,
RuleGroupRevisionList,
type RuleGroupSource,
type RuleGroupUpdate,
RuleKeywordList,
type RuleLayer,
RuleParseResult,
type RulePreviewRequest,
RulePreviewResult,
Subscription,
} from "@umt/contracts";
import { api } from "~/lib/api.ts";
import { getParsed, parseOrThrow } from "./library.ts";
// 规则(W9-D,docs/runbook/rules.md):规则组 = 收录过滤条件 + 偏好标签合一;自定义条件;三套默认链;抑制记录。
// 写入给 ruleString(优先)或 layers;PATCH 带 expectedRevision,不匹配 409。
export const ruleKeys = {
all: ["rules"] as const,
groups: (f: RuleGroupFilters) => ["rules", "groups", f] as const,
group: (id: string) => ["rules", "group", id] as const,
revisions: (id: string) => ["rules", "revisions", id] as const,
keywords: ["rules", "keywords"] as const,
atoms: ["rules", "atoms"] as const,
defaults: ["rules", "defaults"] as const,
suppressions: ["rules", "suppressions"] as const,
refs: ["rules", "refs"] as const,
};
export type RuleGroupFilters = {
kind?: RuleGroupKind;
source?: RuleGroupSource;
enabled?: "true" | "false";
includeDeleted?: "true";
q?: string;
};
const GroupPage = cursorPage(RuleGroup);
const AtomPage = cursorPage(RuleAtom);
const SupPage = cursorPage(PreferenceSuppression);
export function useRuleGroups(filters: RuleGroupFilters = {}) {
return useQuery({
queryKey: ruleKeys.groups(filters),
queryFn: ({ signal }) => getParsed(GroupPage, "/rules", { ...filters, limit: 200 }, signal),
placeholderData: keepPreviousData,
});
}
export function useRuleGroupRevisions(id: string | null | undefined) {
return useQuery({
queryKey: ruleKeys.revisions(id ?? ""),
enabled: !!id,
queryFn: async ({ signal }) =>
(await getParsed(RuleGroupRevisionList, `/rules/${id}/revisions`, undefined, signal)).items,
});
}
export function useRuleKeywords() {
return useQuery({
queryKey: ruleKeys.keywords,
queryFn: async ({ signal }) => (await getParsed(RuleKeywordList, "/rules/keywords", undefined, signal)).items,
staleTime: 60_000,
});
}
export function useRuleAtoms() {
return useQuery({
queryKey: ruleKeys.atoms,
queryFn: ({ signal }) => getParsed(AtomPage, "/rules/atoms", { limit: 200 }, signal),
});
}
export function useRuleDefaults() {
return useQuery({
queryKey: ruleKeys.defaults,
queryFn: ({ signal }) => getParsed(RuleDefaults, "/rules/defaults", undefined, signal),
});
}
export function useRuleSuppressions() {
return useQuery({
queryKey: ruleKeys.suppressions,
queryFn: ({ signal }) => getParsed(SupPage, "/rules/suppressions", { limit: 200 }, signal),
});
}
export type RuleRefs = {
/** 规则组 id → 引用它的订阅(params.ruleGroupIds 或私有组) */
subscriptions: Map<string, { id: string; title: string }[]>;
/** 规则组 id → 引用它的版本目标 */
slots: Map<string, { id: string; name: string }[]>;
};
const SubPage = cursorPage(Subscription);
const SlotPage = cursorPage(AcquisitionSlot);
/** 引用关系:列表接口不带引用计数,前端从订阅与版本目标反查(各取前 200 条) */
export function useRuleRefs() {
return useQuery({
queryKey: ruleKeys.refs,
queryFn: async ({ signal }): Promise<RuleRefs> => {
const [subs, slots] = await Promise.all([
getParsed(SubPage, "/subscriptions", { limit: 200 }, signal),
getParsed(SlotPage, "/acquisition-slots", { limit: 200 }, signal),
]);
const subscriptions = new Map<string, { id: string; title: string }[]>();
for (const s of subs.items) {
const ids = Array.isArray(s.params?.ruleGroupIds) ? (s.params.ruleGroupIds as string[]) : [];
const title =
(s as { mediaEntityTitle?: string | null }).mediaEntityTitle ?? s.titleQuery ?? `订阅 ${s.id.slice(0, 8)}`;
for (const id of ids) subscriptions.set(id, [...(subscriptions.get(id) ?? []), { id: s.id, title }]);
}
const slotRefs = new Map<string, { id: string; name: string }[]>();
for (const sl of slots.items)
for (const id of sl.ruleGroupIds ?? [])
slotRefs.set(id, [...(slotRefs.get(id) ?? []), { id: sl.id, name: sl.name }]);
return { subscriptions, slots: slotRefs };
},
staleTime: 30_000,
});
}
/** 规则串 → 层(实时解析;错误带位置与相近名) */
export function parseRuleString(ruleString: string) {
return api
.post<unknown>("/rules/parse", { ruleString })
.then((d) => parseOrThrow(RuleParseResult, d, "/rules/parse"));
}
export function usePreviewRuleGroup() {
return useMutation({
mutationFn: ({ id, ...body }: RulePreviewRequest & { id: string }) =>
api.post<unknown>(`/rules/${id}/preview`, body).then((d) => parseOrThrow(RulePreviewResult, d, "/rules/preview")),
});
}
function useInvalidate() {
const qc = useQueryClient();
return () => {
void qc.invalidateQueries({ queryKey: ruleKeys.all });
// 旧偏好标签页与订阅/版本目标的规则组显示同源
void qc.invalidateQueries({ queryKey: ["preferences"] });
};
}
const parseGroup = (d: unknown) => parseOrThrow(RuleGroup, d, "/rules");
export function useCreateRuleGroup() {
const done = useInvalidate();
return useMutation({
mutationFn: (body: RuleGroupCreate) => api.post<unknown>("/rules", body).then(parseGroup),
onSuccess: done,
});
}
/** 编辑 / 停用(enabled=false 写抑制记录)/ 重新启用;带 expectedRevision 防覆盖 */
export function useUpdateRuleGroup() {
const done = useInvalidate();
return useMutation({
mutationFn: ({ id, ...body }: RuleGroupUpdate & { id: string }) =>
api.patch<unknown>(`/rules/${id}`, body).then(parseGroup),
onSettled: done,
});
}
/** 删除:标记删除并写抑制记录;被版本目标/订阅引用时 409 */
export function useDeleteRuleGroup() {
const done = useInvalidate();
return useMutation({
mutationFn: (id: string) => api.del<unknown>(`/rules/${id}`),
onSuccess: done,
});
}
export function useSaveRuleDefaults() {
const done = useInvalidate();
return useMutation({
mutationFn: (body: RuleDefaults) =>
api.put<unknown>("/rules/defaults", body).then((d) => parseOrThrow(RuleDefaults, d, "/rules/defaults")),
onSuccess: done,
});
}
const parseAtom = (d: unknown) => parseOrThrow(RuleAtom, d, "/rules/atoms");
export function useCreateRuleAtom() {
const done = useInvalidate();
return useMutation({
mutationFn: (body: RuleAtomCreate) => api.post<unknown>("/rules/atoms", body).then(parseAtom),
onSuccess: done,
});
}
export function useUpdateRuleAtom() {
const done = useInvalidate();
return useMutation({
mutationFn: ({ id, ...body }: RuleAtomUpdate & { id: string }) =>
api.patch<unknown>(`/rules/atoms/${id}`, body).then(parseAtom),
onSuccess: done,
});
}
export function useDeleteRuleAtom() {
const done = useInvalidate();
return useMutation({
mutationFn: (id: string) => api.del<unknown>(`/rules/atoms/${id}`),
onSuccess: done,
});
}
/** 解除抑制:之后 Agent 可以再次提出同一规则(仍需审批) */
export function useLiftRuleSuppression() {
const done = useInvalidate();
return useMutation({
mutationFn: (v: { id: string; reason?: string }) =>
api
.patch<unknown>(`/rules/suppressions/${v.id}`, { active: false, reason: v.reason })
.then((d) => parseOrThrow(PreferenceSuppression, d, "/rules/suppressions")),
onSuccess: done,
});
}
export type { RuleLayer };
@@ -0,0 +1,43 @@
import type { RuleExpr, RuleLayer } from "@umt/contracts";
import { describe, expect, it } from "vitest";
import { cardsOf, cardToLayer, exprToString, layersToString } from "./expr.ts";
const a = (id: string): RuleExpr => ({ op: "atom", id });
const not = (arg: RuleExpr): RuleExpr => ({ op: "not", arg });
const and = (...args: RuleExpr[]): RuleExpr => ({ op: "and", args });
const or = (...args: RuleExpr[]): RuleExpr => ({ op: "or", args });
describe("规则串序列化", () => {
it("只加必要括号,原子大写", () => {
expect(exprToString(and(a("specsub"), a("CNSUB")))).toBe("SPECSUB & CNSUB");
expect(exprToString(or(and(a("4K"), a("HDR")), a("1080P")))).toBe("4K & HDR | 1080P");
expect(exprToString(and(or(a("4K"), a("1080P")), not(a("BLU"))))).toBe("(4K | 1080P) & !BLU");
expect(exprToString(not(or(a("REMUX"), a("BLU"))))).toBe("!(REMUX | BLU)");
});
it("多层用 > 连接", () => {
const layers: RuleLayer[] = [{ expr: and(a("SPECSUB"), a("CNSUB")) }, { expr: a("BLU") }];
expect(layersToString(layers)).toBe("SPECSUB & CNSUB > BLU");
});
});
describe("层卡片往返", () => {
it("两层以内的表达式可转卡片再转回", () => {
const layers: RuleLayer[] = [
{ expr: and(or(a("4K"), a("1080P")), not(a("BLU"))) },
{ expr: not(a("REMUX")) },
{ expr: or(a("CNSUB"), not(and(a("HDR"), a("DOLBY")))) },
];
const cards = cardsOf(layers);
expect(cards).not.toBeNull();
expect(cards?.map((c) => cardToLayer(c))).toEqual(layers);
});
it("嵌套太深时返回 null,只能在规则串里改", () => {
expect(cardsOf([{ expr: and(a("A"), or(a("B"), and(a("C"), a("D")))) }])).toBeNull();
});
it("空层转不出表达式", () => {
expect(cardToLayer({ op: "and", items: [] })).toBeNull();
});
});
+115
View File
@@ -0,0 +1,115 @@
import type { RuleExpr, RuleLayer } from "@umt/contracts";
// 规则组层的编辑模型:一层 = 顶层 且/或 + 若干条目;条目是条件(可取反)或一个括号组(组内同一种 且/或)。
// 覆盖 MP 规则串的常见写法;更深的嵌套只能在规则串里改(cardsOf 返回 null)。
export type Op = "and" | "or";
export type AtomItem = { kind: "atom"; id: string; neg: boolean };
export type GroupItem = { kind: "group"; op: Op; neg: boolean; items: AtomItem[] };
export type LayerItem = AtomItem | GroupItem;
export type LayerCard = { name?: string; op: Op; items: LayerItem[] };
/** 规范化:原子大写,层内 ! > & > |,只加必要括号(与后端 /rules/serialize 同口径) */
export function exprToString(e: RuleExpr, parent: "top" | "and" | "or" | "not" = "top"): string {
switch (e.op) {
case "atom":
return e.id.toUpperCase();
case "not":
return `!${exprToString(e.arg, "not")}`;
case "and": {
const s = e.args.map((a) => exprToString(a, "and")).join(" & ");
return parent === "not" ? `(${s})` : s;
}
case "or": {
const s = e.args.map((a) => exprToString(a, "or")).join(" | ");
return parent === "and" || parent === "not" ? `(${s})` : s;
}
}
}
export function layersToString(layers: RuleLayer[]): string {
return layers.map((l) => exprToString(l.expr)).join(" > ");
}
function atomOf(e: RuleExpr): AtomItem | null {
if (e.op === "atom") return { kind: "atom", id: e.id.toUpperCase(), neg: false };
if (e.op === "not" && e.arg.op === "atom") return { kind: "atom", id: e.arg.id.toUpperCase(), neg: true };
return null;
}
function groupOf(e: RuleExpr): GroupItem | null {
let neg = false;
let inner = e;
if (inner.op === "not") {
neg = true;
inner = inner.arg;
}
if (inner.op !== "and" && inner.op !== "or") return null;
const items: AtomItem[] = [];
for (const a of inner.args) {
const at = atomOf(a);
if (!at) return null;
items.push(at);
}
return { kind: "group", op: inner.op, neg, items };
}
/** 表达式 → 卡片;表达不了(嵌套超过两层)返回 null */
export function cardOf(layer: RuleLayer): LayerCard | null {
const e = layer.expr;
const single = atomOf(e);
if (single) return { name: layer.name, op: "and", items: [single] };
if (e.op === "not") {
const g = groupOf(e);
return g ? { name: layer.name, op: "and", items: [g] } : null;
}
if (e.op !== "and" && e.op !== "or") return null;
const items: LayerItem[] = [];
for (const a of e.args) {
const it = atomOf(a) ?? groupOf(a);
if (!it) return null;
items.push(it);
}
return { name: layer.name, op: e.op, items };
}
export function cardsOf(layers: RuleLayer[]): LayerCard[] | null {
const out: LayerCard[] = [];
for (const l of layers) {
const c = cardOf(l);
if (!c) return null;
out.push(c);
}
return out;
}
function atomExpr(a: AtomItem): RuleExpr {
const e: RuleExpr = { op: "atom", id: a.id };
return a.neg ? { op: "not", arg: e } : e;
}
function itemExpr(it: LayerItem): RuleExpr | null {
if (it.kind === "atom") return atomExpr(it);
if (it.items.length === 0) return null;
const inner: RuleExpr =
it.items.length === 1 ? atomExpr(it.items[0] as AtomItem) : { op: it.op, args: it.items.map(atomExpr) };
return it.neg ? { op: "not", arg: inner } : inner;
}
/** 卡片 → 层;空层返回 null 的 expr(调用方据此提示) */
export function cardToLayer(c: LayerCard): RuleLayer | null {
const args = c.items.map(itemExpr).filter((x): x is RuleExpr => !!x);
if (args.length === 0) return null;
const expr: RuleExpr = args.length === 1 ? (args[0] as RuleExpr) : { op: c.op, args };
return c.name ? { name: c.name, expr } : { expr };
}
/** 表达式里引用的全部条件 id */
export function atomsIn(e: RuleExpr, out = new Set<string>()): Set<string> {
if (e.op === "atom") out.add(e.id.toUpperCase());
else if (e.op === "not") atomsIn(e.arg, out);
else for (const a of e.args) atomsIn(a, out);
return out;
}
export const OP_LABEL: Record<Op, string> = { and: "且", or: "或" };
@@ -0,0 +1,420 @@
import {
type RuleGroup,
type RuleGroupCreate,
RuleGroupKind,
type RuleGroupScope,
SubscriptionMediaKind,
} from "@umt/contracts";
import { Plus, RotateCcw, Trash2 } from "lucide-react";
import { useEffect, useId, useState } from "react";
import { toast } from "sonner";
import { useCreateRuleGroup, useDeleteRuleGroup, useRuleGroupRevisions, useUpdateRuleGroup } from "~/api/rules.ts";
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
import { DetailSection, DetailSheet } from "~/components/console/detail-sheet.tsx";
import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { FormField } from "~/components/console/form-field.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { MEDIA_KIND } from "~/components/library/labels.ts";
import { ChipToggleGroup } from "~/components/library/parts.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Input } from "~/components/ui/input.tsx";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
import { Switch } from "~/components/ui/switch.tsx";
import { ApiRequestError, errorMessage } from "~/lib/api.ts";
import { formatDateTime } from "~/lib/utils.ts";
import { exprToString } from "./expr.ts";
import { DEFAULT_CHAIN_LABEL, RULE_KIND, RULE_SOURCE, SUPPRESS_NOTE, scopeText } from "./labels.ts";
import { LadderEditor, type LadderState, ladderFrom, ladderProblem, RuleStringField } from "./ladder-editor.tsx";
import { RulePreviewPanel } from "./preview-panel.tsx";
// 规则组编辑抽屉:基本信息 → 优先级阶梯(规则串 ⇄ 层卡片)→ 硬排除 → 试算 → 修订历史。
// 保存给 ruleString(后端优先用它);PATCH 带 expectedRevision,别处先改了会 409。
type Form = {
name: string;
description: string;
kind: RuleGroupKind;
mediaTypes: SubscriptionMediaKind[];
ladderRequired: boolean;
enabled: boolean;
ladder: LadderState;
excludes: { key: number; state: LadderState }[];
};
let excludeSeq = 0;
function formOf(g: RuleGroup | null, kind: RuleGroupKind): Form {
const scope = (g?.scope ?? {}) as RuleGroupScope & { mediaKinds?: SubscriptionMediaKind[] };
return {
name: g?.name ?? "",
description: g?.description ?? "",
kind: g?.kind ?? kind,
mediaTypes: scope.mediaTypes ?? scope.mediaKinds ?? [],
ladderRequired: g?.ladderRequired ?? true,
enabled: g?.enabled ?? true,
ladder: ladderFrom(g?.layers ?? []),
excludes: (g?.hardExcludes ?? []).map((h) => ({
key: ++excludeSeq,
state: { ...ladderFrom([{ expr: h }]), text: exprToString(h) },
})),
};
}
export function RuleGroupEditor({
open,
group,
defaultKind = "acquisition",
onOpenChange,
}: {
open: boolean;
/** null = 新建 */
group: RuleGroup | null;
defaultKind?: RuleGroupKind;
onOpenChange: (o: boolean) => void;
}) {
const uid = useId();
const create = useCreateRuleGroup();
const update = useUpdateRuleGroup();
const del = useDeleteRuleGroup();
const [form, setForm] = useState<Form>(() => formOf(group, defaultKind));
const [conflict, setConflict] = useState<string | null>(null);
const [nameError, setNameError] = useState<string | null>(null);
/** 表单载入时的修订号:保存带它做 expectedRevision,后台刷新到新修订不覆盖正在编辑的内容 */
const [baseRevision, setBaseRevision] = useState<number | null>(group?.revision ?? null);
const load = (g: RuleGroup | null) => {
setForm(formOf(g, defaultKind));
setBaseRevision(g?.revision ?? null);
setConflict(null);
setNameError(null);
};
// 只在打开或换组时重置
// biome-ignore lint/correctness/useExhaustiveDependencies: 只在打开/换组时重置
useEffect(() => {
if (open) load(group);
}, [open, group?.id]);
const set = <K extends keyof Form>(k: K, v: Form[K]) => setForm((f) => ({ ...f, [k]: v }));
const problems = [
ladderProblem(form.ladder),
...form.excludes.map((e, i) => {
const p = ladderProblem(e.state);
return p ? `第 ${i + 1} 条硬排除:${p}` : null;
}),
].filter((p): p is string => !!p);
const blocked = problems[0] ?? null;
const excludeStrings = form.excludes.map((e) => e.state.text.trim()).filter(Boolean);
const save = async () => {
if (!form.name.trim()) {
setNameError("名称必填");
return;
}
const scope: RuleGroupScope = {
...((group?.scope ?? {}) as RuleGroupScope),
mediaTypes: form.mediaTypes.length ? form.mediaTypes : undefined,
};
delete (scope as { mediaKinds?: unknown }).mediaKinds;
if (!scope.mediaTypes) delete scope.mediaTypes;
const hasLadder = form.ladder.text.trim().length > 0;
const body: RuleGroupCreate = {
name: form.name.trim(),
description: form.description.trim() || null,
kind: form.kind,
scope,
...(hasLadder ? { ruleString: form.ladder.text.trim() } : { layers: [] }),
hardExcludes: excludeStrings,
ladderRequired: form.ladderRequired,
enabled: form.enabled,
};
try {
if (group) {
const saved = await update.mutateAsync({
id: group.id,
...body,
expectedRevision: baseRevision ?? group.revision,
});
load(saved);
toast.success("已保存");
} else {
await create.mutateAsync({ ...body, source: "manual" });
toast.success("已新建规则组");
onOpenChange(false);
}
} catch (err) {
if (err instanceof ApiRequestError && err.code === "conflict") {
if (/revision/.test(err.message)) setConflict("这个规则组刚被别处改过,没有保存。载入最新版本后再改。");
else setNameError(err.message);
} else toast.error(errorMessage(err, "保存失败"));
}
};
const inDefault = group?.defaultOrder !== null && group?.defaultOrder !== undefined;
const pending = create.isPending || update.isPending;
return (
<DetailSheet
open={open}
onOpenChange={onOpenChange}
width="lg"
title={group ? group.name : "新建规则组"}
description={
group ? (
<span className="flex flex-wrap items-center gap-1.5">
<span>
{RULE_KIND[group.kind].label} · {RULE_SOURCE[group.source]} · r{baseRevision ?? group.revision} · 更新于{" "}
{formatDateTime(group.updatedAt)}
</span>
{inDefault ? (
<StatusBadge>
{DEFAULT_CHAIN_LABEL[group.kind]} #{(group.defaultOrder ?? 0) + 1}
</StatusBadge>
) : null}
</span>
) : (
"层从上到下依次匹配,先命中的层优先级高。"
)
}
footer={
<>
{group ? (
<ConfirmDialog
trigger={
<Button variant="ghost" className="mr-auto hover:text-destructive" disabled={inDefault}>
<Trash2 />
删除
</Button>
}
title={`删除「${group.name}」?`}
description={`${SUPPRESS_NOTE}被版本目标或订阅引用时不能删除。`}
confirmLabel="删除"
destructive
onConfirm={async () => {
try {
await del.mutateAsync(group.id);
toast.success("已删除");
onOpenChange(false);
} catch (err) {
toast.error(errorMessage(err, "删除失败"));
throw err;
}
}}
/>
) : null}
<Button variant="outline" onClick={() => onOpenChange(false)}>
取消
</Button>
<Button onClick={save} disabled={pending || !!blocked} title={blocked ?? undefined}>
{pending ? "保存中…" : group ? "保存" : "新建"}
</Button>
</>
}
>
{conflict ? (
<div className="flex flex-wrap items-center gap-2 rounded-lg border border-warning/40 bg-warning/10 px-3 py-2 text-caption text-warning">
<span className="flex-1">{conflict}</span>
<Button size="sm" variant="outline" onClick={() => load(group)}>
<RotateCcw />
载入最新
</Button>
</div>
) : null}
{inDefault ? (
<p className="text-meta text-muted-foreground">在默认链里的组不能删除,先在“默认值”里移出。</p>
) : null}
<div className="grid gap-x-4 sm:grid-cols-2">
<FormField label="名称" htmlFor={`${uid}-name`} required error={nameError}>
<Input
id={`${uid}-name`}
value={form.name}
maxLength={80}
aria-invalid={!!nameError}
onChange={(e) => {
set("name", e.target.value);
setNameError(null);
}}
/>
</FormField>
<FormField label="类型" htmlFor={`${uid}-kind`} hint={RULE_KIND[form.kind].hint}>
<Select value={form.kind} onValueChange={(v) => set("kind", v as RuleGroupKind)}>
<SelectTrigger id={`${uid}-kind`} className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
{RuleGroupKind.options.map((k) => (
<SelectItem key={k} value={k}>
{RULE_KIND[k].label}
</SelectItem>
))}
</SelectContent>
</Select>
</FormField>
<FormField label="说明" htmlFor={`${uid}-desc`} className="sm:col-span-2">
<Input
id={`${uid}-desc`}
value={form.description}
maxLength={500}
onChange={(e) => set("description", e.target.value)}
/>
</FormField>
<FormField
label="作用范围"
hint={
group && (group.scope.entityIds?.length || group.scope.siteIds?.length || group.scope.subscriptionId)
? `另有:${scopeText({ ...group.scope, mediaTypes: undefined, mediaKinds: undefined } as RuleGroupScope)}(此处不改)`
: "不选 = 全部类型"
}
className="sm:col-span-2"
>
<ChipToggleGroup
label="作用范围"
options={SubscriptionMediaKind.options.map((k) => ({ value: k, label: MEDIA_KIND[k] }))}
value={form.mediaTypes}
onChange={(v) => set("mediaTypes", v)}
/>
</FormField>
<div className="flex items-center justify-between gap-3 py-1 sm:col-span-2">
<div className="min-w-0">
<p className="text-caption font-medium text-foreground">一层都不中时拒绝</p>
<p className="text-meta text-muted-foreground">
{form.ladderRequired ? "资源至少要命中一层" : "只影响排序,不中的资源 pri 为 0"}
</p>
</div>
<Switch
checked={form.ladderRequired}
onCheckedChange={(v) => set("ladderRequired", v)}
aria-label="一层都不中时拒绝"
/>
</div>
<div className="flex items-center justify-between gap-3 py-1 sm:col-span-2">
<div className="min-w-0">
<p className="text-caption font-medium text-foreground">启用</p>
<p className="text-meta text-muted-foreground">{SUPPRESS_NOTE}</p>
</div>
<Switch checked={form.enabled} onCheckedChange={(v) => set("enabled", v)} aria-label="启用" />
</div>
</div>
<DetailSection title="优先级阶梯">
<LadderEditor state={form.ladder} onChange={(s) => set("ladder", s)} />
</DetailSection>
<DetailSection title="硬排除">
<p className="mb-2 text-meta text-muted-foreground">任一条命中即拒绝,每条写一层条件。</p>
<ul className="space-y-2">
{form.excludes.map((e, i) => (
<li key={e.key} className="flex items-start gap-2">
<RuleStringField
single
className="flex-1"
ariaLabel={`第 ${i + 1} 条硬排除`}
state={e.state}
onChange={(s) =>
setForm((f) => ({
...f,
excludes: f.excludes.map((x) => (x.key === e.key ? { ...x, state: s } : x)),
}))
}
/>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`删除第 ${i + 1} 条硬排除`}
onClick={() => setForm((f) => ({ ...f, excludes: f.excludes.filter((x) => x.key !== e.key) }))}
>
<Trash2 />
</Button>
</li>
))}
</ul>
<Button
type="button"
variant="outline"
size="sm"
className="mt-2"
onClick={() =>
setForm((f) => ({ ...f, excludes: [...f.excludes, { key: ++excludeSeq, state: ladderFrom([]) }] }))
}
>
<Plus />
加硬排除
</Button>
</DetailSection>
<DetailSection title="试算">
{group ? (
<RulePreviewPanel
groupId={group.id}
blocked={blocked}
draft={{ layers: form.ladder.layers, hardExcludes: excludeStrings, ladderRequired: form.ladderRequired }}
/>
) : (
<p className="text-meta text-muted-foreground">保存后可以用最近的资源试算。</p>
)}
</DetailSection>
{group ? (
<DetailSection title="修订历史">
<Revisions id={group.id} />
</DetailSection>
) : null}
</DetailSheet>
);
}
const REV_ACTION: Record<string, string> = {
created: "新建",
updated: "修改",
deleted: "删除",
disabled: "停用",
enabled: "启用",
};
const FIELD_LABEL: Record<string, string> = {
name: "名称",
description: "说明",
kind: "类型",
scope: "作用范围",
layers: "阶梯",
hardExcludes: "硬排除",
ladderRequired: "一层不中拒绝",
enabled: "启用",
label: "名称",
rule: "规则",
status: "状态",
autoFill: "自动补全",
evidence: "证据",
};
function Revisions({ id }: { id: string }) {
const q = useRuleGroupRevisions(id);
if (q.isPending) return <LoadingState rows={2} />;
if (q.isError) return <ErrorState plain error={q.error} onRetry={() => q.refetch()} />;
if (q.data.length === 0) return <p className="text-meta text-muted-foreground">没有修订记录</p>;
return (
<ul className="space-y-2">
{q.data.slice(0, 20).map((r) => {
const action = r.action.split(".").pop() ?? r.action;
return (
<li key={r.id} className="rounded-lg border border-border/70 px-3 py-2 text-caption">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium text-foreground">{REV_ACTION[action] ?? action}</span>
{r.revision ? <span className="text-muted-foreground tabular-nums">r{r.revision}</span> : null}
<span className="text-muted-foreground">
{r.actorType === "agent" ? "Agent" : r.actorType === "api_key" ? "API Key" : "用户"}
</span>
<span className="ml-auto text-meta text-muted-foreground tabular-nums">{formatDateTime(r.at)}</span>
</div>
{r.changedFields.length ? (
<p className="mt-1 text-meta text-muted-foreground">
改动:{r.changedFields.map((f) => FIELD_LABEL[f] ?? f).join("、")}
</p>
) : null}
</li>
);
})}
</ul>
);
}
@@ -0,0 +1,260 @@
import type { RuleGroup } from "@umt/contracts";
import { ListChecks, ShieldOff, Tags, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { type RuleRefs, useDeleteRuleGroup, useUpdateRuleGroup } from "~/api/rules.ts";
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
import { type DataColumn, DataTable } from "~/components/console/data-table.tsx";
import { EmptyState } from "~/components/console/empty-state.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { Chip, Panel, relTime } from "~/components/library/parts.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Switch } from "~/components/ui/switch.tsx";
import { errorMessage } from "~/lib/api.ts";
import { cn } from "~/lib/utils.ts";
import { DEFAULT_CHAIN_LABEL, RULE_KIND, RULE_SOURCE, ruleSummary, SUPPRESS_NOTE, scopeText } from "./labels.ts";
// 规则组列表(表格)与单条规则的标签形态(原偏好标签)。停用/删除都写抑制记录。
function useToggle() {
const update = useUpdateRuleGroup();
return {
pending: update.isPending,
toggle: async (g: RuleGroup) => {
try {
await update.mutateAsync({ id: g.id, enabled: !g.enabled, expectedRevision: g.revision });
toast.success(g.enabled ? "已停用,Agent 不会自动加回" : "已启用");
} catch (err) {
toast.error(errorMessage(err));
}
},
};
}
function RefsCell({ g, refs }: { g: RuleGroup; refs: RuleRefs | undefined }) {
const subs = refs?.subscriptions.get(g.id) ?? [];
const slots = refs?.slots.get(g.id) ?? [];
const parts: string[] = [];
if (g.defaultOrder !== null) parts.push("默认链");
if (subs.length) parts.push(`${subs.length} 个订阅`);
if (slots.length) parts.push(`${slots.length} 个版本目标`);
if (!parts.length) return <span className="text-meta text-muted-foreground">{refs ? "未引用" : "—"}</span>;
const title = [...subs.map((s) => `订阅:${s.title}`), ...slots.map((s) => `版本目标:${s.name}`)].join("\n");
return (
<span className="text-meta text-foreground" title={title || undefined}>
{parts.join(" · ")}
</span>
);
}
export function RuleGroupTable({
rows,
loading,
error,
onRetry,
refs,
filtered,
onOpen,
onCreate,
}: {
rows: RuleGroup[] | undefined;
loading: boolean;
error: unknown;
onRetry: () => void;
refs: RuleRefs | undefined;
filtered: boolean;
onOpen: (g: RuleGroup) => void;
onCreate: () => void;
}) {
const { toggle, pending } = useToggle();
const columns: DataColumn<RuleGroup>[] = [
{
key: "name",
header: "名称",
cell: (g) => (
<div className="min-w-40">
<div className="flex flex-wrap items-center gap-1.5">
<span className={cn("font-medium text-foreground", !g.enabled && "text-muted-foreground line-through")}>
{g.name}
</span>
{g.defaultOrder !== null ? (
<StatusBadge tone="success">
{DEFAULT_CHAIN_LABEL[g.kind]} #{g.defaultOrder + 1}
</StatusBadge>
) : null}
</div>
{g.ruleString || g.hardExcludes.length ? (
<p className="mt-0.5 line-clamp-1 font-mono text-meta break-all text-muted-foreground">{ruleSummary(g)}</p>
) : g.description ? (
<p className="mt-0.5 line-clamp-1 text-meta text-muted-foreground">{g.description}</p>
) : null}
</div>
),
},
{ key: "kind", header: "类型", cell: (g) => RULE_KIND[g.kind].label, className: "whitespace-nowrap" },
{ key: "scope", header: "作用范围", cell: (g) => scopeText(g.scope), hideBelow: "md" },
{
key: "source",
header: "来源",
cell: (g) => RULE_SOURCE[g.source],
hideBelow: "sm",
className: "whitespace-nowrap",
},
{ key: "refs", header: "引用", cell: (g) => <RefsCell g={g} refs={refs} />, hideBelow: "lg" },
{
key: "rev",
header: "修订",
cell: (g) => (
<span className="text-meta text-muted-foreground tabular-nums" title={`更新于 ${relTime(g.updatedAt)}`}>
r{g.revision}
</span>
),
hideBelow: "md",
},
{
key: "enabled",
header: "启用",
align: "right",
cell: (g) => (
// biome-ignore lint/a11y/noStaticElementInteractions: 阻止行点击冒泡
// biome-ignore lint/a11y/useKeyWithClickEvents: 同上,开关本身可键盘操作
<span onClick={(e) => e.stopPropagation()}>
<Switch
checked={g.enabled}
disabled={pending}
onCheckedChange={() => toggle(g)}
aria-label={`${g.enabled ? "停用" : "启用"}「${g.name}」`}
/>
</span>
),
},
];
return (
<DataTable
columns={columns}
rows={rows}
rowKey={(g) => g.id}
loading={loading}
error={error}
onRetry={onRetry}
onRowClick={onOpen}
empty={{
icon: ListChecks,
title: filtered ? "没有符合条件的规则组" : "还没有规则组",
description: filtered ? undefined : "新建一个规则组,或在 Agent 会话里说明长期偏好。",
action: filtered ? undefined : (
<Button size="sm" onClick={onCreate}>
新建规则组
</Button>
),
}}
/>
);
}
/** 单条规则的标签:点开进编辑;停用/删除直接在卡片上做 */
function RuleTag({ g, onOpen }: { g: RuleGroup; onOpen: () => void }) {
const { toggle, pending } = useToggle();
const del = useDeleteRuleGroup();
return (
<li
className={cn(
"flex min-w-0 flex-col rounded-xl border border-border/80 bg-foreground/[0.02] transition-colors duration-micro hover:bg-foreground/[0.04]",
!g.enabled && "opacity-75",
)}
>
<button
type="button"
onClick={onOpen}
className="min-w-0 flex-1 cursor-pointer rounded-t-xl px-3 pt-3 pb-2 text-left outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
<div className="flex items-start justify-between gap-2">
<span className={cn("min-w-0 font-medium break-words text-foreground", !g.enabled && "line-through")}>
{g.name}
</span>
{g.enabled ? null : (
<StatusBadge tone={g.suppressedAt ? "warning" : "neutral"}>
{g.suppressedAt ? "已抑制" : "已停用"}
</StatusBadge>
)}
</div>
<p className="mt-1.5 line-clamp-2 font-mono text-meta break-all text-muted-foreground">{ruleSummary(g)}</p>
<div className="mt-2 flex flex-wrap gap-1.5">
<Chip>{RULE_SOURCE[g.source]}</Chip>
<Chip>{RULE_KIND[g.kind].label}</Chip>
<Chip>{scopeText(g.scope)}</Chip>
</div>
</button>
<div className="flex items-center gap-1 border-t border-border/60 px-2 py-1">
<span className="px-1 text-micro text-muted-foreground tabular-nums">
r{g.revision} · {relTime(g.updatedAt)}
</span>
<span className="ml-auto flex items-center gap-1">
{g.enabled ? (
<ConfirmDialog
trigger={
<Button variant="ghost" size="xs" disabled={pending}>
<ShieldOff />
停用
</Button>
}
title={`停用「${g.name}」?`}
description={SUPPRESS_NOTE}
confirmLabel="停用"
onConfirm={() => toggle(g)}
/>
) : (
<Button variant="ghost" size="xs" disabled={pending} onClick={() => toggle(g)}>
启用
</Button>
)}
<ConfirmDialog
trigger={
<Button variant="ghost" size="xs" className="hover:text-destructive" aria-label={`删除「${g.name}」`}>
<Trash2 />
</Button>
}
title={`删除「${g.name}」?`}
description={`${SUPPRESS_NOTE}被版本目标或订阅引用时不能删除。`}
confirmLabel="删除"
destructive
onConfirm={async () => {
try {
await del.mutateAsync(g.id);
toast.success("已删除,Agent 不会自动加回");
} catch (err) {
toast.error(errorMessage(err, "删除失败"));
throw err;
}
}}
/>
</span>
</div>
</li>
);
}
export function RuleTags({ rows, onOpen }: { rows: RuleGroup[]; onOpen: (g: RuleGroup) => void }) {
return (
<Panel
title="偏好"
description="单条规则,多由对话或 Agent 提出。停用或删除后 Agent 不会自动加回。"
actions={<span className="text-meta text-muted-foreground tabular-nums">{rows.length} 条</span>}
>
{rows.length === 0 ? (
<EmptyState
plain
icon={Tags}
title="还没有偏好"
description="在 Agent 会话里说明长期偏好,会出现在这里。"
className="py-6"
/>
) : (
<ul className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{rows.map((g) => (
<RuleTag key={g.id} g={g} onOpen={() => onOpen(g)} />
))}
</ul>
)}
</Panel>
);
}
+63
View File
@@ -0,0 +1,63 @@
import type { RuleGroup, RuleGroupKind, RuleGroupScope, RuleGroupSource } from "@umt/contracts";
import { MEDIA_KIND } from "~/components/library/labels.ts";
import { exprToString } from "./expr.ts";
// 规则页文案:类型 / 来源 / 作用范围 / 默认链 / 抑制原因
export const RULE_KIND: Record<RuleGroupKind, { label: string; hint: string }> = {
acquisition: { label: "订阅", hint: "订阅与版本目标挑资源时用" },
upgrade: { label: "洗版", hint: "版本目标开了升级时额外叠加" },
search: { label: "搜索", hint: "手动搜索与 Agent 搜索资源时用" },
};
export const SUPPRESS_NOTE = "停用或删除会留下抑制记录,Agent 不会自动加回;重新启用或手动新建同样的规则后解除。";
export const RULE_SOURCE: Record<RuleGroupSource, string> = {
manual: "手动",
chat: "对话",
agent: "Agent",
};
export const DEFAULT_CHAIN_LABEL: Record<RuleGroupKind, string> = {
acquisition: "订阅默认",
upgrade: "洗版默认",
search: "搜索默认",
};
export const SUPPRESS_REASON: Record<string, string> = {
deleted: "删除",
disabled: "停用",
"status:disabled": "停用",
"status:deleted": "删除",
edited: "修改语义",
};
export const suppressReason = (r: string | null) => (r ? (SUPPRESS_REASON[r] ?? r) : "未注明");
/** 作用范围摘要:空 = 全部;旧数据里的 mediaKinds 同 mediaTypes */
export function scopeText(scope: RuleGroupScope): string {
const s = scope as RuleGroupScope & { mediaKinds?: string[] };
const parts: string[] = [];
const kinds = s.mediaTypes ?? s.mediaKinds;
if (kinds?.length) parts.push(kinds.map((k) => MEDIA_KIND[k as keyof typeof MEDIA_KIND] ?? k).join("、"));
if (s.entityIds?.length) parts.push(`${s.entityIds.length} 个作品`);
if (s.siteIds?.length) parts.push(`${s.siteIds.length} 个站点`);
if (s.subscriptionId) parts.push("订阅私有");
return parts.length ? parts.join(" · ") : "全部";
}
/** 单条规则(偏好标签形态):不在默认链、不是订阅私有,层 + 硬排除合计一条;旧偏好标签迁来的都算 */
export function isTagLike(g: RuleGroup): boolean {
if (g.defaultOrder !== null || g.scope.subscriptionId) return false;
if (g.legacyKind) return true;
return g.layers.length + g.hardExcludes.length === 1;
}
/** 标签上的一句规则摘要 */
export function ruleSummary(g: RuleGroup): string {
const parts: string[] = [];
if (g.layers.length)
parts.push(`${g.ladderRequired ? "要求" : "偏好"} ${g.layers.map((l) => exprToString(l.expr)).join(" > ")}`);
for (const h of g.hardExcludes) parts.push(`排除 ${exprToString(h)}`);
if (parts.length) return parts.join(";");
return g.legacyKind ? "旧偏好标签,没有可评估的条件" : "没有条件";
}
@@ -0,0 +1,578 @@
import type { RuleKeyword, RuleLayer, RuleParseError } from "@umt/contracts";
import { ArrowDown, ArrowUp, Brackets, LoaderCircle, Plus, Search, Trash2, X } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { parseRuleString, useRuleKeywords } from "~/api/rules.ts";
import { Button } from "~/components/ui/button.tsx";
import { Input } from "~/components/ui/input.tsx";
import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover.tsx";
import { errorMessage } from "~/lib/api.ts";
import { cn } from "~/lib/utils.ts";
import {
type AtomItem,
cardsOf,
cardToLayer,
type LayerCard,
type LayerItem,
layersToString,
OP_LABEL,
type Op,
} from "./expr.ts";
// 优先级阶梯编辑器:规则串输入与层卡片双向同步。
// 改卡片 → 本地规范化出规则串;改规则串 → 防抖调 /rules/parse,无错误时画回卡片,有错误逐条给位置与相近名。
const BASIS_LABEL: Record<RuleKeyword["basis"], string> = {
field: "解析字段",
regex: "正则",
site: "站点信息",
custom: "自定义条件",
};
export type LadderState = {
/** 输入框里的规则串(可能有错) */
text: string;
/** 最近一次解析成功的层 */
layers: RuleLayer[];
/** null = 嵌套太深,卡片画不出来,只能改规则串 */
cards: LayerCard[] | null;
errors: RuleParseError[];
parsing: boolean;
/** 解析请求本身失败 */
failure: string | null;
};
export function ladderFrom(layers: RuleLayer[]): LadderState {
return { text: layersToString(layers), layers, cards: cardsOf(layers), errors: [], parsing: false, failure: null };
}
/** 保存用:有错误 / 解析中 / 有空层时不可保存 */
export function ladderProblem(s: LadderState): string | null {
if (s.parsing) return "规则串还在解析";
if (s.failure) return s.failure;
if (s.errors.length) return "规则串有错误";
if (s.cards?.some((c) => c.items.length === 0)) return "有空层";
return null;
}
// ─── 关键字选择 ───
function useKeywordMap() {
const q = useRuleKeywords();
const map = useMemo(() => new Map((q.data ?? []).map((k) => [k.id.toUpperCase(), k])), [q.data]);
return { list: q.data ?? [], map, loading: q.isPending };
}
export function KeywordPicker({
onPick,
label = "条件",
size = "xs",
}: {
onPick: (id: string) => void;
label?: string;
size?: "xs" | "sm";
}) {
const { list, loading } = useKeywordMap();
const [open, setOpen] = useState(false);
const [q, setQ] = useState("");
const shown = useMemo(() => {
const t = q.trim().toLowerCase();
return list.filter(
(k) => !t || k.id.toLowerCase().includes(t) || k.name.toLowerCase().includes(t) || k.description.includes(t),
);
}, [list, q]);
const groups = useMemo(() => {
const g = new Map<string, RuleKeyword[]>();
for (const k of shown) {
const key = k.builtin ? BASIS_LABEL[k.basis] : "自定义条件";
g.set(key, [...(g.get(key) ?? []), k]);
}
return [...g.entries()];
}, [shown]);
return (
<Popover
open={open}
onOpenChange={(o) => {
setOpen(o);
if (!o) setQ("");
}}
>
<PopoverTrigger asChild>
<Button type="button" variant="ghost" size={size}>
<Plus />
{label}
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-80 p-2">
<div className="relative mb-2">
<Search className="pointer-events-none absolute top-1/2 left-2.5 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
autoFocus
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="搜索关键字"
aria-label="搜索关键字"
className="h-8 pl-8 text-caption"
/>
</div>
<div className="max-h-72 space-y-2 overflow-y-auto pr-1">
{loading ? <p className="px-2 py-3 text-meta text-muted-foreground">加载中…</p> : null}
{!loading && shown.length === 0 ? (
<p className="px-2 py-3 text-meta text-muted-foreground">没有匹配的关键字</p>
) : null}
{groups.map(([title, items]) => (
<div key={title}>
<p className="px-2 pb-1 text-micro font-semibold tracking-wide text-muted-foreground">{title}</p>
{items.map((k) => (
<button
key={k.id}
type="button"
disabled={!k.supported}
onClick={() => {
onPick(k.id.toUpperCase());
setOpen(false);
setQ("");
}}
className="flex w-full cursor-pointer items-baseline gap-2 rounded-lg px-2 py-1.5 text-left transition-colors duration-micro hover:bg-foreground/[0.06] disabled:cursor-not-allowed disabled:opacity-50"
>
<span className="shrink-0 font-mono text-caption font-medium text-foreground">{k.id}</span>
<span className="min-w-0 truncate text-meta text-muted-foreground">
{k.supported ? k.description || k.name : "不支持"}
</span>
</button>
))}
</div>
))}
</div>
</PopoverContent>
</Popover>
);
}
// ─── 规则串输入 ───
/** 防抖解析规则串;single = 硬排除行,不允许多层 */
export function useRuleTextParser(
state: LadderState,
onChange: (s: LadderState) => void,
{ single = false }: { single?: boolean } = {},
) {
const seq = useRef(0);
const latest = useRef(state);
latest.current = state;
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
const [pendingText, setPendingText] = useState<string | null>(null);
useEffect(() => {
if (pendingText === null) return;
const text = pendingText;
const id = ++seq.current;
const timer = setTimeout(async () => {
if (!text.trim()) {
onChangeRef.current({ ...latest.current, layers: [], cards: [], errors: [], parsing: false, failure: null });
return;
}
try {
const r = await parseRuleString(text);
if (id !== seq.current) return;
const errors = [...r.errors];
if (single && r.layers.length > 1)
errors.push({
code: "unexpected_token",
message: "硬排除只能写一层,不能用 >",
position: text.indexOf(">"),
layer: 1,
});
onChangeRef.current({
...latest.current,
layers: errors.length ? latest.current.layers : r.layers,
cards: errors.length ? latest.current.cards : cardsOf(r.layers),
errors,
parsing: false,
failure: null,
});
} catch (err) {
if (id !== seq.current) return;
onChangeRef.current({ ...latest.current, parsing: false, failure: errorMessage(err, "规则串解析失败") });
}
}, 300);
return () => clearTimeout(timer);
}, [pendingText, single]);
return (text: string) => {
onChangeRef.current({ ...latest.current, text, parsing: true });
setPendingText(text);
};
}
/** 规则串输入框 + 逐条错误(位置指示、相近名一键替换) */
export function RuleStringField({
state,
onChange,
single = false,
placeholder,
ariaLabel,
className,
}: {
state: LadderState;
onChange: (s: LadderState) => void;
single?: boolean;
placeholder?: string;
ariaLabel: string;
className?: string;
}) {
const setText = useRuleTextParser(state, onChange, { single });
const replaceAt = (e: RuleParseError, s: string) => {
if (!e.atom) return;
const at = state.text.toUpperCase().indexOf(e.atom.toUpperCase(), Math.max(0, e.position - 1));
const pos = at >= 0 ? at : e.position;
setText(state.text.slice(0, pos) + s + state.text.slice(pos + e.atom.length));
};
return (
<div className={cn("min-w-0", className)}>
<div className="relative">
<Input
value={state.text}
onChange={(e) => setText(e.target.value)}
placeholder={placeholder ?? (single ? "REMUX | BLU" : "SPECSUB & CNSUB > 4K > 1080P")}
aria-label={ariaLabel}
aria-invalid={state.errors.length > 0 || undefined}
spellCheck={false}
autoCapitalize="characters"
className="pr-8 font-mono text-caption"
/>
{state.parsing ? (
<LoaderCircle className="absolute top-1/2 right-2.5 h-3.5 w-3.5 -translate-y-1/2 animate-spin text-muted-foreground" />
) : null}
</div>
{state.failure ? <p className="mt-1 text-meta text-destructive">{state.failure}</p> : null}
{state.errors.length ? (
<ul className="mt-1.5 space-y-1" aria-live="polite">
{state.errors.map((e, i) => (
<li key={`${e.code}-${e.position}-${i}`} className="text-meta text-destructive">
<ErrorCaret text={state.text} position={e.position} />
<span>
第 {e.position + 1} 个字符{single ? "" : `(第 ${e.layer + 1} 层)`}:{e.message}
</span>
{e.suggestions?.length ? (
<span className="ml-1 inline-flex flex-wrap gap-1 align-middle">
{e.suggestions.map((s) => (
<button
key={s}
type="button"
onClick={() => replaceAt(e, s)}
className="cursor-pointer rounded-full border border-border px-1.5 font-mono text-micro text-foreground hover:bg-foreground/[0.06]"
>
改成 {s}
</button>
))}
</span>
) : null}
</li>
))}
</ul>
) : null}
</div>
);
}
/** 出错位置:规则串原文 + 在出错字符下画标记 */
function ErrorCaret({ text, position }: { text: string; position: number }) {
if (position < 0 || !text) return null;
const p = Math.min(position, text.length);
const start = Math.max(0, p - 24);
const end = Math.min(text.length, p + 24);
return (
<span className="mb-0.5 block overflow-x-auto font-mono text-meta whitespace-pre text-muted-foreground">
{start > 0 ? "…" : ""}
{text.slice(start, p)}
<mark className="rounded-sm bg-destructive/15 text-destructive underline decoration-destructive underline-offset-2">
{text.slice(p, p + 1) || " "}
</mark>
{text.slice(p + 1, end)}
{end < text.length ? "…" : ""}
</span>
);
}
// ─── 层卡片 ───
function OpToggle({ op, onChange, label }: { op: Op; onChange: (op: Op) => void; label: string }) {
return (
<fieldset aria-label={label} className="m-0 inline-flex rounded-full border border-border p-0.5">
{(["and", "or"] as const).map((o) => (
<button
key={o}
type="button"
aria-pressed={op === o}
onClick={() => onChange(o)}
className={cn(
"h-6 cursor-pointer rounded-full px-2 text-meta font-medium transition-colors duration-micro",
op === o ? "pill-selected text-foreground" : "text-muted-foreground hover:text-foreground",
)}
>
{OP_LABEL[o]}
</button>
))}
</fieldset>
);
}
function AtomChip({
item,
keyword,
onToggleNeg,
onRemove,
}: {
item: AtomItem;
keyword: RuleKeyword | undefined;
onToggleNeg: () => void;
onRemove: () => void;
}) {
const unknown = !keyword;
return (
<span
className={cn(
"inline-flex h-7 max-w-full items-center rounded-full border text-meta",
item.neg ? "border-destructive/35 bg-destructive/10 text-destructive" : "border-border bg-foreground/[0.03]",
unknown && "border-warning/50",
)}
title={keyword ? `${keyword.name}:${keyword.description}` : "未知条件"}
>
<button
type="button"
onClick={onToggleNeg}
className="flex h-full cursor-pointer items-center gap-1 rounded-l-full pr-1 pl-2.5 font-mono font-medium"
aria-label={`${item.neg ? "取消取反" : "取反"} ${item.id}`}
>
{item.neg ? <span aria-hidden>非</span> : null}
{item.id}
</button>
<button
type="button"
onClick={onRemove}
aria-label={`移除 ${item.id}`}
className="flex h-full cursor-pointer items-center rounded-r-full pr-2 pl-0.5 opacity-60 hover:opacity-100"
>
<X className="h-3 w-3" />
</button>
</span>
);
}
function Joiner({ op }: { op: Op }) {
return <span className="px-0.5 text-meta text-muted-foreground">{OP_LABEL[op]}</span>;
}
function LayerCardView({
card,
index,
total,
keywords,
onChange,
onMove,
onRemove,
}: {
card: LayerCard;
index: number;
total: number;
keywords: Map<string, RuleKeyword>;
onChange: (c: LayerCard) => void;
onMove: (dir: -1 | 1) => void;
onRemove: () => void;
}) {
const setItem = (i: number, it: LayerItem | null) =>
onChange({
...card,
items: it ? card.items.map((x, j) => (j === i ? it : x)) : card.items.filter((_, j) => j !== i),
});
return (
<li className="rounded-xl border border-border/80 bg-foreground/[0.02] p-3">
<div className="flex flex-wrap items-center gap-2">
<span className="text-caption font-medium text-foreground tabular-nums">
优先级 {index + 1} · pri {100 - index}
</span>
<Input
value={card.name ?? ""}
onChange={(e) => onChange({ ...card, name: e.target.value || undefined })}
placeholder="层名称(可选)"
aria-label={`第 ${index + 1} 层名称`}
maxLength={40}
className="h-7 w-36 text-meta"
/>
{card.items.length > 1 ? (
<OpToggle op={card.op} onChange={(op) => onChange({ ...card, op })} label={`第 ${index + 1} 层条件关系`} />
) : null}
<span className="ml-auto flex items-center gap-0.5">
<Button
type="button"
variant="ghost"
size="icon-xs"
disabled={index === 0}
onClick={() => onMove(-1)}
aria-label="上移一层"
>
<ArrowUp />
</Button>
<Button
type="button"
variant="ghost"
size="icon-xs"
disabled={index === total - 1}
onClick={() => onMove(1)}
aria-label="下移一层"
>
<ArrowDown />
</Button>
<Button
type="button"
variant="ghost"
size="icon-xs"
onClick={onRemove}
aria-label="删除这一层"
className="hover:text-destructive"
>
<Trash2 />
</Button>
</span>
</div>
<div className="mt-2 flex min-w-0 flex-wrap items-center gap-1.5">
{card.items.length === 0 ? <span className="text-meta text-warning">这一层还没有条件</span> : null}
{card.items.map((it, i) => (
<span key={i} className="inline-flex max-w-full flex-wrap items-center gap-1.5">
{i > 0 ? <Joiner op={card.op} /> : null}
{it.kind === "atom" ? (
<AtomChip
item={it}
keyword={keywords.get(it.id)}
onToggleNeg={() => setItem(i, { ...it, neg: !it.neg })}
onRemove={() => setItem(i, null)}
/>
) : (
<span
className={cn(
"inline-flex max-w-full flex-wrap items-center gap-1 rounded-xl border border-dashed px-1.5 py-1",
it.neg ? "border-destructive/40" : "border-border",
)}
>
<button
type="button"
onClick={() => setItem(i, { ...it, neg: !it.neg })}
className={cn(
"cursor-pointer rounded-full px-1 font-mono text-meta",
it.neg ? "text-destructive" : "text-muted-foreground hover:text-foreground",
)}
aria-label={it.neg ? "取消整组取反" : "整组取反"}
>
{it.neg ? "非(" : "("}
</button>
{it.items.map((a, k) => (
<span key={k} className="inline-flex items-center gap-1">
{k > 0 ? <Joiner op={it.op} /> : null}
<AtomChip
item={a}
keyword={keywords.get(a.id)}
onToggleNeg={() =>
setItem(i, { ...it, items: it.items.map((x, j) => (j === k ? { ...x, neg: !x.neg } : x)) })
}
onRemove={() => {
const rest = it.items.filter((_, j) => j !== k);
setItem(i, rest.length ? { ...it, items: rest } : null);
}}
/>
</span>
))}
<span className="font-mono text-meta text-muted-foreground">)</span>
{it.items.length > 1 ? (
<OpToggle op={it.op} onChange={(op) => setItem(i, { ...it, op })} label="括号组内条件关系" />
) : null}
<KeywordPicker
label=""
onPick={(id) => setItem(i, { ...it, items: [...it.items, { kind: "atom", id, neg: false }] })}
/>
</span>
)}
</span>
))}
<KeywordPicker
onPick={(id) => onChange({ ...card, items: [...card.items, { kind: "atom", id, neg: false }] })}
/>
<Button
type="button"
variant="ghost"
size="xs"
onClick={() =>
onChange({
...card,
items: [...card.items, { kind: "group", op: card.op === "and" ? "or" : "and", neg: false, items: [] }],
})
}
>
<Brackets />
括号组
</Button>
</div>
</li>
);
}
/** 阶梯编辑:上面规则串,下面层卡片;任一处改动同步到另一处 */
export function LadderEditor({ state, onChange }: { state: LadderState; onChange: (s: LadderState) => void }) {
const { map } = useKeywordMap();
const setCards = (cards: LayerCard[]) => {
const layers = cards.map(cardToLayer).filter((l): l is RuleLayer => !!l);
onChange({ text: layersToString(layers), layers, cards, errors: [], parsing: false, failure: null });
};
return (
<div className="space-y-3">
<RuleStringField state={state} onChange={onChange} ariaLabel="规则串" />
{state.errors.length ? (
<p className="text-meta text-muted-foreground">规则串改对后同步到下面的层;现在显示的是上次解析成功的结果。</p>
) : null}
{state.cards === null ? (
<p className="rounded-lg border border-border/70 px-3 py-2 text-meta text-muted-foreground">
嵌套超过两层,只能在规则串里改。
</p>
) : (
<>
{state.cards.length ? (
<ol className="space-y-2">
{state.cards.map((c, i) => (
<LayerCardView
key={i}
card={c}
index={i}
total={state.cards?.length ?? 0}
keywords={map}
onChange={(next) => setCards((state.cards ?? []).map((x, j) => (j === i ? next : x)))}
onMove={(dir) => {
const arr = [...(state.cards ?? [])];
const [moved] = arr.splice(i, 1);
if (moved) arr.splice(i + dir, 0, moved);
setCards(arr);
}}
onRemove={() => setCards((state.cards ?? []).filter((_, j) => j !== i))}
/>
))}
</ol>
) : state.errors.length ? null : (
<p className="text-meta text-muted-foreground">没有层:只用硬排除过滤,不影响排序。</p>
)}
<Button
type="button"
variant="outline"
size="sm"
disabled={state.errors.length > 0 || state.parsing}
onClick={() => setCards([...(state.cards ?? []), { op: "and", items: [] }])}
>
<Plus />
加一层
</Button>
</>
)}
</div>
);
}
@@ -0,0 +1,177 @@
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>
);
}
+573
View File
@@ -0,0 +1,573 @@
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">
停用、删除,或改掉对话 / Agent 提出的规则,都会留下抑制记录。Agent 再提出同样的规则会被直接拒绝,解除后才能再提。
</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="解除后 Agent 可以再次提出这条规则,仍需审批。规则组本身不会恢复。"
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>
);
}
+12 -408
View File
@@ -1,413 +1,17 @@
import type { PreferenceKind, PreferenceSuppression, PreferenceTag } from "@umt/contracts";
import { Ban, History, ListChecks, Plus, ShieldOff, Tags } from "lucide-react";
import { useMemo, useState } from "react";
import { Link } from "react-router";
import { toast } from "sonner";
import {
useDeleteTag,
useLiftSuppression,
usePreferenceTags,
useSuppressions,
useTagRevisions,
useUpdateTag,
} from "~/api/preferences.ts";
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
import { DetailSection, DetailSheet } from "~/components/console/detail-sheet.tsx";
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx";
import { JsonView } from "~/components/console/json-view.tsx";
import { KvList } from "~/components/console/kv-list.tsx";
import { consolePath } from "~/components/console/nav.ts";
import { PageHeader } from "~/components/console/page-header.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { Timeline, type TimelineItem } from "~/components/console/timeline.tsx";
import { MEDIA_KIND, opts, PREF_KIND, PREF_ORIGIN, PREF_STATUS } from "~/components/library/labels.ts";
import { Chip, Panel, relTime, Tone } from "~/components/library/parts.tsx";
import { PreferenceFormDialog } from "~/components/library/preferences-form.tsx";
import { Button } from "~/components/ui/button.tsx";
import { errorMessage } from "~/lib/api.ts";
import { cn, formatDateTime } from "~/lib/utils.ts";
import { redirect } from "react-router";
// 偏好标签(REQ §6.7):一个偏好一个标签;停用/删除/改语义留抑制记录,Agent 不据同一旧证据自动加回。
// 偏好标签已并入规则页(docs/design/subscriptions.md §6):单条规则以标签形态显示在“规则组”Tab。
// 旧链接带 origin 筛选时透传,规则页按来源筛选。
const SUP_REASON: Record<string, string> = {
deleted: "删除",
"status:disabled": "停用",
"status:deleted": "删除",
edited: "修改语义",
};
const supReason = (r: string | null) => (r ? (SUP_REASON[r] ?? r) : "未注明");
const actorName = (s: string | null) => (s ? s.replace(/^[a-z_]+:/, "") || s : "—");
/** scope 摘要:{} = 全局;mediaKinds 翻中文;其余键原样 */
function scopeSummary(scope: Record<string, unknown>): string {
const keys = Object.keys(scope);
if (keys.length === 0) return "全局";
const parts: string[] = [];
for (const k of keys) {
const v = scope[k];
if (k === "mediaKinds" && Array.isArray(v))
parts.push(v.map((x) => MEDIA_KIND[x as keyof typeof MEDIA_KIND] ?? String(x)).join("、"));
else parts.push(`${k}: ${Array.isArray(v) ? v.join("、") : typeof v === "object" ? "…" : String(v)}`);
}
return parts.join(" · ");
export function clientLoader({ request }: { request: Request }) {
const from = new URL(request.url).searchParams;
const to = new URLSearchParams();
const origin = from.get("origin");
if (origin) to.set("origin", origin);
const qs = to.toString();
return redirect(`/console/rules${qs ? `?${qs}` : ""}`);
}
function TagCard({ tag, onOpen }: { tag: PreferenceTag; onOpen: () => void }) {
const inactive = tag.status !== "active";
return (
<button
type="button"
onClick={onOpen}
className={cn(
"min-w-0 cursor-pointer rounded-xl border border-border/80 bg-foreground/[0.02] p-3 text-left transition-colors duration-micro hover:bg-foreground/[0.05] focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none",
inactive && "opacity-70",
)}
>
<div className="flex items-start justify-between gap-2">
<span className={cn("min-w-0 font-medium break-words text-foreground", inactive && "line-through")}>
{tag.label}
</span>
<Tone v={PREF_STATUS[tag.status]} className="shrink-0" />
</div>
<div className="mt-2 flex flex-wrap gap-1.5">
<Chip>{PREF_KIND[tag.kind].label}</Chip>
<Chip>{scopeSummary(tag.scope)}</Chip>
<Chip>{PREF_ORIGIN[tag.origin]}</Chip>
</div>
{tag.description ? (
<p className="mt-2 line-clamp-2 text-meta break-words text-muted-foreground">{tag.description}</p>
) : null}
<p className="mt-2 text-micro text-muted-foreground tabular-nums">
r{tag.revision} · 更新于 {relTime(tag.updatedAt)}
</p>
</button>
);
}
export default function PreferencesPage() {
const [status, setStatus] = useState("");
const [kind, setKind] = useState("");
const tags = usePreferenceTags({
status: (status || undefined) as "active" | "disabled" | undefined,
kind: (kind || undefined) as PreferenceKind | undefined,
});
const deleted = usePreferenceTags({ status: "deleted" });
const allTags = usePreferenceTags({});
const sups = useSuppressions();
const [selectedId, setSelectedId] = useState<string | null>(null);
const [form, setForm] = useState<{ open: boolean; tag: PreferenceTag | null }>({ open: false, tag: null });
const update = useUpdateTag();
const del = useDeleteTag();
const lift = useLiftSuppression();
const tagById = useMemo(() => {
const m = new Map<string, PreferenceTag>();
for (const t of [...(allTags.data?.items ?? []), ...(deleted.data?.items ?? [])]) m.set(t.id, t);
return m;
}, [allTags.data, deleted.data]);
const selected = selectedId ? (tagById.get(selectedId) ?? tags.data?.items.find((t) => t.id === selectedId)) : null;
const toggle = async (t: PreferenceTag) => {
const next = t.status === "active" ? "disabled" : "active";
try {
await update.mutateAsync({ id: t.id, status: next });
toast.success(next === "active" ? "已启用,抑制已解除" : "已停用");
} catch (err) {
toast.error(errorMessage(err));
}
};
const history = useMemo<TimelineItem[]>(() => {
const items: (TimelineItem & { ts: number })[] = [];
for (const s of sups.data?.items ?? []) {
const label = s.preferenceTagId ? (tagById.get(s.preferenceTagId)?.label ?? "未知标签") : "未关联标签";
items.push({
id: `sup-${s.id}`,
at: s.createdAt,
ts: Date.parse(s.createdAt),
tone: "warning",
title: `「${label}」加入抑制(${supReason(s.reason)})`,
meta: actorName(s.suppressedBy),
});
if (s.liftedAt)
items.push({
id: `lift-${s.id}`,
at: s.liftedAt,
ts: Date.parse(s.liftedAt),
tone: "success",
title: `「${label}」的抑制已解除`,
meta: actorName(s.liftedBy),
});
}
for (const t of deleted.data?.items ?? []) {
if ((sups.data?.items ?? []).some((s) => s.preferenceTagId === t.id && s.reason === "deleted")) continue;
items.push({
id: `del-${t.id}`,
at: t.updatedAt,
ts: Date.parse(t.updatedAt),
tone: "neutral",
title: `「${t.label}」已删除`,
});
}
return items.sort((a, b) => b.ts - a.ts).slice(0, 30);
}, [sups.data, deleted.data, tagById]);
const activeSups = (sups.data?.items ?? []).filter((s) => s.active);
const filtered = !!status || !!kind;
return (
<>
<PageHeader
title="偏好标签"
description="软偏好影响排序,硬规则直接排除。手动取消的偏好 Agent 不会再加回。"
actions={
<>
<Button asChild variant="ghost" size="sm">
<Link to={consolePath("rules")}>
<ListChecks />
收录策略
</Link>
</Button>
<Button onClick={() => setForm({ open: true, tag: null })}>
<Plus />
添加偏好
</Button>
</>
}
/>
<FilterBar
onReset={
filtered
? () => {
setStatus("");
setKind("");
}
: undefined
}
>
<FilterSelect
label="状态"
value={status}
onChange={setStatus}
allLabel="未删除"
options={[
{ value: "active", label: "生效中" },
{ value: "disabled", label: "已停用" },
]}
/>
<FilterSelect label="类型" value={kind} onChange={setKind} options={opts(PREF_KIND)} />
</FilterBar>
<div className="space-y-6">
{tags.isPending ? (
<div className="pg-slab rounded-xl p-4">
<LoadingState rows={3} />
</div>
) : tags.isError ? (
<ErrorState error={tags.error} onRetry={() => tags.refetch()} />
) : tags.data.items.length === 0 ? (
<EmptyState
icon={Tags}
title={filtered ? "没有符合条件的偏好" : "还没有偏好标签"}
description="点「添加偏好」,或在 Agent 会话里说明长期偏好。"
/>
) : (
<Panel title="偏好" description={`${tags.data.items.length} 个标签`}>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{tags.data.items.map((t) => (
<TagCard key={t.id} tag={t} onOpen={() => setSelectedId(t.id)} />
))}
</div>
</Panel>
)}
<Panel title="抑制记录" description={`${activeSups.length} 条生效中`} bodyClassName="space-y-3">
<div className="flex gap-2 rounded-lg border border-warning/30 bg-warning/10 px-3 py-2 text-caption text-foreground">
<ShieldOff className="mt-0.5 h-4 w-4 shrink-0 text-warning" />
<p>停用、删除或修改语义的偏好会留下抑制记录,Agent 不会按旧证据加回。重新启用、重新添加或解除抑制后恢复。</p>
</div>
{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) => (
<SuppressionRow
key={s.id}
s={s}
label={s.preferenceTagId ? tagById.get(s.preferenceTagId)?.label : undefined}
onLift={async () => {
try {
await lift.mutateAsync({ id: s.id, reason: "用户明确解除" });
toast.success("已解除抑制");
} catch (err) {
toast.error(errorMessage(err));
throw err;
}
}}
/>
))}
</ul>
)}
</Panel>
<Panel title="历史" description="停用、删除、解除抑制(新 → 旧)">
{sups.isPending || deleted.isPending ? (
<LoadingState rows={2} />
) : history.length === 0 ? (
<EmptyState plain icon={History} title="还没有变更历史" className="py-8" />
) : (
<Timeline items={history} />
)}
</Panel>
</div>
<DetailSheet
open={!!selected}
onOpenChange={(o) => !o && setSelectedId(null)}
title={selected?.label ?? ""}
description={selected ? `${PREF_KIND[selected.kind].label} · ${PREF_KIND[selected.kind].hint}` : undefined}
footer={
selected && selected.status !== "deleted" ? (
<>
<ConfirmDialog
trigger={<Button variant="destructive">删除</Button>}
title={`删除「${selected.label}」?`}
description="删除后留下抑制记录,Agent 不会按旧证据加回。"
confirmLabel="删除"
destructive
onConfirm={async () => {
try {
await del.mutateAsync(selected.id);
toast.success("已删除");
setSelectedId(null);
} catch (err) {
toast.error(errorMessage(err));
throw err;
}
}}
/>
<Button variant="outline" onClick={() => toggle(selected)} disabled={update.isPending}>
{selected.status === "active" ? "停用" : "重新启用"}
</Button>
<Button onClick={() => setForm({ open: true, tag: selected })}>编辑</Button>
</>
) : undefined
}
>
{selected ? (
<>
<KvList
items={[
{ label: "状态", value: <Tone v={PREF_STATUS[selected.status]} /> },
{ label: "来源", value: PREF_ORIGIN[selected.origin] },
{ label: "作用范围", value: scopeSummary(selected.scope) },
{ label: "修订", value: `r${selected.revision}` },
{ label: "创建", value: formatDateTime(selected.createdAt) },
{ label: "更新", value: formatDateTime(selected.updatedAt) },
{ label: "说明", value: selected.description, wide: true },
{ label: "语义指纹", value: selected.fingerprint, mono: true, wide: true },
]}
/>
<DetailSection title="规则">
<JsonView value={selected.rule} />
</DetailSection>
<DetailSection title="作用范围">
<JsonView value={selected.scope} />
</DetailSection>
<DetailSection title="证据">
<JsonView value={Object.keys(selected.evidence).length ? selected.evidence : null} />
</DetailSection>
<DetailSection title="修订历史">
<TagRevisions id={selected.id} />
</DetailSection>
</>
) : null}
</DetailSheet>
<PreferenceFormDialog open={form.open} tag={form.tag} onOpenChange={(o) => setForm((f) => ({ ...f, open: o }))} />
</>
);
}
const REV_ACTION = { created: "创建", updated: "修改", deleted: "删除" } as const;
const FIELD_LABEL: Record<string, string> = {
label: "名称",
kind: "类型",
scope: "作用范围",
rule: "规则",
description: "说明",
evidence: "证据",
status: "状态",
};
function TagRevisions({ id }: { id: string }) {
const q = useTagRevisions(id);
if (q.isPending) return <LoadingState rows={2} />;
if (q.isError) return <ErrorState plain error={q.error} onRetry={() => q.refetch()} />;
if (q.data.length === 0)
return <p className="text-caption text-muted-foreground">没有修订记录。W4 之前创建的标签没有历史。</p>;
return (
<ul className="space-y-2">
{q.data.map((r) => (
<li key={r.id} className="rounded-lg border border-border/70 px-3 py-2 text-caption">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium text-foreground">{REV_ACTION[r.action]}</span>
{r.revision ? <span className="text-muted-foreground tabular-nums">r{r.revision}</span> : null}
<span className="text-muted-foreground">{r.actorType === "api_key" ? "API Key" : "用户"}</span>
<span className="ml-auto text-meta text-muted-foreground tabular-nums">{formatDateTime(r.at)}</span>
</div>
<p className="mt-1 text-meta text-muted-foreground">
{r.changedFields.length
? `改动:${r.changedFields.map((f) => FIELD_LABEL[f] ?? f).join("、")}`
: `名称:${r.snapshot.label} · 状态:${r.snapshot.status}`}
</p>
</li>
))}
</ul>
);
}
function SuppressionRow({
s,
label,
onLift,
}: {
s: PreferenceSuppression;
label: string | undefined;
onLift: () => Promise<void>;
}) {
return (
<li 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">{label ?? "未关联标签"}</span>
<Chip>{supReason(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="解除后,Agent 可按证据再次提出这个偏好,仍需授权。标签不会恢复。"
confirmLabel="解除"
onConfirm={onLift}
/>
) : null}
</li>
);
export default function PreferencesRedirect() {
return null;
}
+180 -19
View File
@@ -1,38 +1,199 @@
import { FilePlus2, Tags } from "lucide-react";
import { useState } from "react";
import { Link } from "react-router";
import { consolePath } from "~/components/console/nav.ts";
import type { RuleGroup, RuleGroupKind, RuleGroupSource } from "@umt/contracts";
import { FilePlus2, Plus } from "lucide-react";
import { useMemo, useState } from "react";
import { useSearchParams } from "react-router";
import { useRuleGroups, useRuleRefs } from "~/api/rules.ts";
import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx";
import { PageHeader } from "~/components/console/page-header.tsx";
import { PolicyWorkspace } from "~/components/library/subscriptions-policy.tsx";
import { RuleGroupEditor } from "~/components/rules/group-editor.tsx";
import { RuleGroupTable, RuleTags } from "~/components/rules/group-list.tsx";
import { isTagLike, RULE_KIND, RULE_SOURCE } from "~/components/rules/labels.ts";
import { AtomsTab, DefaultsTab, SuppressionsTab } from "~/components/rules/tabs.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs.tsx";
// 收录策略(REQ §6.5):草案 → 预览 → 发布;偏好标签在 /console/preferences,排序时叠加生效。
// 规则(docs/design/subscriptions.md §6):收录策略 + 偏好标签合一。
// Tab:规则组(含单条规则的标签形态)/ 自定义条件 / 默认值 / 收录策略 / 抑制记录。
export const meta = () => [{ title: "收录策略 · UHD 媒体自动化控制台" }];
export const meta = () => [{ title: "规则 · UHD 媒体自动化控制台" }];
const TABS = ["groups", "atoms", "defaults", "policies", "suppressions"] as const;
type Tab = (typeof TABS)[number];
/** 旧 /preferences?origin= 透传过来的来源 */
const ORIGIN_TO_SOURCE: Record<string, RuleGroupSource> = {
manual: "manual",
conversation: "chat",
agent_inferred: "agent",
};
export default function RulesPage() {
const [creating, setCreating] = useState(false);
const [params, setParams] = useSearchParams();
const tabParam = params.get("tab") ?? "groups";
const tab: Tab = (TABS as readonly string[]).includes(tabParam) ? (tabParam as Tab) : "groups";
const setParam = (k: string, v: string) =>
setParams(
(p) => {
if (v) p.set(k, v);
else p.delete(k);
return p;
},
{ replace: true },
);
const kind = params.get("kind") ?? "";
const source = params.get("source") ?? ORIGIN_TO_SOURCE[params.get("origin") ?? ""] ?? "";
const enabled = params.get("enabled") ?? "";
const [q, setQ] = useState(params.get("q") ?? "");
const filtered = !!(kind || source || enabled || q.trim());
const groups = useRuleGroups({
kind: (kind || undefined) as RuleGroupKind | undefined,
source: (source || undefined) as RuleGroupSource | undefined,
enabled: (enabled || undefined) as "true" | "false" | undefined,
q: q.trim() || undefined,
});
const all = useRuleGroups({});
const refs = useRuleRefs();
const [editor, setEditor] = useState<{ open: boolean; id: string | null }>({ open: false, id: null });
const [creatingPolicy, setCreatingPolicy] = useState(false);
const items = groups.data?.items;
const tags = useMemo(() => (items ?? []).filter(isTagLike), [items]);
const rest = useMemo(() => items?.filter((g) => !isTagLike(g)), [items]);
const editing: RuleGroup | null = editor.id
? ((items ?? []).find((g) => g.id === editor.id) ?? all.data?.items.find((g) => g.id === editor.id) ?? null)
: null;
const open = (g: RuleGroup) => setEditor({ open: true, id: g.id });
const create = () => setEditor({ open: true, id: null });
return (
<>
<PageHeader
title="收录策略"
description="草案发布后才生效。回滚会用旧版本内容发布一个新版本。"
title="规则"
description="规则组决定哪些资源能下载、同一作品优先挑哪个。收录策略决定哪些新作品自动收录。"
actions={
<>
<Button asChild variant="ghost" size="sm">
<Link to={consolePath("preferences")}>
<Tags />
偏好标签
</Link>
</Button>
<Button onClick={() => setCreating(true)}>
tab === "policies" ? (
<Button onClick={() => setCreatingPolicy(true)}>
<FilePlus2 />
新建策略草案
</Button>
</>
) : (
<Button onClick={create}>
<Plus />
新建规则组
</Button>
)
}
/>
<PolicyWorkspace creating={creating} onCreatingChange={setCreating} />
<Tabs
value={tab}
onValueChange={(v) =>
setParams(
(p) => {
if (v === "groups") p.delete("tab");
else p.set("tab", v);
return p;
},
{ replace: true },
)
}
>
<TabsList aria-label="规则分节">
<TabsTrigger value="groups">规则组</TabsTrigger>
<TabsTrigger value="atoms">自定义条件</TabsTrigger>
<TabsTrigger value="defaults">默认值</TabsTrigger>
<TabsTrigger value="policies">收录策略</TabsTrigger>
<TabsTrigger value="suppressions">抑制记录</TabsTrigger>
</TabsList>
<TabsContent value="groups">
<FilterBar
search={{
value: q,
onChange: (v) => {
setQ(v);
setParam("q", v);
},
placeholder: "搜索名称",
}}
onReset={
filtered
? () => {
setQ("");
setParams(new URLSearchParams(), { replace: true });
}
: undefined
}
>
<FilterSelect
label="类型"
value={kind}
onChange={(v) => setParam("kind", v)}
options={(Object.keys(RULE_KIND) as RuleGroupKind[]).map((k) => ({
value: k,
label: RULE_KIND[k].label,
}))}
/>
<FilterSelect
label="来源"
value={source}
onChange={(v) => {
setParam("origin", "");
setParam("source", v);
}}
options={(Object.keys(RULE_SOURCE) as RuleGroupSource[]).map((k) => ({
value: k,
label: RULE_SOURCE[k],
}))}
/>
<FilterSelect
label="状态"
value={enabled}
onChange={(v) => setParam("enabled", v)}
options={[
{ value: "true", label: "启用" },
{ value: "false", label: "停用" },
]}
/>
</FilterBar>
<div className="space-y-6">
{groups.isSuccess && (tags.length > 0 || !filtered) ? <RuleTags rows={tags} onOpen={open} /> : null}
<RuleGroupTable
rows={rest}
loading={groups.isPending}
error={groups.error}
onRetry={() => groups.refetch()}
refs={refs.data}
filtered={filtered}
onOpen={open}
onCreate={create}
/>
</div>
</TabsContent>
<TabsContent value="atoms">
<AtomsTab />
</TabsContent>
<TabsContent value="defaults">
<DefaultsTab groups={all.data?.items} />
</TabsContent>
<TabsContent value="policies">
<PolicyWorkspace creating={creatingPolicy} onCreatingChange={setCreatingPolicy} />
</TabsContent>
<TabsContent value="suppressions">
<SuppressionsTab />
</TabsContent>
</Tabs>
<RuleGroupEditor
open={editor.open}
group={editing}
defaultKind={(kind || "acquisition") as RuleGroupKind}
onOpenChange={(o) => setEditor((e) => ({ ...e, open: o }))}
/>
</>
);
}
+1
View File
@@ -10,6 +10,7 @@
| ContentUnit | 季 / 集;统称"内容单元" | 单元、分集单元 |
| Slot | 版本目标 | Slot、收录目标、目标槽。给模型的说明可写一次"版本目标(slot)"对应参数名 |
| AcquisitionPolicy | 收录策略(版本号称"策略版本") | 收录规则、规则集 |
| RuleGroup | 规则组;条件叫"关键字"(内置)/"自定义条件";只有一条规则的组在规则页以"偏好"标签显示 | 偏好标签、过滤规则、规则集 |
| Subscription | 订阅 | 追剧、关注 |
| Site / SiteAccount | 站点 / 站点账号 | PT 源 |
| FeedSource | Feed | RSS 源、订阅源 |