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:
1 parent
81bf010e3e
commit
b908b4a0c9
12 files changed
+2657
-427
No files matched your search
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 }))}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
| ContentUnit | 季 / 集;统称"内容单元" | 单元、分集单元 |
|
||||
| Slot | 版本目标 | Slot、收录目标、目标槽。给模型的说明可写一次"版本目标(slot)"对应参数名 |
|
||||
| AcquisitionPolicy | 收录策略(版本号称"策略版本") | 收录规则、规则集 |
|
||||
| RuleGroup | 规则组;条件叫"关键字"(内置)/"自定义条件";只有一条规则的组在规则页以"偏好"标签显示 | 偏好标签、过滤规则、规则集 |
|
||||
| Subscription | 订阅 | 追剧、关注 |
|
||||
| Site / SiteAccount | 站点 / 站点账号 | PT 源 |
|
||||
| FeedSource | Feed | RSS 源、订阅源 |
|
||||
|
||||
Reference in new issue
Block a user