merge: fe3-web (FE-3 rules page, naming form, global search) into main

Conflicts resolved: routes.ts keeps both /console/status (FE-1) and
/console/search (FE-3); smoke list keeps FE-1/FE-4/FE-5 routes plus the
rules tabs, search results and preferences→rules redirects. Top bar mounts
SearchTrigger and CommandPalette next to the FE-1 inbox bell.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-28 16:39:13 +08:00
commit 04401309fb
31 files changed
+5049 -429

No files matched your search

+97
View File
@@ -0,0 +1,97 @@
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
EntityNamingOverridePut,
LibraryRootNamingPut,
type NamingOverride,
type NamingPreviewInput,
NamingPreviewResult,
NamingSettings,
NamingTemplatesView,
NamingVariablesResponse,
} from "@umt/contracts";
import { api } from "~/lib/api.ts";
import { qk } from "~/lib/query-keys.ts";
import { getParsed, libraryKeys, parseOrThrow } from "./library.ts";
// 整理命名模板(W9-C):变量表、三级模板(全局 > 库根 > 作品)、预览与保存。
export const namingKeys = {
all: ["naming"] as const,
variables: ["naming", "variables"] as const,
templates: (q: NamingScope) => ["naming", "templates", q] as const,
preview: (input: NamingPreviewInput) => ["naming", "preview", input] as const,
};
export type NamingScope = { libraryRootId?: string; entityId?: string };
export function useNamingVariables() {
return useQuery({
queryKey: namingKeys.variables,
queryFn: ({ signal }) => getParsed(NamingVariablesResponse, "/naming/variables", undefined, signal),
staleTime: 30 * 60_000,
});
}
/** 三级模板 + MP 默认 + 按类型的生效模板与来源 */
export function useNamingTemplates(scope: NamingScope = {}, enabled = true) {
return useQuery({
queryKey: namingKeys.templates(scope),
enabled,
queryFn: ({ signal }) => getParsed(NamingTemplatesView, "/naming/templates", scope, signal),
});
}
/** 预览:input 为 null 时不请求;调用方负责防抖 */
export function useNamingPreview(input: NamingPreviewInput | null) {
return useQuery({
queryKey: namingKeys.preview(input ?? { kind: "movie" }),
enabled: !!input,
placeholderData: keepPreviousData,
retry: false,
queryFn: async () =>
parseOrThrow(NamingPreviewResult, await api.post<unknown>("/naming/preview", input), "/naming/preview"),
});
}
function useInvalidate() {
const qc = useQueryClient();
return () => {
void qc.invalidateQueries({ queryKey: namingKeys.all });
void qc.invalidateQueries({ queryKey: qk.settings });
void qc.invalidateQueries({ queryKey: libraryKeys.roots });
};
}
/** 保存全局模板(逐条编译,有错 422,消息带 "movie: 行:列 …") */
export function useSaveGlobalNaming() {
const done = useInvalidate();
return useMutation({
mutationFn: (body: Partial<NamingSettings>) =>
api.put<unknown>("/naming/templates", body).then((d) => parseOrThrow(NamingSettings, d, "/naming/templates")),
onSuccess: done,
});
}
/** 库根覆盖;null = 清除覆盖 */
export function useSaveRootNaming() {
const done = useInvalidate();
return useMutation({
mutationFn: ({ id, naming }: { id: string; naming: NamingOverride | null }) =>
api
.put<unknown>(`/library-roots/${id}/naming`, { naming })
.then((d) => parseOrThrow(LibraryRootNamingPut, d, "/library-roots/naming")),
onSuccess: done,
});
}
/** 作品命名覆盖;null = 清除覆盖 */
export function useSaveEntityNaming() {
const done = useInvalidate();
return useMutation({
mutationFn: ({ id, namingOverride }: { id: string; namingOverride: NamingOverride | null }) =>
api
.put<unknown>(`/media-entities/${id}/naming-override`, { namingOverride })
.then((d) => parseOrThrow(EntityNamingOverridePut, d, "/media-entities/naming-override")),
onSuccess: done,
});
}
+235
View File
@@ -0,0 +1,235 @@
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
AcquisitionSlot,
cursorPage,
PreferenceSuppression,
RuleAtom,
type RuleAtomCreate,
type RuleAtomUpdate,
RuleDefaults,
RuleGroup,
type RuleGroupCreate,
type RuleGroupKind,
RuleGroupRevisionList,
type RuleGroupSource,
type RuleGroupUpdate,
RuleKeywordList,
type RuleLayer,
RuleParseResult,
type RulePreviewRequest,
RulePreviewResult,
Subscription,
} from "@umt/contracts";
import { api } from "~/lib/api.ts";
import { getParsed, parseOrThrow } from "./library.ts";
// 规则(W9-D,docs/runbook/rules.md):规则组 = 收录过滤条件 + 偏好标签合一;自定义条件;三套默认链;抑制记录。
// 写入给 ruleString(优先)或 layers;PATCH 带 expectedRevision,不匹配 409。
export const ruleKeys = {
all: ["rules"] as const,
groups: (f: RuleGroupFilters) => ["rules", "groups", f] as const,
group: (id: string) => ["rules", "group", id] as const,
revisions: (id: string) => ["rules", "revisions", id] as const,
keywords: ["rules", "keywords"] as const,
atoms: ["rules", "atoms"] as const,
defaults: ["rules", "defaults"] as const,
suppressions: ["rules", "suppressions"] as const,
refs: ["rules", "refs"] as const,
};
export type RuleGroupFilters = {
kind?: RuleGroupKind;
source?: RuleGroupSource;
enabled?: "true" | "false";
includeDeleted?: "true";
q?: string;
};
const GroupPage = cursorPage(RuleGroup);
const AtomPage = cursorPage(RuleAtom);
const SupPage = cursorPage(PreferenceSuppression);
export function useRuleGroups(filters: RuleGroupFilters = {}) {
return useQuery({
queryKey: ruleKeys.groups(filters),
queryFn: ({ signal }) => getParsed(GroupPage, "/rules", { ...filters, limit: 200 }, signal),
placeholderData: keepPreviousData,
});
}
export function useRuleGroupRevisions(id: string | null | undefined) {
return useQuery({
queryKey: ruleKeys.revisions(id ?? ""),
enabled: !!id,
queryFn: async ({ signal }) =>
(await getParsed(RuleGroupRevisionList, `/rules/${id}/revisions`, undefined, signal)).items,
});
}
export function useRuleKeywords() {
return useQuery({
queryKey: ruleKeys.keywords,
queryFn: async ({ signal }) => (await getParsed(RuleKeywordList, "/rules/keywords", undefined, signal)).items,
staleTime: 60_000,
});
}
export function useRuleAtoms() {
return useQuery({
queryKey: ruleKeys.atoms,
queryFn: ({ signal }) => getParsed(AtomPage, "/rules/atoms", { limit: 200 }, signal),
});
}
export function useRuleDefaults() {
return useQuery({
queryKey: ruleKeys.defaults,
queryFn: ({ signal }) => getParsed(RuleDefaults, "/rules/defaults", undefined, signal),
});
}
export function useRuleSuppressions() {
return useQuery({
queryKey: ruleKeys.suppressions,
queryFn: ({ signal }) => getParsed(SupPage, "/rules/suppressions", { limit: 200 }, signal),
});
}
export type RuleRefs = {
/** 规则组 id → 引用它的订阅(params.ruleGroupIds 或私有组) */
subscriptions: Map<string, { id: string; title: string }[]>;
/** 规则组 id → 引用它的版本目标 */
slots: Map<string, { id: string; name: string }[]>;
};
const SubPage = cursorPage(Subscription);
const SlotPage = cursorPage(AcquisitionSlot);
/** 引用关系:列表接口不带引用计数,前端从订阅与版本目标反查(各取前 200 条) */
export function useRuleRefs() {
return useQuery({
queryKey: ruleKeys.refs,
queryFn: async ({ signal }): Promise<RuleRefs> => {
const [subs, slots] = await Promise.all([
getParsed(SubPage, "/subscriptions", { limit: 200 }, signal),
getParsed(SlotPage, "/acquisition-slots", { limit: 200 }, signal),
]);
const subscriptions = new Map<string, { id: string; title: string }[]>();
for (const s of subs.items) {
const ids = Array.isArray(s.params?.ruleGroupIds) ? (s.params.ruleGroupIds as string[]) : [];
const title =
(s as { mediaEntityTitle?: string | null }).mediaEntityTitle ?? s.titleQuery ?? `订阅 ${s.id.slice(0, 8)}`;
for (const id of ids) subscriptions.set(id, [...(subscriptions.get(id) ?? []), { id: s.id, title }]);
}
const slotRefs = new Map<string, { id: string; name: string }[]>();
for (const sl of slots.items)
for (const id of sl.ruleGroupIds ?? [])
slotRefs.set(id, [...(slotRefs.get(id) ?? []), { id: sl.id, name: sl.name }]);
return { subscriptions, slots: slotRefs };
},
staleTime: 30_000,
});
}
/** 规则串 → 层(实时解析;错误带位置与相近名) */
export function parseRuleString(ruleString: string) {
return api
.post<unknown>("/rules/parse", { ruleString })
.then((d) => parseOrThrow(RuleParseResult, d, "/rules/parse"));
}
export function usePreviewRuleGroup() {
return useMutation({
mutationFn: ({ id, ...body }: RulePreviewRequest & { id: string }) =>
api.post<unknown>(`/rules/${id}/preview`, body).then((d) => parseOrThrow(RulePreviewResult, d, "/rules/preview")),
});
}
function useInvalidate() {
const qc = useQueryClient();
return () => {
void qc.invalidateQueries({ queryKey: ruleKeys.all });
// 旧偏好标签页与订阅/版本目标的规则组显示同源
void qc.invalidateQueries({ queryKey: ["preferences"] });
};
}
const parseGroup = (d: unknown) => parseOrThrow(RuleGroup, d, "/rules");
export function useCreateRuleGroup() {
const done = useInvalidate();
return useMutation({
mutationFn: (body: RuleGroupCreate) => api.post<unknown>("/rules", body).then(parseGroup),
onSuccess: done,
});
}
/** 编辑 / 停用(enabled=false 写抑制记录)/ 重新启用;带 expectedRevision 防覆盖 */
export function useUpdateRuleGroup() {
const done = useInvalidate();
return useMutation({
mutationFn: ({ id, ...body }: RuleGroupUpdate & { id: string }) =>
api.patch<unknown>(`/rules/${id}`, body).then(parseGroup),
onSettled: done,
});
}
/** 删除:标记删除并写抑制记录;被版本目标/订阅引用时 409 */
export function useDeleteRuleGroup() {
const done = useInvalidate();
return useMutation({
mutationFn: (id: string) => api.del<unknown>(`/rules/${id}`),
onSuccess: done,
});
}
export function useSaveRuleDefaults() {
const done = useInvalidate();
return useMutation({
mutationFn: (body: RuleDefaults) =>
api.put<unknown>("/rules/defaults", body).then((d) => parseOrThrow(RuleDefaults, d, "/rules/defaults")),
onSuccess: done,
});
}
const parseAtom = (d: unknown) => parseOrThrow(RuleAtom, d, "/rules/atoms");
export function useCreateRuleAtom() {
const done = useInvalidate();
return useMutation({
mutationFn: (body: RuleAtomCreate) => api.post<unknown>("/rules/atoms", body).then(parseAtom),
onSuccess: done,
});
}
export function useUpdateRuleAtom() {
const done = useInvalidate();
return useMutation({
mutationFn: ({ id, ...body }: RuleAtomUpdate & { id: string }) =>
api.patch<unknown>(`/rules/atoms/${id}`, body).then(parseAtom),
onSuccess: done,
});
}
export function useDeleteRuleAtom() {
const done = useInvalidate();
return useMutation({
mutationFn: (id: string) => api.del<unknown>(`/rules/atoms/${id}`),
onSuccess: done,
});
}
/** 解除抑制:之后 Agent 可以再次提出同一规则(仍需审批) */
export function useLiftRuleSuppression() {
const done = useInvalidate();
return useMutation({
mutationFn: (v: { id: string; reason?: string }) =>
api
.patch<unknown>(`/rules/suppressions/${v.id}`, { active: false, reason: v.reason })
.then((d) => parseOrThrow(PreferenceSuppression, d, "/rules/suppressions")),
onSuccess: done,
});
}
export type { RuleLayer };
+93
View File
@@ -0,0 +1,93 @@
import { keepPreviousData, useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
ManualAcquisitionResult,
ReleaseSearchPage,
type ReleaseSearchSort,
type SearchReleasePlanRequest,
SearchResponse,
type SearchScope,
} from "@umt/contracts";
import { api } from "~/lib/api.ts";
import { getParsed, parseOrThrow } from "./library.ts";
// 全局搜索(W9-G):命令面板查 media/actions(快);资源结果页走 /search/releases(筛选/排序/游标,与 /search 共用 30s 缓存)。
export const searchKeys = {
all: ["search"] as const,
aggregate: (q: string, scope: SearchScope, limit: number) => ["search", "aggregate", q, scope, limit] as const,
releases: (f: ReleaseSearchFilters) => ["search", "releases", f] as const,
};
/** 聚合搜索;q 为空不发请求 */
export function useSearch(q: string, scope: SearchScope, limit = 8, enabled = true) {
const term = q.trim();
return useQuery({
queryKey: searchKeys.aggregate(term, scope, limit),
enabled: enabled && term.length > 0,
queryFn: ({ signal }) => getParsed(SearchResponse, "/search", { q: term, scope, limit }, signal),
placeholderData: keepPreviousData,
staleTime: 15_000,
});
}
export type ReleaseSearchFilters = {
q: string;
/** 逗号分隔 */
sites?: string;
resolution?: string;
source?: string;
/** 字节 */
minSize?: number;
maxSize?: number;
free?: "true" | "false";
hr?: "true" | "false";
sort?: ReleaseSearchSort;
order?: "asc" | "desc";
};
/** 资源结果页:游标翻页;refresh() 带 refresh=true 跳过缓存重查第一页 */
export function useReleaseSearch(filters: ReleaseSearchFilters, enabled = true) {
return useInfiniteQuery({
queryKey: searchKeys.releases(filters),
enabled: enabled && filters.q.trim().length > 0,
initialPageParam: undefined as string | undefined,
queryFn: ({ pageParam, signal }) =>
getParsed(ReleaseSearchPage, "/search/releases", { ...filters, cursor: pageParam, limit: 50 }, signal),
getNextPageParam: (last) => last.nextCursor ?? undefined,
placeholderData: keepPreviousData,
staleTime: 30_000,
});
}
/** 跳过缓存重查站点(仍参与并发合并),结果写回当前筛选的第一页 */
export function useRefreshReleaseSearch() {
const qc = useQueryClient();
return useMutation({
mutationFn: (filters: ReleaseSearchFilters) =>
getParsed(ReleaseSearchPage, "/search/releases", { ...filters, refresh: "true", limit: 50 }),
onSuccess: (page, filters) => {
qc.setQueryData(searchKeys.releases(filters), { pages: [page], pageParams: [undefined] });
void qc.invalidateQueries({ queryKey: ["search", "aggregate"] });
},
});
}
/** 从搜索结果下载:与 POST /acquisition-plans 同一流程 */
export function usePlanFromSearch() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({
siteId,
siteReleaseId,
...body
}: SearchReleasePlanRequest & { siteId: string; siteReleaseId: string }) =>
api
.post<unknown>(`/search/releases/${encodeURIComponent(siteId)}/${encodeURIComponent(siteReleaseId)}/plan`, body)
.then((d) => parseOrThrow(ManualAcquisitionResult, d, "/search/releases/plan")),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ["plans"] });
void qc.invalidateQueries({ queryKey: ["slots"] });
void qc.invalidateQueries({ queryKey: searchKeys.all });
},
});
}
@@ -0,0 +1,127 @@
import type { MediaKind, NamingKind } from "@umt/contracts";
import { FileSignature, Save, Trash2 } from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { useNamingTemplates, useSaveEntityNaming } from "~/api/naming.ts";
import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { Button } from "~/components/ui/button.tsx";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog.tsx";
import { errorMessage } from "~/lib/api.ts";
import { overrideBody, SOURCE_LABEL, splitNamingErrors } from "./model.ts";
import { NAMING_NOTE, NamingTemplateEditor } from "./template-editor.tsx";
// 作品详情 → 命名覆盖:只编辑该作品类型对应的模板;留空沿用库根 / 全局。
export function namingKindOf(kind: MediaKind): NamingKind {
if (kind === "series") return "tv";
if (kind === "variety") return "variety";
return "movie";
}
/** 作品头部按钮 + 对话框(自带开关状态,挂载处只放一行) */
export function EntityNamingButton({ entityId, kind }: { entityId: string; kind: MediaKind }) {
const [open, setOpen] = useState(false);
return (
<>
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
<FileSignature />
命名覆盖
</Button>
{open ? (
<EntityNamingDialog entityId={entityId} kind={namingKindOf(kind)} onClose={() => setOpen(false)} />
) : null}
</>
);
}
function EntityNamingDialog({ entityId, kind, onClose }: { entityId: string; kind: NamingKind; onClose: () => void }) {
const q = useNamingTemplates({ entityId });
const save = useSaveEntityNaming();
const [draft, setDraft] = useState<Partial<Record<NamingKind, string>> | null>(null);
const [errors, setErrors] = useState<Partial<Record<NamingKind, string>> | undefined>();
useEffect(() => {
if (q.data && !draft) setDraft({ ...(q.data.entity ?? {}) });
}, [q.data, draft]);
const saved = q.data?.entity ?? {};
const inheritedTemplate = q.data ? (q.data.root?.[kind] ?? q.data.global[kind]) : "";
const inheritedSource = q.data?.root?.[kind] ? SOURCE_LABEL.root : SOURCE_LABEL.global;
const dirty = !!draft && (draft[kind] ?? "") !== (saved[kind] ?? "");
const put = async (clear: boolean) => {
if (!draft) return;
setErrors(undefined);
// 只改本类型,其它类型的已有覆盖原样保留
const next = clear ? { ...saved, [kind]: "" } : { ...saved, [kind]: draft[kind] ?? "" };
const body = overrideBody(next);
try {
await save.mutateAsync({ id: entityId, namingOverride: body });
setDraft({ ...(body ?? {}) });
toast.success(clear ? "已清除作品命名覆盖" : "作品命名覆盖已保存");
} catch (err) {
const split = splitNamingErrors(errorMessage(err));
if (split) setErrors(split);
toast.error(split ? "模板有错误,未保存" : errorMessage(err, "保存失败"));
}
};
return (
<Dialog open onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-5xl">
<DialogHeader>
<DialogTitle>命名覆盖</DialogTitle>
<DialogDescription>{NAMING_NOTE}</DialogDescription>
</DialogHeader>
{q.isError ? (
<ErrorState plain error={q.error} onRetry={() => q.refetch()} />
) : q.isPending || !draft ? (
<LoadingState rows={4} />
) : (
<div className="min-w-0 space-y-3">
<div className="flex flex-wrap items-center gap-2">
<StatusBadge tone={saved[kind] ? "success" : "neutral"}>
当前:{saved[kind] ? "作品覆盖" : `沿用${inheritedSource}`}
</StatusBadge>
</div>
<NamingTemplateEditor
kinds={[kind]}
values={draft}
onChange={(k, v) => {
setDraft((d) => ({ ...d, [k]: v }));
setErrors(undefined);
}}
defaults={q.data.defaults}
inherited={{ [kind]: { template: inheritedTemplate, source: inheritedSource } }}
entityId={entityId}
saveErrors={errors}
/>
</div>
)}
<DialogFooter className="gap-2">
<Button
variant="ghost"
className="hover:text-destructive"
disabled={!saved[kind] || save.isPending}
onClick={() => put(true)}
>
<Trash2 />
清除覆盖
</Button>
<Button onClick={() => put(false)} disabled={!dirty || save.isPending}>
<Save />
{save.isPending ? "保存中…" : "保存"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+27
View File
@@ -0,0 +1,27 @@
import type { NamingKind, NamingOverride } from "@umt/contracts";
// 命名模板表单的纯函数:保存错误拆分、覆盖请求体、来源文案。
export const NAMING_KINDS: NamingKind[] = ["movie", "tv", "variety"];
export const SOURCE_LABEL = { global: "全局", root: "库根", entity: "作品" } as const;
/** 422 消息 "movie: 1:3 …; tv: …" → 按类型拆开,行列号改写成中文 */
export function splitNamingErrors(message: string): Partial<Record<NamingKind, string>> | null {
const out: Partial<Record<NamingKind, string>> = {};
for (const m of message.matchAll(/(movie|tv|variety):\s*([^;]+)/g)) {
const [, k, msg] = m;
const pos = /^(\d+):(\d+)\s*(.*)$/.exec((msg ?? "").trim());
out[k as NamingKind] = pos ? `第 ${pos[1]} 行第 ${pos[2]} 列:${pos[3]}` : (msg ?? "").trim();
}
return Object.keys(out).length ? out : null;
}
/** 覆盖草稿 → 请求体;全部留空 = null(清除覆盖) */
export function overrideBody(draft: Partial<Record<NamingKind, string>>): NamingOverride | null {
const o: NamingOverride = {};
for (const k of NAMING_KINDS) {
const v = draft[k];
if (v?.trim()) o[k] = v;
}
return Object.keys(o).length ? o : null;
}
@@ -0,0 +1,503 @@
import type { NamingKind, NamingPreviewResult, NamingVariablesResponse } from "@umt/contracts";
import { Braces, ChevronRight, RotateCcw, Search, Undo2 } from "lucide-react";
import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
import { useNamingPreview, useNamingVariables } from "~/api/naming.ts";
import { StatusBadge } from "~/components/console/status-badge.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 { Tabs, TabsList, TabsTrigger } from "~/components/ui/tabs.tsx";
import { Textarea } from "~/components/ui/textarea.tsx";
import { cn } from "~/lib/utils.ts";
// 命名模板编辑器(设置全局 / 库根覆盖 / 作品覆盖共用):按类型分段,变量点击插入,实时预览带段角色与错误位置。
export const NAMING_KIND_LABEL: Record<NamingKind, string> = { movie: "电影", tv: "剧集", variety: "综艺" };
const GROUP_LABEL: Record<NamingVariablesResponse["items"][number]["group"], string> = {
title: "标题",
episode: "季集",
tech: "技术参数",
id: "外部身份",
ours: "其他",
};
const GROUP_ORDER = ["title", "episode", "tech", "id", "ours"] as const;
const ROLE: Record<NamingPreviewResult["segments"][number]["role"], { label: string; className: string }> = {
category: { label: "分类", className: "text-muted-foreground" },
root: { label: "作品根", className: "text-primary font-medium" },
season: { label: "季目录", className: "text-foreground" },
sub: { label: "子目录", className: "text-muted-foreground" },
file: { label: "文件名", className: "text-foreground font-medium" },
};
export const NAMING_NOTE = "已建立目录的作品不受模板影响,模板只决定新目录和新文件名。";
function useDebounced<T>(v: T, ms = 300): T {
const [d, setD] = useState(v);
useEffect(() => {
const t = setTimeout(() => setD(v), ms);
return () => clearTimeout(t);
}, [v, ms]);
return d;
}
/** 1 起的行列号 → 字符偏移 */
function offsetOf(text: string, line: number, col: number): number {
const lines = text.split("\n");
let off = 0;
for (let i = 0; i < Math.min(line - 1, lines.length); i++) off += (lines[i]?.length ?? 0) + 1;
return Math.min(text.length, off + Math.max(0, col - 1));
}
export type NamingEditorProps = {
kinds: NamingKind[];
values: Partial<Record<NamingKind, string>>;
onChange: (kind: NamingKind, value: string) => void;
/** MP 默认(恢复默认) */
defaults: Record<NamingKind, string>;
/** 覆盖模式:留空沿用的上级模板与来源说明;不传 = 全局模式(必填) */
inherited?: Partial<Record<NamingKind, { template: string; source: string }>>;
/** 预览上下文 */
libraryRootId?: string;
entityId?: string;
/** 保存失败时服务端给的逐类型错误 */
saveErrors?: Partial<Record<NamingKind, string>>;
/** 各类型是否与已保存值不同 */
dirtyKinds?: Partial<Record<NamingKind, boolean>>;
};
export function NamingTemplateEditor({
kinds,
values,
onChange,
defaults,
inherited,
libraryRootId,
entityId,
saveErrors,
dirtyKinds,
}: NamingEditorProps) {
const [kind, setKind] = useState<NamingKind>(kinds[0] ?? "movie");
const active = kinds.includes(kind) ? kind : (kinds[0] ?? "movie");
const vars = useNamingVariables();
const ref = useRef<HTMLTextAreaElement>(null);
const override = !!inherited;
const value = values[active] ?? "";
const effective = value.trim() ? value : (inherited?.[active]?.template ?? "");
const insert = (token: string) => {
const el = ref.current;
const start = el?.selectionStart ?? value.length;
const end = el?.selectionEnd ?? value.length;
const next = value.slice(0, start) + token + value.slice(end);
onChange(active, next);
requestAnimationFrame(() => {
if (!el) return;
el.focus();
el.setSelectionRange(start + token.length, start + token.length);
});
};
const jumpTo = (line: number, col: number) => {
const el = ref.current;
if (!el) return;
const off = offsetOf(el.value, line, col);
el.focus();
el.setSelectionRange(off, Math.min(el.value.length, off + 1));
};
return (
<div className="grid min-w-0 gap-5 lg:grid-cols-[minmax(0,1fr)_18rem]">
<div className="min-w-0 space-y-4">
<div className="flex flex-wrap items-center justify-between gap-2">
{kinds.length > 1 ? (
<Tabs value={active} onValueChange={(v) => setKind(v as NamingKind)}>
<TabsList aria-label="作品类型">
{kinds.map((k) => (
<TabsTrigger key={k} value={k}>
{NAMING_KIND_LABEL[k]}
{dirtyKinds?.[k] ? (
<span className="h-1.5 w-1.5 rounded-full bg-warning">
<span className="sr-only">未保存</span>
</span>
) : null}
</TabsTrigger>
))}
</TabsList>
</Tabs>
) : (
<span className="text-body font-medium text-foreground">{NAMING_KIND_LABEL[active]}模板</span>
)}
<div className="flex items-center gap-1">
{override ? (
<Button type="button" variant="ghost" size="sm" disabled={!value} onClick={() => onChange(active, "")}>
<Undo2 />
沿用上级
</Button>
) : null}
<Button
type="button"
variant="ghost"
size="sm"
disabled={value === defaults[active]}
onClick={() => onChange(active, defaults[active])}
>
<RotateCcw />
恢复默认
</Button>
</div>
</div>
<div className="space-y-1.5">
<Textarea
ref={ref}
value={value}
onChange={(e) => onChange(active, e.target.value)}
rows={4}
spellCheck={false}
autoCapitalize="off"
autoCorrect="off"
aria-label={`${NAMING_KIND_LABEL[active]}命名模板`}
placeholder={override ? (inherited?.[active]?.template ?? "") : "{{title}}/{{title}}{{fileExt}}"}
aria-invalid={!!saveErrors?.[active] || undefined}
className="max-h-60 min-h-24 font-mono text-caption leading-relaxed break-all field-sizing-content"
/>
<div className="flex flex-wrap items-center justify-between gap-2 text-meta text-muted-foreground">
<span>
{override && !value.trim()
? `留空:沿用${inherited?.[active]?.source ?? "上级"}模板`
: "/ 分目录,最后一段是文件名,须以 {{fileExt}} 结尾"}
</span>
<span className="tabular-nums">{value.length} / 1024</span>
</div>
{saveErrors?.[active] ? <p className="text-caption text-destructive">{saveErrors[active]}</p> : null}
</div>
<NamingPreview
kind={active}
template={effective}
libraryRootId={libraryRootId}
entityId={entityId}
samples={vars.data?.samples ?? []}
onJump={jumpTo}
/>
</div>
<VariablePalette data={vars.data} loading={vars.isPending} error={vars.error} onInsert={insert} />
</div>
);
}
// ── 预览 ──
const CUSTOM = "custom";
function NamingPreview({
kind,
template,
libraryRootId,
entityId,
samples,
onJump,
}: {
kind: NamingKind;
template: string;
libraryRootId?: string;
entityId?: string;
samples: NamingVariablesResponse["samples"];
onJump: (line: number, col: number) => void;
}) {
const ofKind = samples.filter((s) => s.kind === kind);
const [sample, setSample] = useState<string>("");
const [fileName, setFileName] = useState("");
// 切换类型后选中该类型的第一条样本
const sampleId = sample && (sample === CUSTOM || ofKind.some((s) => String(s.id) === sample)) ? sample : "";
const selected = sampleId || (ofKind[0] ? String(ofKind[0].id) : "");
const input = useMemo(() => {
if (!template.trim()) return null;
const s =
selected === CUSTOM
? fileName.trim()
? { fileName: fileName.trim() }
: undefined
: selected
? { builtin: Number(selected) }
: undefined;
return {
template,
kind,
...(s ? { sample: s } : {}),
...(libraryRootId ? { libraryRootId } : {}),
...(entityId ? { entityId } : {}),
};
}, [template, kind, selected, fileName, libraryRootId, entityId]);
const debounced = useDebounced(input, 300);
const q = useNamingPreview(debounced);
const r = q.data;
const stale = q.isFetching || debounced !== input;
return (
<div className="min-w-0 rounded-xl border border-border/70">
<div className="flex flex-wrap items-center gap-2 border-b border-border/60 px-3 py-2">
<span className="text-caption font-medium text-foreground">预览</span>
<Select value={selected} onValueChange={setSample}>
<SelectTrigger size="sm" aria-label="预览样本" className="max-w-full min-w-0 sm:max-w-80">
<SelectValue placeholder="选择样本" />
</SelectTrigger>
<SelectContent position="popper">
{ofKind.map((s) => (
<SelectItem key={s.id} value={String(s.id)}>
{s.label}
</SelectItem>
))}
<SelectItem value={CUSTOM}>输入文件名</SelectItem>
</SelectContent>
</Select>
{stale && input ? <span className="text-meta text-muted-foreground">预览中…</span> : null}
</div>
<div className="space-y-3 px-3 py-3">
{selected === CUSTOM ? (
<Input
value={fileName}
onChange={(e) => setFileName(e.target.value)}
placeholder="Show.Name.S01E02.1080p.WEB-DL.H264-GROUP.mkv"
aria-label="样例文件名"
className="font-mono text-caption"
/>
) : null}
{!input ? (
<p className="text-caption text-muted-foreground">模板为空,没有可预览的内容。</p>
) : q.isError && !r ? (
<p className="text-caption text-destructive">预览失败:{(q.error as Error).message}</p>
) : !r ? (
<p className="text-caption text-muted-foreground">预览中…</p>
) : (
<PreviewBody r={r} template={template} onJump={onJump} />
)}
</div>
</div>
);
}
function PreviewBody({
r,
template,
onJump,
}: {
r: NamingPreviewResult;
template: string;
onJump: (line: number, col: number) => void;
}) {
return (
<>
<p className="text-meta break-all text-muted-foreground">
样本:<span className="font-mono">{r.sample.fileName}</span>
{r.sample.entityTitle ? ` · 作品 ${r.sample.entityTitle}` : ""}
</p>
{r.errors.length ? (
<ul className="space-y-2">
{r.errors.map((e, i) => (
<li key={`${e.code}-${i}`}>
<ErrorExcerpt template={template} line={e.line} col={e.col} message={e.message} onJump={onJump} />
</li>
))}
</ul>
) : (
<div className="flex min-w-0 flex-wrap items-start gap-x-1 gap-y-2 font-mono text-caption">
{r.segments.map((s, i) => (
<span key={`${i}-${s.text}`} className="flex min-w-0 items-start gap-1">
{i > 0 ? <span className="text-muted-foreground">/</span> : null}
<span className="flex min-w-0 flex-col">
<span className={cn("break-all", ROLE[s.role].className)}>{s.text}</span>
<span className="font-sans text-micro text-muted-foreground">{ROLE[s.role].label}</span>
</span>
</span>
))}
</div>
)}
{r.usesBinding ? <Note>这部作品已有目录,目录沿用已建立的路径,模板只决定文件名。</Note> : null}
{r.missing.length ? (
<p className="text-meta text-muted-foreground">
无值的变量(输出为空):<span className="font-mono">{r.missing.join("、")}</span>
</p>
) : null}
{r.warnings.map((w) => (
<p key={w} className="text-meta text-warning">
{w}
</p>
))}
</>
);
}
function Note({ children }: { children: ReactNode }) {
return (
<p className="rounded-lg border border-border/70 bg-foreground/[0.03] px-3 py-2 text-caption text-foreground">
{children}
</p>
);
}
/** 出错行摘录,出错位置之后的一个词加下划线;点击跳到模板里对应位置 */
function ErrorExcerpt({
template,
line,
col,
message,
onJump,
}: {
template: string;
line: number;
col: number;
message: string;
onJump: (line: number, col: number) => void;
}) {
const text = template.split("\n")[line - 1] ?? "";
const at = Math.max(0, Math.min(text.length, col - 1));
const m = /^[\w.]+|^./.exec(text.slice(at));
const len = m ? m[0].length : 0;
return (
<button
type="button"
onClick={() => onJump(line, col)}
className="block w-full min-w-0 cursor-pointer rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-left transition-colors duration-micro hover:bg-destructive/15"
>
<span className="flex flex-wrap items-center gap-2">
<StatusBadge tone="destructive">
第 {line} 行第 {col} 列
</StatusBadge>
<span className="text-caption break-words text-destructive">{message}</span>
</span>
{text ? (
<span className="mt-1.5 block font-mono text-meta break-all text-foreground">
{text.slice(Math.max(0, at - 40), at)}
<mark className="rounded-sm bg-destructive/20 text-destructive underline decoration-wavy underline-offset-4">
{text.slice(at, at + len) || " "}
</mark>
{text.slice(at + len, at + len + 40)}
</span>
) : null}
</button>
);
}
// ── 变量清单 ──
function VariablePalette({
data,
loading,
error,
onInsert,
}: {
data: NamingVariablesResponse | undefined;
loading: boolean;
error: unknown;
onInsert: (token: string) => void;
}) {
const [q, setQ] = useState("");
const [filtersOpen, setFiltersOpen] = useState(false);
const term = q.trim().toLowerCase();
const items = (data?.items ?? []).filter(
(v) => !term || v.name.toLowerCase().includes(term) || v.label.toLowerCase().includes(term),
);
return (
<aside className="min-w-0 space-y-3 rounded-xl border border-border/70 px-3 py-3">
<div className="flex items-center gap-2">
<Braces className="h-4 w-4 text-muted-foreground" />
<span className="text-caption font-medium text-foreground">变量</span>
<span className="text-meta text-muted-foreground">点击插入到光标处</span>
</div>
<div className="relative">
<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
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="搜索变量"
aria-label="搜索变量"
className="h-8 pl-8 text-caption"
/>
</div>
{loading ? (
<p className="text-meta text-muted-foreground">加载中…</p>
) : error ? (
<p className="text-meta text-destructive">变量表加载失败:{(error as Error).message}</p>
) : (
<div className="max-h-96 space-y-3 overflow-y-auto pr-1">
{GROUP_ORDER.map((g) => {
const list = items.filter((v) => v.group === g);
if (!list.length) return null;
return (
<div key={g}>
<p className="mb-1 text-micro font-semibold tracking-[0.12em] text-muted-foreground uppercase">
{GROUP_LABEL[g]}
</p>
<ul className="space-y-0.5">
{list.map((v) => (
<li key={v.name}>
<button
type="button"
onClick={() => onInsert(`{{${v.name}}}`)}
title={`插入 {{${v.name}}}`}
className="block w-full min-w-0 cursor-pointer rounded-md px-2 py-1 text-left transition-colors duration-micro hover:bg-foreground/[0.05] focus-visible:bg-foreground/[0.06] focus-visible:outline-none"
>
<span className="flex flex-wrap items-center gap-1.5">
<span className="font-mono text-caption text-foreground">{v.name}</span>
{v.ours ? <span className="text-micro text-muted-foreground">MP 无</span> : null}
{v.example ? (
<span className="ml-auto max-w-full truncate font-mono text-meta text-muted-foreground">
{v.example}
</span>
) : null}
</span>
<span className="block text-meta text-muted-foreground">{v.label}</span>
</button>
</li>
))}
</ul>
</div>
);
})}
{items.length === 0 ? <p className="text-meta text-muted-foreground">没有匹配的变量</p> : null}
</div>
)}
{data?.filters.length ? (
<div className="border-t border-border/60 pt-2">
<button
type="button"
aria-expanded={filtersOpen}
onClick={() => setFiltersOpen((o) => !o)}
className="flex w-full cursor-pointer items-center gap-1 text-caption font-medium text-foreground"
>
<ChevronRight
className={cn("h-3.5 w-3.5 transition-transform duration-micro", filtersOpen && "rotate-90")}
/>
过滤器与语法
</button>
{filtersOpen ? (
<ul className="mt-2 space-y-1.5">
<li className="text-meta text-muted-foreground">
<span className="font-mono text-foreground">{"{% if 变量 %}…{% endif %}"}</span> 条件;
<span className="font-mono text-foreground">{"{{ a or b }}"}</span> 取第一个有值的
</li>
{data.filters.map((f) => (
<li key={f.name} className="text-meta text-muted-foreground">
<button
type="button"
onClick={() => onInsert(` | ${f.name}`)}
className="cursor-pointer font-mono text-foreground hover:underline"
title="插入到光标处"
>
{f.name}
</button>{" "}
{f.doc}
</li>
))}
</ul>
) : null}
</div>
) : null}
</aside>
);
}
@@ -0,0 +1,43 @@
import type { RuleExpr, RuleLayer } from "@umt/contracts";
import { describe, expect, it } from "vitest";
import { cardsOf, cardToLayer, exprToString, layersToString } from "./expr.ts";
const a = (id: string): RuleExpr => ({ op: "atom", id });
const not = (arg: RuleExpr): RuleExpr => ({ op: "not", arg });
const and = (...args: RuleExpr[]): RuleExpr => ({ op: "and", args });
const or = (...args: RuleExpr[]): RuleExpr => ({ op: "or", args });
describe("规则串序列化", () => {
it("只加必要括号,原子大写", () => {
expect(exprToString(and(a("specsub"), a("CNSUB")))).toBe("SPECSUB & CNSUB");
expect(exprToString(or(and(a("4K"), a("HDR")), a("1080P")))).toBe("4K & HDR | 1080P");
expect(exprToString(and(or(a("4K"), a("1080P")), not(a("BLU"))))).toBe("(4K | 1080P) & !BLU");
expect(exprToString(not(or(a("REMUX"), a("BLU"))))).toBe("!(REMUX | BLU)");
});
it("多层用 > 连接", () => {
const layers: RuleLayer[] = [{ expr: and(a("SPECSUB"), a("CNSUB")) }, { expr: a("BLU") }];
expect(layersToString(layers)).toBe("SPECSUB & CNSUB > BLU");
});
});
describe("层卡片往返", () => {
it("两层以内的表达式可转卡片再转回", () => {
const layers: RuleLayer[] = [
{ expr: and(or(a("4K"), a("1080P")), not(a("BLU"))) },
{ expr: not(a("REMUX")) },
{ expr: or(a("CNSUB"), not(and(a("HDR"), a("DOLBY")))) },
];
const cards = cardsOf(layers);
expect(cards).not.toBeNull();
expect(cards?.map((c) => cardToLayer(c))).toEqual(layers);
});
it("嵌套太深时返回 null,只能在规则串里改", () => {
expect(cardsOf([{ expr: and(a("A"), or(a("B"), and(a("C"), a("D")))) }])).toBeNull();
});
it("空层转不出表达式", () => {
expect(cardToLayer({ op: "and", items: [] })).toBeNull();
});
});
+115
View File
@@ -0,0 +1,115 @@
import type { RuleExpr, RuleLayer } from "@umt/contracts";
// 规则组层的编辑模型:一层 = 顶层 且/或 + 若干条目;条目是条件(可取反)或一个括号组(组内同一种 且/或)。
// 覆盖 MP 规则串的常见写法;更深的嵌套只能在规则串里改(cardsOf 返回 null)。
export type Op = "and" | "or";
export type AtomItem = { kind: "atom"; id: string; neg: boolean };
export type GroupItem = { kind: "group"; op: Op; neg: boolean; items: AtomItem[] };
export type LayerItem = AtomItem | GroupItem;
export type LayerCard = { name?: string; op: Op; items: LayerItem[] };
/** 规范化:原子大写,层内 ! > & > |,只加必要括号(与后端 /rules/serialize 同口径) */
export function exprToString(e: RuleExpr, parent: "top" | "and" | "or" | "not" = "top"): string {
switch (e.op) {
case "atom":
return e.id.toUpperCase();
case "not":
return `!${exprToString(e.arg, "not")}`;
case "and": {
const s = e.args.map((a) => exprToString(a, "and")).join(" & ");
return parent === "not" ? `(${s})` : s;
}
case "or": {
const s = e.args.map((a) => exprToString(a, "or")).join(" | ");
return parent === "and" || parent === "not" ? `(${s})` : s;
}
}
}
export function layersToString(layers: RuleLayer[]): string {
return layers.map((l) => exprToString(l.expr)).join(" > ");
}
function atomOf(e: RuleExpr): AtomItem | null {
if (e.op === "atom") return { kind: "atom", id: e.id.toUpperCase(), neg: false };
if (e.op === "not" && e.arg.op === "atom") return { kind: "atom", id: e.arg.id.toUpperCase(), neg: true };
return null;
}
function groupOf(e: RuleExpr): GroupItem | null {
let neg = false;
let inner = e;
if (inner.op === "not") {
neg = true;
inner = inner.arg;
}
if (inner.op !== "and" && inner.op !== "or") return null;
const items: AtomItem[] = [];
for (const a of inner.args) {
const at = atomOf(a);
if (!at) return null;
items.push(at);
}
return { kind: "group", op: inner.op, neg, items };
}
/** 表达式 → 卡片;表达不了(嵌套超过两层)返回 null */
export function cardOf(layer: RuleLayer): LayerCard | null {
const e = layer.expr;
const single = atomOf(e);
if (single) return { name: layer.name, op: "and", items: [single] };
if (e.op === "not") {
const g = groupOf(e);
return g ? { name: layer.name, op: "and", items: [g] } : null;
}
if (e.op !== "and" && e.op !== "or") return null;
const items: LayerItem[] = [];
for (const a of e.args) {
const it = atomOf(a) ?? groupOf(a);
if (!it) return null;
items.push(it);
}
return { name: layer.name, op: e.op, items };
}
export function cardsOf(layers: RuleLayer[]): LayerCard[] | null {
const out: LayerCard[] = [];
for (const l of layers) {
const c = cardOf(l);
if (!c) return null;
out.push(c);
}
return out;
}
function atomExpr(a: AtomItem): RuleExpr {
const e: RuleExpr = { op: "atom", id: a.id };
return a.neg ? { op: "not", arg: e } : e;
}
function itemExpr(it: LayerItem): RuleExpr | null {
if (it.kind === "atom") return atomExpr(it);
if (it.items.length === 0) return null;
const inner: RuleExpr =
it.items.length === 1 ? atomExpr(it.items[0] as AtomItem) : { op: it.op, args: it.items.map(atomExpr) };
return it.neg ? { op: "not", arg: inner } : inner;
}
/** 卡片 → 层;空层返回 null 的 expr(调用方据此提示) */
export function cardToLayer(c: LayerCard): RuleLayer | null {
const args = c.items.map(itemExpr).filter((x): x is RuleExpr => !!x);
if (args.length === 0) return null;
const expr: RuleExpr = args.length === 1 ? (args[0] as RuleExpr) : { op: c.op, args };
return c.name ? { name: c.name, expr } : { expr };
}
/** 表达式里引用的全部条件 id */
export function atomsIn(e: RuleExpr, out = new Set<string>()): Set<string> {
if (e.op === "atom") out.add(e.id.toUpperCase());
else if (e.op === "not") atomsIn(e.arg, out);
else for (const a of e.args) atomsIn(a, out);
return out;
}
export const OP_LABEL: Record<Op, string> = { and: "且", or: "或" };
@@ -0,0 +1,420 @@
import {
type RuleGroup,
type RuleGroupCreate,
RuleGroupKind,
type RuleGroupScope,
SubscriptionMediaKind,
} from "@umt/contracts";
import { Plus, RotateCcw, Trash2 } from "lucide-react";
import { useEffect, useId, useState } from "react";
import { toast } from "sonner";
import { useCreateRuleGroup, useDeleteRuleGroup, useRuleGroupRevisions, useUpdateRuleGroup } from "~/api/rules.ts";
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
import { DetailSection, DetailSheet } from "~/components/console/detail-sheet.tsx";
import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { FormField } from "~/components/console/form-field.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { MEDIA_KIND } from "~/components/library/labels.ts";
import { ChipToggleGroup } from "~/components/library/parts.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Input } from "~/components/ui/input.tsx";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
import { Switch } from "~/components/ui/switch.tsx";
import { ApiRequestError, errorMessage } from "~/lib/api.ts";
import { formatDateTime } from "~/lib/utils.ts";
import { exprToString } from "./expr.ts";
import { DEFAULT_CHAIN_LABEL, RULE_KIND, RULE_SOURCE, SUPPRESS_NOTE, scopeText } from "./labels.ts";
import { LadderEditor, type LadderState, ladderFrom, ladderProblem, RuleStringField } from "./ladder-editor.tsx";
import { RulePreviewPanel } from "./preview-panel.tsx";
// 规则组编辑抽屉:基本信息 → 优先级阶梯(规则串 ⇄ 层卡片)→ 硬排除 → 试算 → 修订历史。
// 保存给 ruleString(后端优先用它);PATCH 带 expectedRevision,别处先改了会 409。
type Form = {
name: string;
description: string;
kind: RuleGroupKind;
mediaTypes: SubscriptionMediaKind[];
ladderRequired: boolean;
enabled: boolean;
ladder: LadderState;
excludes: { key: number; state: LadderState }[];
};
let excludeSeq = 0;
function formOf(g: RuleGroup | null, kind: RuleGroupKind): Form {
const scope = (g?.scope ?? {}) as RuleGroupScope & { mediaKinds?: SubscriptionMediaKind[] };
return {
name: g?.name ?? "",
description: g?.description ?? "",
kind: g?.kind ?? kind,
mediaTypes: scope.mediaTypes ?? scope.mediaKinds ?? [],
ladderRequired: g?.ladderRequired ?? true,
enabled: g?.enabled ?? true,
ladder: ladderFrom(g?.layers ?? []),
excludes: (g?.hardExcludes ?? []).map((h) => ({
key: ++excludeSeq,
state: { ...ladderFrom([{ expr: h }]), text: exprToString(h) },
})),
};
}
export function RuleGroupEditor({
open,
group,
defaultKind = "acquisition",
onOpenChange,
}: {
open: boolean;
/** null = 新建 */
group: RuleGroup | null;
defaultKind?: RuleGroupKind;
onOpenChange: (o: boolean) => void;
}) {
const uid = useId();
const create = useCreateRuleGroup();
const update = useUpdateRuleGroup();
const del = useDeleteRuleGroup();
const [form, setForm] = useState<Form>(() => formOf(group, defaultKind));
const [conflict, setConflict] = useState<string | null>(null);
const [nameError, setNameError] = useState<string | null>(null);
/** 表单载入时的修订号:保存带它做 expectedRevision,后台刷新到新修订不覆盖正在编辑的内容 */
const [baseRevision, setBaseRevision] = useState<number | null>(group?.revision ?? null);
const load = (g: RuleGroup | null) => {
setForm(formOf(g, defaultKind));
setBaseRevision(g?.revision ?? null);
setConflict(null);
setNameError(null);
};
// 只在打开或换组时重置
// biome-ignore lint/correctness/useExhaustiveDependencies: 只在打开/换组时重置
useEffect(() => {
if (open) load(group);
}, [open, group?.id]);
const set = <K extends keyof Form>(k: K, v: Form[K]) => setForm((f) => ({ ...f, [k]: v }));
const problems = [
ladderProblem(form.ladder),
...form.excludes.map((e, i) => {
const p = ladderProblem(e.state);
return p ? `第 ${i + 1} 条硬排除:${p}` : null;
}),
].filter((p): p is string => !!p);
const blocked = problems[0] ?? null;
const excludeStrings = form.excludes.map((e) => e.state.text.trim()).filter(Boolean);
const save = async () => {
if (!form.name.trim()) {
setNameError("名称必填");
return;
}
const scope: RuleGroupScope = {
...((group?.scope ?? {}) as RuleGroupScope),
mediaTypes: form.mediaTypes.length ? form.mediaTypes : undefined,
};
delete (scope as { mediaKinds?: unknown }).mediaKinds;
if (!scope.mediaTypes) delete scope.mediaTypes;
const hasLadder = form.ladder.text.trim().length > 0;
const body: RuleGroupCreate = {
name: form.name.trim(),
description: form.description.trim() || null,
kind: form.kind,
scope,
...(hasLadder ? { ruleString: form.ladder.text.trim() } : { layers: [] }),
hardExcludes: excludeStrings,
ladderRequired: form.ladderRequired,
enabled: form.enabled,
};
try {
if (group) {
const saved = await update.mutateAsync({
id: group.id,
...body,
expectedRevision: baseRevision ?? group.revision,
});
load(saved);
toast.success("已保存");
} else {
await create.mutateAsync({ ...body, source: "manual" });
toast.success("已新建规则组");
onOpenChange(false);
}
} catch (err) {
if (err instanceof ApiRequestError && err.code === "conflict") {
if (/revision/.test(err.message)) setConflict("这个规则组刚被别处改过,没有保存。载入最新版本后再改。");
else setNameError(err.message);
} else toast.error(errorMessage(err, "保存失败"));
}
};
const inDefault = group?.defaultOrder !== null && group?.defaultOrder !== undefined;
const pending = create.isPending || update.isPending;
return (
<DetailSheet
open={open}
onOpenChange={onOpenChange}
width="lg"
title={group ? group.name : "新建规则组"}
description={
group ? (
<span className="flex flex-wrap items-center gap-1.5">
<span>
{RULE_KIND[group.kind].label} · {RULE_SOURCE[group.source]} · r{baseRevision ?? group.revision} · 更新于{" "}
{formatDateTime(group.updatedAt)}
</span>
{inDefault ? (
<StatusBadge>
{DEFAULT_CHAIN_LABEL[group.kind]} #{(group.defaultOrder ?? 0) + 1}
</StatusBadge>
) : null}
</span>
) : (
"层从上到下依次匹配,先命中的层优先级高。"
)
}
footer={
<>
{group ? (
<ConfirmDialog
trigger={
<Button variant="ghost" className="mr-auto hover:text-destructive" disabled={inDefault}>
<Trash2 />
删除
</Button>
}
title={`删除「${group.name}」?`}
description={`${SUPPRESS_NOTE}被版本目标或订阅引用时不能删除。`}
confirmLabel="删除"
destructive
onConfirm={async () => {
try {
await del.mutateAsync(group.id);
toast.success("已删除");
onOpenChange(false);
} catch (err) {
toast.error(errorMessage(err, "删除失败"));
throw err;
}
}}
/>
) : null}
<Button variant="outline" onClick={() => onOpenChange(false)}>
取消
</Button>
<Button onClick={save} disabled={pending || !!blocked} title={blocked ?? undefined}>
{pending ? "保存中…" : group ? "保存" : "新建"}
</Button>
</>
}
>
{conflict ? (
<div className="flex flex-wrap items-center gap-2 rounded-lg border border-warning/40 bg-warning/10 px-3 py-2 text-caption text-warning">
<span className="flex-1">{conflict}</span>
<Button size="sm" variant="outline" onClick={() => load(group)}>
<RotateCcw />
载入最新
</Button>
</div>
) : null}
{inDefault ? (
<p className="text-meta text-muted-foreground">在默认链里的组不能删除,先在“默认值”里移出。</p>
) : null}
<div className="grid gap-x-4 sm:grid-cols-2">
<FormField label="名称" htmlFor={`${uid}-name`} required error={nameError}>
<Input
id={`${uid}-name`}
value={form.name}
maxLength={80}
aria-invalid={!!nameError}
onChange={(e) => {
set("name", e.target.value);
setNameError(null);
}}
/>
</FormField>
<FormField label="类型" htmlFor={`${uid}-kind`} hint={RULE_KIND[form.kind].hint}>
<Select value={form.kind} onValueChange={(v) => set("kind", v as RuleGroupKind)}>
<SelectTrigger id={`${uid}-kind`} className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
{RuleGroupKind.options.map((k) => (
<SelectItem key={k} value={k}>
{RULE_KIND[k].label}
</SelectItem>
))}
</SelectContent>
</Select>
</FormField>
<FormField label="说明" htmlFor={`${uid}-desc`} className="sm:col-span-2">
<Input
id={`${uid}-desc`}
value={form.description}
maxLength={500}
onChange={(e) => set("description", e.target.value)}
/>
</FormField>
<FormField
label="作用范围"
hint={
group && (group.scope.entityIds?.length || group.scope.siteIds?.length || group.scope.subscriptionId)
? `另有:${scopeText({ ...group.scope, mediaTypes: undefined, mediaKinds: undefined } as RuleGroupScope)}(此处不改)`
: "不选 = 全部类型"
}
className="sm:col-span-2"
>
<ChipToggleGroup
label="作用范围"
options={SubscriptionMediaKind.options.map((k) => ({ value: k, label: MEDIA_KIND[k] }))}
value={form.mediaTypes}
onChange={(v) => set("mediaTypes", v)}
/>
</FormField>
<div className="flex items-center justify-between gap-3 py-1 sm:col-span-2">
<div className="min-w-0">
<p className="text-caption font-medium text-foreground">一层都不中时拒绝</p>
<p className="text-meta text-muted-foreground">
{form.ladderRequired ? "资源至少要命中一层" : "只影响排序,不中的资源 pri 为 0"}
</p>
</div>
<Switch
checked={form.ladderRequired}
onCheckedChange={(v) => set("ladderRequired", v)}
aria-label="一层都不中时拒绝"
/>
</div>
<div className="flex items-center justify-between gap-3 py-1 sm:col-span-2">
<div className="min-w-0">
<p className="text-caption font-medium text-foreground">启用</p>
<p className="text-meta text-muted-foreground">{SUPPRESS_NOTE}</p>
</div>
<Switch checked={form.enabled} onCheckedChange={(v) => set("enabled", v)} aria-label="启用" />
</div>
</div>
<DetailSection title="优先级阶梯">
<LadderEditor state={form.ladder} onChange={(s) => set("ladder", s)} />
</DetailSection>
<DetailSection title="硬排除">
<p className="mb-2 text-meta text-muted-foreground">任一条命中即拒绝,每条写一层条件。</p>
<ul className="space-y-2">
{form.excludes.map((e, i) => (
<li key={e.key} className="flex items-start gap-2">
<RuleStringField
single
className="flex-1"
ariaLabel={`第 ${i + 1} 条硬排除`}
state={e.state}
onChange={(s) =>
setForm((f) => ({
...f,
excludes: f.excludes.map((x) => (x.key === e.key ? { ...x, state: s } : x)),
}))
}
/>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`删除第 ${i + 1} 条硬排除`}
onClick={() => setForm((f) => ({ ...f, excludes: f.excludes.filter((x) => x.key !== e.key) }))}
>
<Trash2 />
</Button>
</li>
))}
</ul>
<Button
type="button"
variant="outline"
size="sm"
className="mt-2"
onClick={() =>
setForm((f) => ({ ...f, excludes: [...f.excludes, { key: ++excludeSeq, state: ladderFrom([]) }] }))
}
>
<Plus />
加硬排除
</Button>
</DetailSection>
<DetailSection title="试算">
{group ? (
<RulePreviewPanel
groupId={group.id}
blocked={blocked}
draft={{ layers: form.ladder.layers, hardExcludes: excludeStrings, ladderRequired: form.ladderRequired }}
/>
) : (
<p className="text-meta text-muted-foreground">保存后可以用最近的资源试算。</p>
)}
</DetailSection>
{group ? (
<DetailSection title="修订历史">
<Revisions id={group.id} />
</DetailSection>
) : null}
</DetailSheet>
);
}
const REV_ACTION: Record<string, string> = {
created: "新建",
updated: "修改",
deleted: "删除",
disabled: "停用",
enabled: "启用",
};
const FIELD_LABEL: Record<string, string> = {
name: "名称",
description: "说明",
kind: "类型",
scope: "作用范围",
layers: "阶梯",
hardExcludes: "硬排除",
ladderRequired: "一层不中拒绝",
enabled: "启用",
label: "名称",
rule: "规则",
status: "状态",
autoFill: "自动补全",
evidence: "证据",
};
function Revisions({ id }: { id: string }) {
const q = useRuleGroupRevisions(id);
if (q.isPending) return <LoadingState rows={2} />;
if (q.isError) return <ErrorState plain error={q.error} onRetry={() => q.refetch()} />;
if (q.data.length === 0) return <p className="text-meta text-muted-foreground">没有修订记录</p>;
return (
<ul className="space-y-2">
{q.data.slice(0, 20).map((r) => {
const action = r.action.split(".").pop() ?? r.action;
return (
<li key={r.id} className="rounded-lg border border-border/70 px-3 py-2 text-caption">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium text-foreground">{REV_ACTION[action] ?? action}</span>
{r.revision ? <span className="text-muted-foreground tabular-nums">r{r.revision}</span> : null}
<span className="text-muted-foreground">
{r.actorType === "agent" ? "Agent" : r.actorType === "api_key" ? "API Key" : "用户"}
</span>
<span className="ml-auto text-meta text-muted-foreground tabular-nums">{formatDateTime(r.at)}</span>
</div>
{r.changedFields.length ? (
<p className="mt-1 text-meta text-muted-foreground">
改动:{r.changedFields.map((f) => FIELD_LABEL[f] ?? f).join("、")}
</p>
) : null}
</li>
);
})}
</ul>
);
}
@@ -0,0 +1,260 @@
import type { RuleGroup } from "@umt/contracts";
import { ListChecks, ShieldOff, Tags, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { type RuleRefs, useDeleteRuleGroup, useUpdateRuleGroup } from "~/api/rules.ts";
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
import { type DataColumn, DataTable } from "~/components/console/data-table.tsx";
import { EmptyState } from "~/components/console/empty-state.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { Chip, Panel, relTime } from "~/components/library/parts.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Switch } from "~/components/ui/switch.tsx";
import { errorMessage } from "~/lib/api.ts";
import { cn } from "~/lib/utils.ts";
import { DEFAULT_CHAIN_LABEL, RULE_KIND, RULE_SOURCE, ruleSummary, SUPPRESS_NOTE, scopeText } from "./labels.ts";
// 规则组列表(表格)与单条规则的标签形态(原偏好标签)。停用/删除都写抑制记录。
function useToggle() {
const update = useUpdateRuleGroup();
return {
pending: update.isPending,
toggle: async (g: RuleGroup) => {
try {
await update.mutateAsync({ id: g.id, enabled: !g.enabled, expectedRevision: g.revision });
toast.success(g.enabled ? "已停用,Agent 不会自动加回" : "已启用");
} catch (err) {
toast.error(errorMessage(err));
}
},
};
}
function RefsCell({ g, refs }: { g: RuleGroup; refs: RuleRefs | undefined }) {
const subs = refs?.subscriptions.get(g.id) ?? [];
const slots = refs?.slots.get(g.id) ?? [];
const parts: string[] = [];
if (g.defaultOrder !== null) parts.push("默认链");
if (subs.length) parts.push(`${subs.length} 个订阅`);
if (slots.length) parts.push(`${slots.length} 个版本目标`);
if (!parts.length) return <span className="text-meta text-muted-foreground">{refs ? "未引用" : "—"}</span>;
const title = [...subs.map((s) => `订阅:${s.title}`), ...slots.map((s) => `版本目标:${s.name}`)].join("\n");
return (
<span className="text-meta text-foreground" title={title || undefined}>
{parts.join(" · ")}
</span>
);
}
export function RuleGroupTable({
rows,
loading,
error,
onRetry,
refs,
filtered,
onOpen,
onCreate,
}: {
rows: RuleGroup[] | undefined;
loading: boolean;
error: unknown;
onRetry: () => void;
refs: RuleRefs | undefined;
filtered: boolean;
onOpen: (g: RuleGroup) => void;
onCreate: () => void;
}) {
const { toggle, pending } = useToggle();
const columns: DataColumn<RuleGroup>[] = [
{
key: "name",
header: "名称",
cell: (g) => (
<div className="min-w-40">
<div className="flex flex-wrap items-center gap-1.5">
<span className={cn("font-medium text-foreground", !g.enabled && "text-muted-foreground line-through")}>
{g.name}
</span>
{g.defaultOrder !== null ? (
<StatusBadge tone="success">
{DEFAULT_CHAIN_LABEL[g.kind]} #{g.defaultOrder + 1}
</StatusBadge>
) : null}
</div>
{g.ruleString || g.hardExcludes.length ? (
<p className="mt-0.5 line-clamp-1 font-mono text-meta break-all text-muted-foreground">{ruleSummary(g)}</p>
) : g.description ? (
<p className="mt-0.5 line-clamp-1 text-meta text-muted-foreground">{g.description}</p>
) : null}
</div>
),
},
{ key: "kind", header: "类型", cell: (g) => RULE_KIND[g.kind].label, className: "whitespace-nowrap" },
{ key: "scope", header: "作用范围", cell: (g) => scopeText(g.scope), hideBelow: "md" },
{
key: "source",
header: "来源",
cell: (g) => RULE_SOURCE[g.source],
hideBelow: "sm",
className: "whitespace-nowrap",
},
{ key: "refs", header: "引用", cell: (g) => <RefsCell g={g} refs={refs} />, hideBelow: "lg" },
{
key: "rev",
header: "修订",
cell: (g) => (
<span className="text-meta text-muted-foreground tabular-nums" title={`更新于 ${relTime(g.updatedAt)}`}>
r{g.revision}
</span>
),
hideBelow: "md",
},
{
key: "enabled",
header: "启用",
align: "right",
cell: (g) => (
// biome-ignore lint/a11y/noStaticElementInteractions: 阻止行点击冒泡
// biome-ignore lint/a11y/useKeyWithClickEvents: 同上,开关本身可键盘操作
<span onClick={(e) => e.stopPropagation()}>
<Switch
checked={g.enabled}
disabled={pending}
onCheckedChange={() => toggle(g)}
aria-label={`${g.enabled ? "停用" : "启用"}「${g.name}」`}
/>
</span>
),
},
];
return (
<DataTable
columns={columns}
rows={rows}
rowKey={(g) => g.id}
loading={loading}
error={error}
onRetry={onRetry}
onRowClick={onOpen}
empty={{
icon: ListChecks,
title: filtered ? "没有符合条件的规则组" : "还没有规则组",
description: filtered ? undefined : "新建一个规则组,或在 Agent 会话里说明长期偏好。",
action: filtered ? undefined : (
<Button size="sm" onClick={onCreate}>
新建规则组
</Button>
),
}}
/>
);
}
/** 单条规则的标签:点开进编辑;停用/删除直接在卡片上做 */
function RuleTag({ g, onOpen }: { g: RuleGroup; onOpen: () => void }) {
const { toggle, pending } = useToggle();
const del = useDeleteRuleGroup();
return (
<li
className={cn(
"flex min-w-0 flex-col rounded-xl border border-border/80 bg-foreground/[0.02] transition-colors duration-micro hover:bg-foreground/[0.04]",
!g.enabled && "opacity-75",
)}
>
<button
type="button"
onClick={onOpen}
className="min-w-0 flex-1 cursor-pointer rounded-t-xl px-3 pt-3 pb-2 text-left outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
<div className="flex items-start justify-between gap-2">
<span className={cn("min-w-0 font-medium break-words text-foreground", !g.enabled && "line-through")}>
{g.name}
</span>
{g.enabled ? null : (
<StatusBadge tone={g.suppressedAt ? "warning" : "neutral"}>
{g.suppressedAt ? "已抑制" : "已停用"}
</StatusBadge>
)}
</div>
<p className="mt-1.5 line-clamp-2 font-mono text-meta break-all text-muted-foreground">{ruleSummary(g)}</p>
<div className="mt-2 flex flex-wrap gap-1.5">
<Chip>{RULE_SOURCE[g.source]}</Chip>
<Chip>{RULE_KIND[g.kind].label}</Chip>
<Chip>{scopeText(g.scope)}</Chip>
</div>
</button>
<div className="flex items-center gap-1 border-t border-border/60 px-2 py-1">
<span className="px-1 text-micro text-muted-foreground tabular-nums">
r{g.revision} · {relTime(g.updatedAt)}
</span>
<span className="ml-auto flex items-center gap-1">
{g.enabled ? (
<ConfirmDialog
trigger={
<Button variant="ghost" size="xs" disabled={pending}>
<ShieldOff />
停用
</Button>
}
title={`停用「${g.name}」?`}
description={SUPPRESS_NOTE}
confirmLabel="停用"
onConfirm={() => toggle(g)}
/>
) : (
<Button variant="ghost" size="xs" disabled={pending} onClick={() => toggle(g)}>
启用
</Button>
)}
<ConfirmDialog
trigger={
<Button variant="ghost" size="xs" className="hover:text-destructive" aria-label={`删除「${g.name}」`}>
<Trash2 />
</Button>
}
title={`删除「${g.name}」?`}
description={`${SUPPRESS_NOTE}被版本目标或订阅引用时不能删除。`}
confirmLabel="删除"
destructive
onConfirm={async () => {
try {
await del.mutateAsync(g.id);
toast.success("已删除,Agent 不会自动加回");
} catch (err) {
toast.error(errorMessage(err, "删除失败"));
throw err;
}
}}
/>
</span>
</div>
</li>
);
}
export function RuleTags({ rows, onOpen }: { rows: RuleGroup[]; onOpen: (g: RuleGroup) => void }) {
return (
<Panel
title="偏好"
description="单条规则,多由对话或 Agent 提出。停用或删除后 Agent 不会自动加回。"
actions={<span className="text-meta text-muted-foreground tabular-nums">{rows.length} 条</span>}
>
{rows.length === 0 ? (
<EmptyState
plain
icon={Tags}
title="还没有偏好"
description="在 Agent 会话里说明长期偏好,会出现在这里。"
className="py-6"
/>
) : (
<ul className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{rows.map((g) => (
<RuleTag key={g.id} g={g} onOpen={() => onOpen(g)} />
))}
</ul>
)}
</Panel>
);
}
+63
View File
@@ -0,0 +1,63 @@
import type { RuleGroup, RuleGroupKind, RuleGroupScope, RuleGroupSource } from "@umt/contracts";
import { MEDIA_KIND } from "~/components/library/labels.ts";
import { exprToString } from "./expr.ts";
// 规则页文案:类型 / 来源 / 作用范围 / 默认链 / 抑制原因
export const RULE_KIND: Record<RuleGroupKind, { label: string; hint: string }> = {
acquisition: { label: "订阅", hint: "订阅与版本目标挑资源时用" },
upgrade: { label: "洗版", hint: "版本目标开了升级时额外叠加" },
search: { label: "搜索", hint: "手动搜索与 Agent 搜索资源时用" },
};
export const SUPPRESS_NOTE = "停用或删除会留下抑制记录,Agent 不会自动加回;重新启用或手动新建同样的规则后解除。";
export const RULE_SOURCE: Record<RuleGroupSource, string> = {
manual: "手动",
chat: "对话",
agent: "Agent",
};
export const DEFAULT_CHAIN_LABEL: Record<RuleGroupKind, string> = {
acquisition: "订阅默认",
upgrade: "洗版默认",
search: "搜索默认",
};
export const SUPPRESS_REASON: Record<string, string> = {
deleted: "删除",
disabled: "停用",
"status:disabled": "停用",
"status:deleted": "删除",
edited: "修改语义",
};
export const suppressReason = (r: string | null) => (r ? (SUPPRESS_REASON[r] ?? r) : "未注明");
/** 作用范围摘要:空 = 全部;旧数据里的 mediaKinds 同 mediaTypes */
export function scopeText(scope: RuleGroupScope): string {
const s = scope as RuleGroupScope & { mediaKinds?: string[] };
const parts: string[] = [];
const kinds = s.mediaTypes ?? s.mediaKinds;
if (kinds?.length) parts.push(kinds.map((k) => MEDIA_KIND[k as keyof typeof MEDIA_KIND] ?? k).join("、"));
if (s.entityIds?.length) parts.push(`${s.entityIds.length} 个作品`);
if (s.siteIds?.length) parts.push(`${s.siteIds.length} 个站点`);
if (s.subscriptionId) parts.push("订阅私有");
return parts.length ? parts.join(" · ") : "全部";
}
/** 单条规则(偏好标签形态):不在默认链、不是订阅私有,层 + 硬排除合计一条;旧偏好标签迁来的都算 */
export function isTagLike(g: RuleGroup): boolean {
if (g.defaultOrder !== null || g.scope.subscriptionId) return false;
if (g.legacyKind) return true;
return g.layers.length + g.hardExcludes.length === 1;
}
/** 标签上的一句规则摘要 */
export function ruleSummary(g: RuleGroup): string {
const parts: string[] = [];
if (g.layers.length)
parts.push(`${g.ladderRequired ? "要求" : "偏好"} ${g.layers.map((l) => exprToString(l.expr)).join(" > ")}`);
for (const h of g.hardExcludes) parts.push(`排除 ${exprToString(h)}`);
if (parts.length) return parts.join(";");
return g.legacyKind ? "旧偏好标签,没有可评估的条件" : "没有条件";
}
@@ -0,0 +1,578 @@
import type { RuleKeyword, RuleLayer, RuleParseError } from "@umt/contracts";
import { ArrowDown, ArrowUp, Brackets, LoaderCircle, Plus, Search, Trash2, X } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { parseRuleString, useRuleKeywords } from "~/api/rules.ts";
import { Button } from "~/components/ui/button.tsx";
import { Input } from "~/components/ui/input.tsx";
import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover.tsx";
import { errorMessage } from "~/lib/api.ts";
import { cn } from "~/lib/utils.ts";
import {
type AtomItem,
cardsOf,
cardToLayer,
type LayerCard,
type LayerItem,
layersToString,
OP_LABEL,
type Op,
} from "./expr.ts";
// 优先级阶梯编辑器:规则串输入与层卡片双向同步。
// 改卡片 → 本地规范化出规则串;改规则串 → 防抖调 /rules/parse,无错误时画回卡片,有错误逐条给位置与相近名。
const BASIS_LABEL: Record<RuleKeyword["basis"], string> = {
field: "解析字段",
regex: "正则",
site: "站点信息",
custom: "自定义条件",
};
export type LadderState = {
/** 输入框里的规则串(可能有错) */
text: string;
/** 最近一次解析成功的层 */
layers: RuleLayer[];
/** null = 嵌套太深,卡片画不出来,只能改规则串 */
cards: LayerCard[] | null;
errors: RuleParseError[];
parsing: boolean;
/** 解析请求本身失败 */
failure: string | null;
};
export function ladderFrom(layers: RuleLayer[]): LadderState {
return { text: layersToString(layers), layers, cards: cardsOf(layers), errors: [], parsing: false, failure: null };
}
/** 保存用:有错误 / 解析中 / 有空层时不可保存 */
export function ladderProblem(s: LadderState): string | null {
if (s.parsing) return "规则串还在解析";
if (s.failure) return s.failure;
if (s.errors.length) return "规则串有错误";
if (s.cards?.some((c) => c.items.length === 0)) return "有空层";
return null;
}
// ─── 关键字选择 ───
function useKeywordMap() {
const q = useRuleKeywords();
const map = useMemo(() => new Map((q.data ?? []).map((k) => [k.id.toUpperCase(), k])), [q.data]);
return { list: q.data ?? [], map, loading: q.isPending };
}
export function KeywordPicker({
onPick,
label = "条件",
size = "xs",
}: {
onPick: (id: string) => void;
label?: string;
size?: "xs" | "sm";
}) {
const { list, loading } = useKeywordMap();
const [open, setOpen] = useState(false);
const [q, setQ] = useState("");
const shown = useMemo(() => {
const t = q.trim().toLowerCase();
return list.filter(
(k) => !t || k.id.toLowerCase().includes(t) || k.name.toLowerCase().includes(t) || k.description.includes(t),
);
}, [list, q]);
const groups = useMemo(() => {
const g = new Map<string, RuleKeyword[]>();
for (const k of shown) {
const key = k.builtin ? BASIS_LABEL[k.basis] : "自定义条件";
g.set(key, [...(g.get(key) ?? []), k]);
}
return [...g.entries()];
}, [shown]);
return (
<Popover
open={open}
onOpenChange={(o) => {
setOpen(o);
if (!o) setQ("");
}}
>
<PopoverTrigger asChild>
<Button type="button" variant="ghost" size={size}>
<Plus />
{label}
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-80 p-2">
<div className="relative mb-2">
<Search className="pointer-events-none absolute top-1/2 left-2.5 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
autoFocus
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="搜索关键字"
aria-label="搜索关键字"
className="h-8 pl-8 text-caption"
/>
</div>
<div className="max-h-72 space-y-2 overflow-y-auto pr-1">
{loading ? <p className="px-2 py-3 text-meta text-muted-foreground">加载中…</p> : null}
{!loading && shown.length === 0 ? (
<p className="px-2 py-3 text-meta text-muted-foreground">没有匹配的关键字</p>
) : null}
{groups.map(([title, items]) => (
<div key={title}>
<p className="px-2 pb-1 text-micro font-semibold tracking-wide text-muted-foreground">{title}</p>
{items.map((k) => (
<button
key={k.id}
type="button"
disabled={!k.supported}
onClick={() => {
onPick(k.id.toUpperCase());
setOpen(false);
setQ("");
}}
className="flex w-full cursor-pointer items-baseline gap-2 rounded-lg px-2 py-1.5 text-left transition-colors duration-micro hover:bg-foreground/[0.06] disabled:cursor-not-allowed disabled:opacity-50"
>
<span className="shrink-0 font-mono text-caption font-medium text-foreground">{k.id}</span>
<span className="min-w-0 truncate text-meta text-muted-foreground">
{k.supported ? k.description || k.name : "不支持"}
</span>
</button>
))}
</div>
))}
</div>
</PopoverContent>
</Popover>
);
}
// ─── 规则串输入 ───
/** 防抖解析规则串;single = 硬排除行,不允许多层 */
export function useRuleTextParser(
state: LadderState,
onChange: (s: LadderState) => void,
{ single = false }: { single?: boolean } = {},
) {
const seq = useRef(0);
const latest = useRef(state);
latest.current = state;
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
const [pendingText, setPendingText] = useState<string | null>(null);
useEffect(() => {
if (pendingText === null) return;
const text = pendingText;
const id = ++seq.current;
const timer = setTimeout(async () => {
if (!text.trim()) {
onChangeRef.current({ ...latest.current, layers: [], cards: [], errors: [], parsing: false, failure: null });
return;
}
try {
const r = await parseRuleString(text);
if (id !== seq.current) return;
const errors = [...r.errors];
if (single && r.layers.length > 1)
errors.push({
code: "unexpected_token",
message: "硬排除只能写一层,不能用 >",
position: text.indexOf(">"),
layer: 1,
});
onChangeRef.current({
...latest.current,
layers: errors.length ? latest.current.layers : r.layers,
cards: errors.length ? latest.current.cards : cardsOf(r.layers),
errors,
parsing: false,
failure: null,
});
} catch (err) {
if (id !== seq.current) return;
onChangeRef.current({ ...latest.current, parsing: false, failure: errorMessage(err, "规则串解析失败") });
}
}, 300);
return () => clearTimeout(timer);
}, [pendingText, single]);
return (text: string) => {
onChangeRef.current({ ...latest.current, text, parsing: true });
setPendingText(text);
};
}
/** 规则串输入框 + 逐条错误(位置指示、相近名一键替换) */
export function RuleStringField({
state,
onChange,
single = false,
placeholder,
ariaLabel,
className,
}: {
state: LadderState;
onChange: (s: LadderState) => void;
single?: boolean;
placeholder?: string;
ariaLabel: string;
className?: string;
}) {
const setText = useRuleTextParser(state, onChange, { single });
const replaceAt = (e: RuleParseError, s: string) => {
if (!e.atom) return;
const at = state.text.toUpperCase().indexOf(e.atom.toUpperCase(), Math.max(0, e.position - 1));
const pos = at >= 0 ? at : e.position;
setText(state.text.slice(0, pos) + s + state.text.slice(pos + e.atom.length));
};
return (
<div className={cn("min-w-0", className)}>
<div className="relative">
<Input
value={state.text}
onChange={(e) => setText(e.target.value)}
placeholder={placeholder ?? (single ? "REMUX | BLU" : "SPECSUB & CNSUB > 4K > 1080P")}
aria-label={ariaLabel}
aria-invalid={state.errors.length > 0 || undefined}
spellCheck={false}
autoCapitalize="characters"
className="pr-8 font-mono text-caption"
/>
{state.parsing ? (
<LoaderCircle className="absolute top-1/2 right-2.5 h-3.5 w-3.5 -translate-y-1/2 animate-spin text-muted-foreground" />
) : null}
</div>
{state.failure ? <p className="mt-1 text-meta text-destructive">{state.failure}</p> : null}
{state.errors.length ? (
<ul className="mt-1.5 space-y-1" aria-live="polite">
{state.errors.map((e, i) => (
<li key={`${e.code}-${e.position}-${i}`} className="text-meta text-destructive">
<ErrorCaret text={state.text} position={e.position} />
<span>
第 {e.position + 1} 个字符{single ? "" : `(第 ${e.layer + 1} 层)`}:{e.message}
</span>
{e.suggestions?.length ? (
<span className="ml-1 inline-flex flex-wrap gap-1 align-middle">
{e.suggestions.map((s) => (
<button
key={s}
type="button"
onClick={() => replaceAt(e, s)}
className="cursor-pointer rounded-full border border-border px-1.5 font-mono text-micro text-foreground hover:bg-foreground/[0.06]"
>
改成 {s}
</button>
))}
</span>
) : null}
</li>
))}
</ul>
) : null}
</div>
);
}
/** 出错位置:规则串原文 + 在出错字符下画标记 */
function ErrorCaret({ text, position }: { text: string; position: number }) {
if (position < 0 || !text) return null;
const p = Math.min(position, text.length);
const start = Math.max(0, p - 24);
const end = Math.min(text.length, p + 24);
return (
<span className="mb-0.5 block overflow-x-auto font-mono text-meta whitespace-pre text-muted-foreground">
{start > 0 ? "…" : ""}
{text.slice(start, p)}
<mark className="rounded-sm bg-destructive/15 text-destructive underline decoration-destructive underline-offset-2">
{text.slice(p, p + 1) || " "}
</mark>
{text.slice(p + 1, end)}
{end < text.length ? "…" : ""}
</span>
);
}
// ─── 层卡片 ───
function OpToggle({ op, onChange, label }: { op: Op; onChange: (op: Op) => void; label: string }) {
return (
<fieldset aria-label={label} className="m-0 inline-flex rounded-full border border-border p-0.5">
{(["and", "or"] as const).map((o) => (
<button
key={o}
type="button"
aria-pressed={op === o}
onClick={() => onChange(o)}
className={cn(
"h-6 cursor-pointer rounded-full px-2 text-meta font-medium transition-colors duration-micro",
op === o ? "pill-selected text-foreground" : "text-muted-foreground hover:text-foreground",
)}
>
{OP_LABEL[o]}
</button>
))}
</fieldset>
);
}
function AtomChip({
item,
keyword,
onToggleNeg,
onRemove,
}: {
item: AtomItem;
keyword: RuleKeyword | undefined;
onToggleNeg: () => void;
onRemove: () => void;
}) {
const unknown = !keyword;
return (
<span
className={cn(
"inline-flex h-7 max-w-full items-center rounded-full border text-meta",
item.neg ? "border-destructive/35 bg-destructive/10 text-destructive" : "border-border bg-foreground/[0.03]",
unknown && "border-warning/50",
)}
title={keyword ? `${keyword.name}:${keyword.description}` : "未知条件"}
>
<button
type="button"
onClick={onToggleNeg}
className="flex h-full cursor-pointer items-center gap-1 rounded-l-full pr-1 pl-2.5 font-mono font-medium"
aria-label={`${item.neg ? "取消取反" : "取反"} ${item.id}`}
>
{item.neg ? <span aria-hidden>非</span> : null}
{item.id}
</button>
<button
type="button"
onClick={onRemove}
aria-label={`移除 ${item.id}`}
className="flex h-full cursor-pointer items-center rounded-r-full pr-2 pl-0.5 opacity-60 hover:opacity-100"
>
<X className="h-3 w-3" />
</button>
</span>
);
}
function Joiner({ op }: { op: Op }) {
return <span className="px-0.5 text-meta text-muted-foreground">{OP_LABEL[op]}</span>;
}
function LayerCardView({
card,
index,
total,
keywords,
onChange,
onMove,
onRemove,
}: {
card: LayerCard;
index: number;
total: number;
keywords: Map<string, RuleKeyword>;
onChange: (c: LayerCard) => void;
onMove: (dir: -1 | 1) => void;
onRemove: () => void;
}) {
const setItem = (i: number, it: LayerItem | null) =>
onChange({
...card,
items: it ? card.items.map((x, j) => (j === i ? it : x)) : card.items.filter((_, j) => j !== i),
});
return (
<li className="rounded-xl border border-border/80 bg-foreground/[0.02] p-3">
<div className="flex flex-wrap items-center gap-2">
<span className="text-caption font-medium text-foreground tabular-nums">
优先级 {index + 1} · pri {100 - index}
</span>
<Input
value={card.name ?? ""}
onChange={(e) => onChange({ ...card, name: e.target.value || undefined })}
placeholder="层名称(可选)"
aria-label={`第 ${index + 1} 层名称`}
maxLength={40}
className="h-7 w-36 text-meta"
/>
{card.items.length > 1 ? (
<OpToggle op={card.op} onChange={(op) => onChange({ ...card, op })} label={`第 ${index + 1} 层条件关系`} />
) : null}
<span className="ml-auto flex items-center gap-0.5">
<Button
type="button"
variant="ghost"
size="icon-xs"
disabled={index === 0}
onClick={() => onMove(-1)}
aria-label="上移一层"
>
<ArrowUp />
</Button>
<Button
type="button"
variant="ghost"
size="icon-xs"
disabled={index === total - 1}
onClick={() => onMove(1)}
aria-label="下移一层"
>
<ArrowDown />
</Button>
<Button
type="button"
variant="ghost"
size="icon-xs"
onClick={onRemove}
aria-label="删除这一层"
className="hover:text-destructive"
>
<Trash2 />
</Button>
</span>
</div>
<div className="mt-2 flex min-w-0 flex-wrap items-center gap-1.5">
{card.items.length === 0 ? <span className="text-meta text-warning">这一层还没有条件</span> : null}
{card.items.map((it, i) => (
<span key={i} className="inline-flex max-w-full flex-wrap items-center gap-1.5">
{i > 0 ? <Joiner op={card.op} /> : null}
{it.kind === "atom" ? (
<AtomChip
item={it}
keyword={keywords.get(it.id)}
onToggleNeg={() => setItem(i, { ...it, neg: !it.neg })}
onRemove={() => setItem(i, null)}
/>
) : (
<span
className={cn(
"inline-flex max-w-full flex-wrap items-center gap-1 rounded-xl border border-dashed px-1.5 py-1",
it.neg ? "border-destructive/40" : "border-border",
)}
>
<button
type="button"
onClick={() => setItem(i, { ...it, neg: !it.neg })}
className={cn(
"cursor-pointer rounded-full px-1 font-mono text-meta",
it.neg ? "text-destructive" : "text-muted-foreground hover:text-foreground",
)}
aria-label={it.neg ? "取消整组取反" : "整组取反"}
>
{it.neg ? "非(" : "("}
</button>
{it.items.map((a, k) => (
<span key={k} className="inline-flex items-center gap-1">
{k > 0 ? <Joiner op={it.op} /> : null}
<AtomChip
item={a}
keyword={keywords.get(a.id)}
onToggleNeg={() =>
setItem(i, { ...it, items: it.items.map((x, j) => (j === k ? { ...x, neg: !x.neg } : x)) })
}
onRemove={() => {
const rest = it.items.filter((_, j) => j !== k);
setItem(i, rest.length ? { ...it, items: rest } : null);
}}
/>
</span>
))}
<span className="font-mono text-meta text-muted-foreground">)</span>
{it.items.length > 1 ? (
<OpToggle op={it.op} onChange={(op) => setItem(i, { ...it, op })} label="括号组内条件关系" />
) : null}
<KeywordPicker
label=""
onPick={(id) => setItem(i, { ...it, items: [...it.items, { kind: "atom", id, neg: false }] })}
/>
</span>
)}
</span>
))}
<KeywordPicker
onPick={(id) => onChange({ ...card, items: [...card.items, { kind: "atom", id, neg: false }] })}
/>
<Button
type="button"
variant="ghost"
size="xs"
onClick={() =>
onChange({
...card,
items: [...card.items, { kind: "group", op: card.op === "and" ? "or" : "and", neg: false, items: [] }],
})
}
>
<Brackets />
括号组
</Button>
</div>
</li>
);
}
/** 阶梯编辑:上面规则串,下面层卡片;任一处改动同步到另一处 */
export function LadderEditor({ state, onChange }: { state: LadderState; onChange: (s: LadderState) => void }) {
const { map } = useKeywordMap();
const setCards = (cards: LayerCard[]) => {
const layers = cards.map(cardToLayer).filter((l): l is RuleLayer => !!l);
onChange({ text: layersToString(layers), layers, cards, errors: [], parsing: false, failure: null });
};
return (
<div className="space-y-3">
<RuleStringField state={state} onChange={onChange} ariaLabel="规则串" />
{state.errors.length ? (
<p className="text-meta text-muted-foreground">规则串改对后同步到下面的层;现在显示的是上次解析成功的结果。</p>
) : null}
{state.cards === null ? (
<p className="rounded-lg border border-border/70 px-3 py-2 text-meta text-muted-foreground">
嵌套超过两层,只能在规则串里改。
</p>
) : (
<>
{state.cards.length ? (
<ol className="space-y-2">
{state.cards.map((c, i) => (
<LayerCardView
key={i}
card={c}
index={i}
total={state.cards?.length ?? 0}
keywords={map}
onChange={(next) => setCards((state.cards ?? []).map((x, j) => (j === i ? next : x)))}
onMove={(dir) => {
const arr = [...(state.cards ?? [])];
const [moved] = arr.splice(i, 1);
if (moved) arr.splice(i + dir, 0, moved);
setCards(arr);
}}
onRemove={() => setCards((state.cards ?? []).filter((_, j) => j !== i))}
/>
))}
</ol>
) : state.errors.length ? null : (
<p className="text-meta text-muted-foreground">没有层:只用硬排除过滤,不影响排序。</p>
)}
<Button
type="button"
variant="outline"
size="sm"
disabled={state.errors.length > 0 || state.parsing}
onClick={() => setCards([...(state.cards ?? []), { op: "and", items: [] }])}
>
<Plus />
加一层
</Button>
</>
)}
</div>
);
}
@@ -0,0 +1,177 @@
import type { RuleLayer, RulePreviewResult } from "@umt/contracts";
import { FlaskConical } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { usePreviewRuleGroup } from "~/api/rules.ts";
import { FilterSelect } from "~/components/console/filter-bar.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Textarea } from "~/components/ui/textarea.tsx";
import { errorMessage } from "~/lib/api.ts";
import { cn } from "~/lib/utils.ts";
// 试算:对最近 N 条已识别资源(或粘贴的标题)跑当前编辑中的规则组,给 pri / 命中层 / 拒绝原因与逐层依据。
const REASON: Record<string, string> = {
hard_exclude: "命中硬排除",
"rule.hard_exclude": "命中硬排除",
no_layer_matched: "一层都没命中",
"rule.no_layer_matched": "一层都没命中",
};
const reasonText = (r: string) => REASON[r] ?? r;
export type PreviewDraft = { layers: RuleLayer[]; hardExcludes: string[]; ladderRequired: boolean };
export function RulePreviewPanel({
groupId,
draft,
blocked,
}: {
groupId: string;
draft: PreviewDraft;
blocked: string | null;
}) {
const preview = usePreviewRuleGroup();
const [limit, setLimit] = useState("");
const [titles, setTitles] = useState("");
const [pasting, setPasting] = useState(false);
const [result, setResult] = useState<RulePreviewResult | null>(null);
const run = () => {
const list = titles
.split("\n")
.map((t) => t.trim())
.filter(Boolean)
.slice(0, 50)
.map((title) => ({ title }));
preview.mutate(
{
id: groupId,
...(pasting && list.length ? { titles: list } : { limit: Number(limit || 50) }),
draft: { layers: draft.layers, hardExcludes: draft.hardExcludes, ladderRequired: draft.ladderRequired },
},
{ onSuccess: setResult, onError: (e) => toast.error(errorMessage(e, "试算失败")) },
);
};
return (
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
{pasting ? null : (
<FilterSelect
label="样本"
value={limit}
onChange={setLimit}
allLabel="最近 50 条"
options={[
{ value: "100", label: "最近 100 条" },
{ value: "200", label: "最近 200 条" },
]}
/>
)}
<Button type="button" variant="ghost" size="sm" onClick={() => setPasting((v) => !v)}>
{pasting ? "用最近资源" : "粘贴标题"}
</Button>
<Button
type="button"
size="sm"
className="ml-auto"
onClick={run}
disabled={preview.isPending || !!blocked}
title={blocked ?? undefined}
>
<FlaskConical />
{preview.isPending ? "试算中…" : "试算"}
</Button>
</div>
{pasting ? (
<Textarea
value={titles}
onChange={(e) => setTitles(e.target.value)}
placeholder={"每行一个资源标题,最多 50 行\nDune.Part.Two.2024.2160p.UHD.BluRay.REMUX.HDR.DV-FraMeSToR"}
aria-label="资源标题"
rows={4}
className="font-mono text-meta"
/>
) : null}
{blocked ? <p className="text-meta text-muted-foreground">{blocked},改好后再试算。</p> : null}
{result ? <PreviewResult result={result} /> : null}
</div>
);
}
function PreviewResult({ result }: { result: RulePreviewResult }) {
const hist = [...result.priHistogram].sort((a, b) => (b.pri ?? -1) - (a.pri ?? -1));
return (
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2 text-caption tabular-nums">
<span className="text-muted-foreground">样本 {result.sampleSize} 条</span>
<StatusBadge tone="success">通过 {result.passed}</StatusBadge>
<StatusBadge tone={result.rejected ? "destructive" : "neutral"}>拒绝 {result.rejected}</StatusBadge>
{hist.map((h) => (
<span key={String(h.pri)} className="text-meta text-muted-foreground">
{h.pri === null ? "拒绝" : `pri ${h.pri}`} × {h.count}
</span>
))}
</div>
{result.items.length === 0 ? (
<p className="text-meta text-muted-foreground">没有可试算的资源。站点抓到并识别过资源后再试。</p>
) : (
<div className="overflow-x-auto rounded-lg border border-border/70">
<table className="w-full text-left text-caption">
<thead>
<tr className="border-b border-border text-meta text-muted-foreground">
<th className="px-3 py-2 font-medium">资源</th>
<th className="px-3 py-2 font-medium whitespace-nowrap">结果</th>
<th className="hidden px-3 py-2 font-medium sm:table-cell">原因</th>
</tr>
</thead>
<tbody>
{result.items.map((it, i) => (
<tr
key={`${it.sourceReleaseId ?? it.title}-${i}`}
className="border-b border-border/60 align-top last:border-b-0"
>
<td className="max-w-0 px-3 py-2">
<p className="break-all text-foreground">{it.title}</p>
{it.subtitle ? <p className="line-clamp-1 text-meta text-muted-foreground">{it.subtitle}</p> : null}
{it.trace.length ? (
<details className="mt-1">
<summary className="cursor-pointer text-meta text-muted-foreground hover:text-foreground">
依据
</summary>
<ul className="mt-1 space-y-0.5">
{it.trace.map((t, k) => (
<li key={k} className="text-meta break-words text-muted-foreground">
{t}
</li>
))}
</ul>
</details>
) : null}
<p className="mt-1 text-meta text-muted-foreground sm:hidden">
{[...it.excludedBy.map((x) => `排除 ${x}`), ...it.reasons.map(reasonText)].join(";")}
</p>
</td>
<td className="px-3 py-2 whitespace-nowrap">
{it.passed ? (
<StatusBadge tone="success">
pri {it.pri}
{it.level !== null ? ` · 第 ${it.level + 1} 层` : ""}
</StatusBadge>
) : (
<StatusBadge tone="destructive">拒绝</StatusBadge>
)}
</td>
<td className={cn("hidden px-3 py-2 text-meta text-muted-foreground sm:table-cell")}>
{[...it.excludedBy.map((x) => `排除 ${x}`), ...it.reasons.map(reasonText)].join(";") || "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
+573
View File
@@ -0,0 +1,573 @@
import type { RuleAtom, RuleAtomCreate, RuleDefaults, RuleGroup, RuleGroupKind } from "@umt/contracts";
import { ArrowDown, ArrowUp, Ban, FlaskConical, Plus, Save, Trash2, X } from "lucide-react";
import { useEffect, useId, useMemo, useState } from "react";
import { toast } from "sonner";
import {
useCreateRuleAtom,
useDeleteRuleAtom,
useLiftRuleSuppression,
useRuleAtoms,
useRuleDefaults,
useRuleGroups,
useRuleKeywords,
useRuleSuppressions,
useSaveRuleDefaults,
useUpdateRuleAtom,
} from "~/api/rules.ts";
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
import { type DataColumn, DataTable } from "~/components/console/data-table.tsx";
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { FilterSelect } from "~/components/console/filter-bar.tsx";
import { FormField } from "~/components/console/form-field.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { Chip, ChipToggleGroup, Panel } from "~/components/library/parts.tsx";
import { Button } from "~/components/ui/button.tsx";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog.tsx";
import { Input } from "~/components/ui/input.tsx";
import { Switch } from "~/components/ui/switch.tsx";
import { Textarea } from "~/components/ui/textarea.tsx";
import { errorMessage } from "~/lib/api.ts";
import { formatDateTime } from "~/lib/utils.ts";
import { DEFAULT_CHAIN_LABEL, RULE_KIND, suppressReason } from "./labels.ts";
// 规则页的其余 Tab:自定义条件、默认值、抑制记录
// ─── 自定义条件 ───
const MATCH_FIELD = { title: "标题", subtitle: "副标题", labels: "标签" } as const;
type MatchField = keyof typeof MATCH_FIELD;
type AtomForm = {
key: string;
name: string;
description: string;
include: string;
exclude: string;
matchFields: MatchField[];
sizeMin: string;
sizeMax: string;
minSeeders: string;
delayMin: string;
delayMax: string;
free: boolean;
};
const lines = (s: string) =>
s
.split("\n")
.map((x) => x.trim())
.filter(Boolean);
const num = (s: string) => (s.trim() === "" ? undefined : Number(s));
const range = (min: string, max: string) =>
num(min) === undefined && num(max) === undefined ? null : { min: num(min), max: num(max) };
function atomForm(a: RuleAtom | null): AtomForm {
return {
key: a?.key ?? "",
name: a?.name ?? "",
description: a?.description ?? "",
include: (a?.include ?? []).join("\n"),
exclude: (a?.exclude ?? []).join("\n"),
matchFields: a?.matchFields ?? [],
sizeMin: a?.unitSizeMb?.min?.toString() ?? "",
sizeMax: a?.unitSizeMb?.max?.toString() ?? "",
minSeeders: a?.minSeeders?.toString() ?? "",
delayMin: a?.publishDelayMinutes?.min?.toString() ?? "",
delayMax: a?.publishDelayMinutes?.max?.toString() ?? "",
free: a?.free ?? false,
};
}
function AtomDialog({
open,
atom,
onOpenChange,
}: {
open: boolean;
atom: RuleAtom | null;
onOpenChange: (o: boolean) => void;
}) {
const uid = useId();
const create = useCreateRuleAtom();
const update = useUpdateRuleAtom();
const [f, setF] = useState<AtomForm>(() => atomForm(atom));
const [error, setError] = useState<string | null>(null);
// biome-ignore lint/correctness/useExhaustiveDependencies: 打开时按当前条件重置
useEffect(() => {
if (open) {
setF(atomForm(atom));
setError(null);
}
}, [open, atom?.id]);
const set = <K extends keyof AtomForm>(k: K, v: AtomForm[K]) => setF((x) => ({ ...x, [k]: v }));
const submit = async () => {
const body: RuleAtomCreate = {
key: f.key.trim().toUpperCase(),
name: f.name.trim() || null,
description: f.description.trim() || null,
include: lines(f.include),
exclude: lines(f.exclude),
matchFields: f.matchFields,
unitSizeMb: range(f.sizeMin, f.sizeMax),
minSeeders: num(f.minSeeders) ?? null,
publishDelayMinutes: range(f.delayMin, f.delayMax),
free: f.free,
};
try {
if (atom) {
const { key: _k, ...rest } = body;
await update.mutateAsync({ id: atom.id, ...rest });
} else await create.mutateAsync(body);
toast.success(atom ? "已保存" : `已新建条件 ${body.key}`);
onOpenChange(false);
} catch (err) {
setError(errorMessage(err, "保存失败"));
}
};
const pending = create.isPending || update.isPending;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-xl">
<DialogHeader>
<DialogTitle>{atom ? `编辑条件 ${atom.key}` : "新建自定义条件"}</DialogTitle>
<DialogDescription>字段同 MoviePilot 自定义规则。正则不区分大小写,多条之间是“或”。</DialogDescription>
</DialogHeader>
<div className="grid gap-x-4 sm:grid-cols-2">
<FormField label="关键字" htmlFor={`${uid}-key`} required hint="大写字母和数字;不能与内置关键字同名">
<Input
id={`${uid}-key`}
value={f.key}
disabled={!!atom}
maxLength={32}
onChange={(e) => set("key", e.target.value.toUpperCase())}
className="font-mono"
/>
</FormField>
<FormField label="名称" htmlFor={`${uid}-name`}>
<Input id={`${uid}-name`} value={f.name} onChange={(e) => set("name", e.target.value)} />
</FormField>
<FormField label="说明" htmlFor={`${uid}-desc`} className="sm:col-span-2">
<Input id={`${uid}-desc`} value={f.description} onChange={(e) => set("description", e.target.value)} />
</FormField>
<FormField label="包含(正则,每行一条)" htmlFor={`${uid}-inc`}>
<Textarea
id={`${uid}-inc`}
rows={3}
value={f.include}
onChange={(e) => set("include", e.target.value)}
className="font-mono text-meta"
/>
</FormField>
<FormField label="排除(正则,每行一条)" htmlFor={`${uid}-exc`}>
<Textarea
id={`${uid}-exc`}
rows={3}
value={f.exclude}
onChange={(e) => set("exclude", e.target.value)}
className="font-mono text-meta"
/>
</FormField>
<FormField label="匹配字段" hint="不选 = 全部" className="sm:col-span-2">
<ChipToggleGroup
label="匹配字段"
options={(Object.keys(MATCH_FIELD) as MatchField[]).map((k) => ({ value: k, label: MATCH_FIELD[k] }))}
value={f.matchFields}
onChange={(v) => set("matchFields", v)}
/>
</FormField>
<FormField label="大小(MB,剧集按每集)">
<div className="flex items-center gap-2">
<Input
inputMode="numeric"
aria-label="最小 MB"
placeholder="最小"
value={f.sizeMin}
onChange={(e) => set("sizeMin", e.target.value)}
/>
<span className="text-muted-foreground">–</span>
<Input
inputMode="numeric"
aria-label="最大 MB"
placeholder="最大"
value={f.sizeMax}
onChange={(e) => set("sizeMax", e.target.value)}
/>
</div>
</FormField>
<FormField label="发布后(分钟)">
<div className="flex items-center gap-2">
<Input
inputMode="numeric"
aria-label="最少分钟"
placeholder="最少"
value={f.delayMin}
onChange={(e) => set("delayMin", e.target.value)}
/>
<span className="text-muted-foreground">–</span>
<Input
inputMode="numeric"
aria-label="最多分钟"
placeholder="最多"
value={f.delayMax}
onChange={(e) => set("delayMax", e.target.value)}
/>
</div>
</FormField>
<FormField label="最少做种" htmlFor={`${uid}-seed`}>
<Input
id={`${uid}-seed`}
inputMode="numeric"
value={f.minSeeders}
onChange={(e) => set("minSeeders", e.target.value)}
/>
</FormField>
<div className="flex items-center justify-between gap-3 pb-5">
<span className="text-caption font-medium text-foreground">只要免费</span>
<Switch checked={f.free} onCheckedChange={(v) => set("free", v)} aria-label="只要免费" />
</div>
</div>
{error ? <p className="text-caption text-destructive">{error}</p> : null}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
取消
</Button>
<Button onClick={submit} disabled={pending || !f.key.trim()}>
{pending ? "保存中…" : atom ? "保存" : "新建"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function atomSummary(a: RuleAtom): string {
const p: string[] = [];
if (a.include.length) p.push(`包含 ${a.include.join(" | ")}`);
if (a.exclude.length) p.push(`排除 ${a.exclude.join(" | ")}`);
if (a.unitSizeMb) p.push(`大小 ${a.unitSizeMb.min ?? 0}–${a.unitSizeMb.max ?? "∞"} MB`);
if (a.minSeeders !== null) p.push(`做种 ≥ ${a.minSeeders}`);
if (a.publishDelayMinutes) p.push(`发布 ${a.publishDelayMinutes.min ?? 0}–${a.publishDelayMinutes.max ?? "∞"} 分钟`);
if (a.free) p.push("免费");
return p.join(";") || "没有条件";
}
export function AtomsTab() {
const atoms = useRuleAtoms();
const keywords = useRuleKeywords();
const del = useDeleteRuleAtom();
const [dialog, setDialog] = useState<{ open: boolean; atom: RuleAtom | null }>({ open: false, atom: null });
const builtins = (keywords.data ?? []).filter((k) => k.builtin);
const columns: DataColumn<RuleAtom>[] = [
{
key: "key",
header: "关键字",
cell: (a) => (
<div>
<span className="font-mono font-medium text-foreground">{a.key}</span>
{a.name ? <span className="ml-2 text-meta text-muted-foreground">{a.name}</span> : null}
</div>
),
},
{
key: "cond",
header: "条件",
cell: (a) => (
<span className="line-clamp-2 font-mono text-meta break-all text-muted-foreground">{atomSummary(a)}</span>
),
},
{
key: "act",
header: "",
align: "right",
cell: (a) => (
// biome-ignore lint/a11y/noStaticElementInteractions: 阻止行点击冒泡
// biome-ignore lint/a11y/useKeyWithClickEvents: 同上
<span onClick={(e) => e.stopPropagation()}>
<ConfirmDialog
trigger={
<Button variant="ghost" size="icon-xs" aria-label={`删除 ${a.key}`} className="hover:text-destructive">
<Trash2 />
</Button>
}
title={`删除条件 ${a.key}?`}
description="被规则组引用时不能删除。"
confirmLabel="删除"
destructive
onConfirm={async () => {
try {
await del.mutateAsync(a.id);
toast.success("已删除");
} catch (err) {
toast.error(errorMessage(err, "删除失败"));
throw err;
}
}}
/>
</span>
),
},
];
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-caption text-muted-foreground">
内置关键字不够用时,用正则、大小、做种数等定义新条件,在规则串里按关键字引用。
</p>
<Button size="sm" onClick={() => setDialog({ open: true, atom: null })}>
<Plus />
新建条件
</Button>
</div>
<DataTable
columns={columns}
rows={atoms.data?.items}
rowKey={(a) => a.id}
loading={atoms.isPending}
error={atoms.error}
onRetry={() => atoms.refetch()}
onRowClick={(a) => setDialog({ open: true, atom: a })}
empty={{ icon: FlaskConical, title: "还没有自定义条件", description: "内置关键字覆盖不到时再新建。" }}
/>
<Panel title="内置关键字" description="对齐 MoviePilot;判定依据按解析字段优先">
{keywords.isPending ? (
<LoadingState rows={3} />
) : keywords.isError ? (
<ErrorState plain error={keywords.error} onRetry={() => keywords.refetch()} />
) : (
<ul className="grid gap-x-6 gap-y-2 sm:grid-cols-2">
{builtins.map((k) => (
<li key={k.id} className="flex min-w-0 items-baseline gap-2 text-caption">
<span className="w-16 shrink-0 font-mono font-medium text-foreground">{k.id}</span>
<span className="min-w-0 text-muted-foreground">
{k.supported ? k.description || k.name : "不支持,规则串里出现会报错"}
</span>
</li>
))}
</ul>
)}
</Panel>
<AtomDialog open={dialog.open} atom={dialog.atom} onOpenChange={(o) => setDialog((d) => ({ ...d, open: o }))} />
</div>
);
}
// ─── 默认值 ───
const KINDS: RuleGroupKind[] = ["acquisition", "upgrade", "search"];
export function DefaultsTab({ groups }: { groups: RuleGroup[] | undefined }) {
const q = useRuleDefaults();
const save = useSaveRuleDefaults();
const [draft, setDraft] = useState<RuleDefaults | null>(null);
useEffect(() => {
if (q.data) setDraft(q.data);
}, [q.data]);
const byId = useMemo(() => new Map((groups ?? []).map((g) => [g.id, g])), [groups]);
const dirty = !!draft && !!q.data && JSON.stringify(draft) !== JSON.stringify(q.data);
if (q.isPending || !draft) return <LoadingState rows={3} className="pg-slab rounded-xl p-4" />;
if (q.isError) return <ErrorState error={q.error} onRetry={() => q.refetch()} />;
const setKind = (k: RuleGroupKind, ids: string[]) => setDraft((d) => (d ? { ...d, [k]: ids } : d));
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-caption text-muted-foreground">
订阅和版本目标没指定规则组时用这里的链。链上的组按顺序串联,任一组拒绝即拒绝,优先级取最后一个带层的组。
</p>
<div className="flex items-center gap-2">
{dirty ? <StatusBadge tone="warning">未保存</StatusBadge> : null}
{dirty ? (
<Button variant="ghost" size="sm" onClick={() => setDraft(q.data)}>
还原
</Button>
) : null}
<Button
size="sm"
disabled={!dirty || save.isPending}
onClick={async () => {
try {
await save.mutateAsync(draft);
toast.success("默认值已保存");
} catch (err) {
toast.error(errorMessage(err, "保存失败"));
}
}}
>
<Save />
{save.isPending ? "保存中…" : "保存"}
</Button>
</div>
</div>
<div className="grid gap-4 lg:grid-cols-3">
{KINDS.map((k) => {
const ids = draft[k];
const candidates = (groups ?? []).filter((g) => g.kind === k && !ids.includes(g.id));
const lastLadder = [...ids].reverse().find((id) => (byId.get(id)?.layers.length ?? 0) > 0);
return (
<Panel key={k} title={DEFAULT_CHAIN_LABEL[k]} description={RULE_KIND[k].hint}>
{ids.length === 0 ? (
<p className="text-meta text-muted-foreground">链为空:不按规则组过滤。</p>
) : (
<ol className="space-y-1.5">
{ids.map((id, i) => {
const g = byId.get(id);
return (
<li key={id} className="flex items-center gap-2 rounded-lg border border-border/70 px-2.5 py-1.5">
<span className="text-meta text-muted-foreground tabular-nums">{i + 1}</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-caption text-foreground">
{g?.name ?? "已删除的规则组"}
</span>
{id === lastLadder ? (
<span className="text-micro text-muted-foreground">决定优先级</span>
) : null}
{g && !g.enabled ? <span className="text-micro text-warning">已停用,评估时跳过</span> : null}
</span>
<Button
variant="ghost"
size="icon-xs"
aria-label="上移"
disabled={i === 0}
onClick={() => {
const a = [...ids];
[a[i - 1], a[i]] = [a[i] as string, a[i - 1] as string];
setKind(k, a);
}}
>
<ArrowUp />
</Button>
<Button
variant="ghost"
size="icon-xs"
aria-label="下移"
disabled={i === ids.length - 1}
onClick={() => {
const a = [...ids];
[a[i + 1], a[i]] = [a[i] as string, a[i + 1] as string];
setKind(k, a);
}}
>
<ArrowDown />
</Button>
<Button
variant="ghost"
size="icon-xs"
aria-label="移出"
onClick={() =>
setKind(
k,
ids.filter((x) => x !== id),
)
}
>
<X />
</Button>
</li>
);
})}
</ol>
)}
{candidates.length ? (
<div className="mt-3">
<FilterSelect
label="加入"
value=""
allLabel="选择规则组"
onChange={(v) => v && setKind(k, [...ids, v])}
options={candidates.map((g) => ({ value: g.id, label: g.name }))}
className="w-full"
/>
</div>
) : null}
</Panel>
);
})}
</div>
</div>
);
}
// ─── 抑制记录 ───
const actorName = (s: string | null) => (s ? s.replace(/^[a-z_]+:/, "") || s : "—");
export function SuppressionsTab() {
const sups = useRuleSuppressions();
const groups = useRuleGroups({ includeDeleted: "true" }).data?.items;
const lift = useLiftRuleSuppression();
const byId = useMemo(() => new Map((groups ?? []).map((g) => [g.id, g])), [groups]);
const active = (sups.data?.items ?? []).filter((s) => s.active).length;
return (
<Panel title="抑制记录" description={sups.data ? `${active} 条生效中` : undefined}>
<p className="mb-3 text-caption text-muted-foreground">
停用、删除,或改掉对话 / Agent 提出的规则,都会留下抑制记录。Agent 再提出同样的规则会被直接拒绝,解除后才能再提。
</p>
{sups.isPending ? (
<LoadingState rows={2} />
) : sups.isError ? (
<ErrorState plain error={sups.error} onRetry={() => sups.refetch()} />
) : sups.data.items.length === 0 ? (
<EmptyState plain icon={Ban} title="没有抑制记录" className="py-8" />
) : (
<ul className="divide-y divide-border/60">
{sups.data.items.map((s) => {
const gid = s.ruleGroupId ?? s.preferenceTagId;
const g = gid ? byId.get(gid) : undefined;
return (
<li key={s.id} className="flex flex-wrap items-center gap-x-3 gap-y-1 py-2.5">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="min-w-0 font-medium break-words text-foreground">
{g?.name ?? "已删除的规则组"}
</span>
<Chip>{suppressReason(s.reason)}</Chip>
{s.active ? <StatusBadge tone="warning">抑制中</StatusBadge> : <StatusBadge>已解除</StatusBadge>}
</div>
<p className="mt-0.5 text-meta text-muted-foreground tabular-nums">
{actorName(s.suppressedBy)} · {formatDateTime(s.createdAt)}
{s.liftedAt ? ` · ${actorName(s.liftedBy)} 于 ${formatDateTime(s.liftedAt)} 解除` : ""}
</p>
</div>
{s.active ? (
<ConfirmDialog
trigger={
<Button variant="outline" size="sm">
解除抑制
</Button>
}
title="解除这条抑制?"
description="解除后 Agent 可以再次提出这条规则,仍需审批。规则组本身不会恢复。"
confirmLabel="解除"
onConfirm={async () => {
try {
await lift.mutateAsync({ id: s.id, reason: "用户明确解除" });
toast.success("已解除抑制");
} catch (err) {
toast.error(errorMessage(err));
throw err;
}
}}
/>
) : null}
</li>
);
})}
</ul>
)}
</Panel>
);
}
@@ -0,0 +1,320 @@
import type { LocalMediaHit, SearchAction, TmdbCandidate } from "@umt/contracts";
import { ArrowRight, CornerDownLeft, Film, Globe, LoaderCircle, type LucideIcon, Plus, Search } from "lucide-react";
import { type ReactNode, useEffect, useId, useMemo, useRef, useState } from "react";
import { Link, useNavigate } from "react-router";
import { useSearch } from "~/api/search.ts";
import { settingsPath } from "~/components/console/nav.ts";
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "~/components/ui/dialog.tsx";
import { errorMessage } from "~/lib/api.ts";
import { cn } from "~/lib/utils.ts";
import { actionIcon, mediaKindLabel, slotSummary } from "./search-labels.ts";
import { useSubscribeTmdb } from "./search-sections.tsx";
import {
isSearchShortcut,
openSearchPalette,
searchPagePath,
setSearchPaletteOpen,
useSearchPalette,
} from "./search-store.ts";
// 命令面板(Ctrl/⌘+K):输入即查本地作品 / TMDB 候选 / 页面与操作,最后一项跳资源结果页。
// 资源组最慢要等 15s,面板里不查,回车进结果页。
function useDebounced<T>(v: T, ms: number): T {
const [d, setD] = useState(v);
useEffect(() => {
const t = setTimeout(() => setD(v), ms);
return () => clearTimeout(t);
}, [v, ms]);
return d;
}
type Item = {
key: string;
group: string;
icon: LucideIcon;
title: ReactNode;
meta?: ReactNode;
/** 右侧动作提示 */
hint: string;
run: () => void;
};
/** 全局快捷键 Ctrl/⌘+K:切换面板(输入框里同样生效) */
export function useSearchHotkey() {
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.repeat || !isSearchShortcut(e)) return;
e.preventDefault();
openSearchPalette();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
}
export function CommandPalette() {
useSearchHotkey();
const { open, initial } = useSearchPalette();
return (
<Dialog open={open} onOpenChange={setSearchPaletteOpen}>
<DialogContent
showCloseButton={false}
className={cn(
"top-4 flex translate-y-0 flex-col gap-0 overflow-hidden p-0 sm:top-[12vh] sm:max-w-xl",
"max-sm:inset-0 max-sm:max-w-none max-sm:translate-x-0 max-sm:rounded-none",
)}
>
<DialogTitle className="sr-only">搜索</DialogTitle>
<DialogDescription className="sr-only">搜索作品、TMDB 和页面;回车在资源中搜索</DialogDescription>
{open ? <PaletteBody initial={initial} /> : null}
</DialogContent>
</Dialog>
);
}
function PaletteBody({ initial }: { initial: string }) {
const navigate = useNavigate();
const listId = useId();
const [q, setQ] = useState(initial);
const term = useDebounced(q.trim(), 150);
const media = useSearch(term, "media", 6);
const actions = useSearch(term, "actions", 6);
const tmdbSub = useSubscribeTmdb();
const [active, setActive] = useState(0);
const listRef = useRef<HTMLDivElement>(null);
const close = () => setSearchPaletteOpen(false);
const go = (to: string) => {
close();
navigate(to);
};
const subscribe = (c: TmdbCandidate) => tmdbSub.subscribe(c, close);
const stale = q.trim() !== term;
const local = term ? (media.data?.media?.local.items ?? []) : [];
const tmdb = term ? media.data?.media?.tmdb : undefined;
const acts = term ? (actions.data?.actions?.items ?? []) : [];
// biome-ignore lint/correctness/useExhaustiveDependencies: 只随数据变化重建(go/subscribe 每次渲染新建)
const items = useMemo<Item[]>(() => {
const out: Item[] = [];
for (const h of local) out.push(localItem(h, () => go(`/console/library/${h.entity.id}`)));
for (const c of tmdb?.items ?? [])
out.push(tmdbItem(c, () => go(`/console/library/${c.localEntityId}`), subscribe));
for (const a of acts) out.push(actionItem(a, () => go(a.href)));
if (term)
out.push({
key: "releases",
group: "资源",
icon: Search,
title: (
<span>
在资源中搜索 <span className="font-medium">“{term}”</span>
</span>
),
meta: "查询全部已启用站点,最慢约 15 秒",
hint: "打开",
run: () => go(searchPagePath(term)),
});
return out;
}, [local, tmdb, acts, term]);
// biome-ignore lint/correctness/useExhaustiveDependencies: 结果变化时高亮回到第一项
useEffect(() => setActive(0), [items.length, term]);
useEffect(() => {
listRef.current?.querySelector(`[data-index="${active}"]`)?.scrollIntoView({ block: "nearest" });
}, [active]);
const onKeyDown = (e: React.KeyboardEvent) => {
if (e.nativeEvent.isComposing) return;
if (e.key === "ArrowDown") {
e.preventDefault();
setActive((i) => (items.length ? (i + 1) % items.length : 0));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setActive((i) => (items.length ? (i - 1 + items.length) % items.length : 0));
} else if (e.key === "Enter") {
e.preventDefault();
const it = items[active];
if (it && !stale) it.run();
else if (q.trim()) go(searchPagePath(q));
}
};
const loading = !!term && (media.isFetching || actions.isFetching);
const activeKey = items[active]?.key;
let lastGroup = "";
return (
<div className="flex min-h-0 flex-1 flex-col">
<div className="flex items-center gap-2 border-b border-border/70 px-4">
<Search className="h-4 w-4 shrink-0 text-muted-foreground" />
<input
// biome-ignore lint/a11y/noAutofocus: 命令面板打开即输入
autoFocus
value={q}
onChange={(e) => setQ(e.target.value)}
onKeyDown={onKeyDown}
placeholder="搜索作品、TMDB、页面"
role="combobox"
aria-expanded={items.length > 0}
aria-controls={listId}
aria-activedescendant={activeKey ? `${listId}-${activeKey}` : undefined}
aria-autocomplete="list"
className="h-12 min-w-0 flex-1 bg-transparent text-body text-foreground outline-none placeholder:text-muted-foreground"
/>
{loading ? <LoaderCircle className="h-4 w-4 shrink-0 animate-spin text-muted-foreground" /> : null}
<kbd className="hidden rounded-tight border border-border/70 px-1.5 text-label text-muted-foreground sm:inline">
Esc
</kbd>
<button
type="button"
onClick={close}
className="cursor-pointer text-caption text-muted-foreground hover:text-foreground sm:hidden"
>
取消
</button>
</div>
<div
ref={listRef}
id={listId}
role="listbox"
aria-label="搜索结果"
className="min-h-0 flex-1 overflow-y-auto p-2 sm:max-h-[60vh]"
>
{!term ? (
<p className="px-3 py-6 text-center text-caption text-muted-foreground">
输入作品名、别名或页面名称。回车在资源中搜索。
</p>
) : (
<>
{items.map((it, i) => {
const head = it.group !== lastGroup ? it.group : null;
lastGroup = it.group;
return (
<div key={it.key}>
{head ? (
<p className="px-3 pt-2 pb-1 text-micro font-semibold tracking-[0.12em] text-muted-foreground uppercase">
{head}
</p>
) : null}
{/* biome-ignore lint/a11y/useKeyWithClickEvents: 键盘由输入框(combobox)的上下键与回车处理 */}
<div
id={`${listId}-${it.key}`}
role="option"
tabIndex={-1}
aria-selected={i === active}
data-index={i}
onMouseMove={() => i !== active && setActive(i)}
onClick={it.run}
className={cn(
"flex cursor-pointer items-center gap-3 rounded-xl px-3 py-2 transition-colors duration-micro",
i === active ? "bg-foreground/[0.06]" : "",
)}
>
<it.icon className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<div className="truncate text-sm text-foreground">{it.title}</div>
{it.meta ? <div className="truncate text-meta text-muted-foreground">{it.meta}</div> : null}
</div>
<span
className={cn(
"flex shrink-0 items-center gap-1 text-meta text-muted-foreground",
i !== active && "max-sm:hidden sm:invisible",
)}
>
{it.hint}
<CornerDownLeft className="h-3 w-3" />
</span>
</div>
</div>
);
})}
{!stale && media.data && tmdb && !tmdb.configured ? (
<p className="px-3 py-2 text-meta text-muted-foreground">
TMDB 未配置,只搜本地作品。
<Link to={settingsPath("metadata")} onClick={close} className="ml-1 underline underline-offset-4">
去配置
</Link>
</p>
) : null}
{!stale && tmdb?.error ? (
<p className="px-3 py-2 text-meta text-warning">TMDB 查询失败:{tmdb.error}</p>
) : null}
{media.isError || actions.isError ? (
<p className="px-3 py-2 text-meta text-destructive">
{errorMessage(media.error ?? actions.error, "搜索失败")}
</p>
) : null}
</>
)}
</div>
<div className="hidden items-center gap-4 border-t border-border/70 px-4 py-2 text-meta text-muted-foreground sm:flex">
<span>↑↓ 选择</span>
<span>↵ 打开</span>
<span className="ml-auto inline-flex items-center gap-1">
<ArrowRight className="h-3 w-3" />
回车在资源中搜索
</span>
</div>
</div>
);
}
function localItem(h: LocalMediaHit, run: () => void): Item {
const e = h.entity;
const parts = [mediaKindLabel(e.kind), e.year ? String(e.year) : null, slotSummary(h)].filter(Boolean);
return {
key: `local-${e.id}`,
group: "本地作品",
icon: Film,
title: (
<span>
{e.title}
{h.matchedAlias && h.matchedAlias !== e.title ? (
<span className="ml-1.5 text-muted-foreground">· {h.matchedAlias}</span>
) : null}
</span>
),
meta: parts.join(" · "),
hint: "打开",
run,
};
}
function tmdbItem(c: TmdbCandidate, view: () => void, subscribe: (c: TmdbCandidate) => void): Item {
const local = !!c.localEntityId;
return {
key: `tmdb-${c.entityType}-${c.externalId}`,
group: "TMDB",
icon: local ? Globe : Plus,
title: (
<span>
{c.title}
{c.year ? <span className="text-muted-foreground"> ({c.year})</span> : null}
</span>
),
meta: [c.entityType === "movie" ? "电影" : "剧集", local ? "本地已有" : c.originalTitle, c.overview]
.filter(Boolean)
.join(" · "),
hint: local ? "查看" : "订阅",
run: local ? view : () => subscribe(c),
};
}
function actionItem(a: SearchAction, run: () => void): Item {
return {
key: `action-${a.id}`,
group: "页面与操作",
icon: actionIcon(a.kind),
title: a.title,
meta: a.group,
hint: "前往",
run,
};
}
@@ -0,0 +1,411 @@
import type { FacetCount, ReleaseSearchPage, SearchRelease } from "@umt/contracts";
import {
ArrowDown,
ArrowDownWideNarrow,
ArrowUp,
ArrowUpNarrowWide,
Bot,
ChevronDown,
Download,
ExternalLink,
PackageSearch,
RefreshCw,
TriangleAlert,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { Link } from "react-router";
import { toast } from "sonner";
import { type ReleaseSearchFilters, useRefreshReleaseSearch, useReleaseSearch } from "~/api/search.ts";
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { FilterSelect } from "~/components/console/filter-bar.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
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 { relTime } from "~/components/workbench/release-fields.ts";
import { errorMessage } from "~/lib/api.ts";
import { cn, formatBytes } from "~/lib/utils.ts";
import { delegateToAgent, SearchDownloadDialog } from "./search-download-dialog.tsx";
import { gbToBytes, INVENTORY_STATE, promoLabel, SITE_STATUS, SORT_OPTIONS } from "./search-labels.ts";
// 资源段:跨站结果的筛选(站点/分辨率/来源/大小/免费/H&R)、排序、游标;行操作同站点工作台。
/** URL 里的资源筛选(字符串形态,空串 = 不筛) */
export type ReleaseFilterState = {
sites: string;
resolution: string;
source: string;
minGb: string;
maxGb: string;
free: string;
hr: string;
sort: string;
order: string;
};
export const RELEASE_FILTER_KEYS = [
"sites",
"resolution",
"source",
"minGb",
"maxGb",
"free",
"hr",
"sort",
"order",
] as const satisfies readonly (keyof ReleaseFilterState)[];
export function toApiFilters(q: string, f: ReleaseFilterState): ReleaseSearchFilters {
const bool = (v: string) => (v === "true" || v === "false" ? v : undefined);
const sort = SORT_OPTIONS.some((o) => o.value === f.sort) ? (f.sort as ReleaseSearchFilters["sort"]) : undefined;
return {
q,
sites: f.sites || undefined,
resolution: f.resolution || undefined,
source: f.source || undefined,
minSize: gbToBytes(f.minGb),
maxSize: gbToBytes(f.maxGb),
free: bool(f.free),
hr: bool(f.hr),
sort,
order: f.order === "asc" ? "asc" : f.order === "desc" ? "desc" : undefined,
};
}
const csvList = (s: string) => (s ? s.split(",").filter(Boolean) : []);
export function ReleaseResults({
q,
filters,
setFilter,
resetFilters,
}: {
q: string;
filters: ReleaseFilterState;
setFilter: (k: keyof ReleaseFilterState, v: string) => void;
resetFilters: () => void;
}) {
const api = useMemo(() => toApiFilters(q, filters), [q, filters]);
const res = useReleaseSearch(api);
const refresh = useRefreshReleaseSearch();
const pages = res.data?.pages ?? [];
const first = pages[0];
const rows = useMemo(() => pages.flatMap((p) => p.items), [pages]);
const [dl, setDl] = useState<SearchRelease | null>(null);
const filtered = RELEASE_FILTER_KEYS.some((k) => k !== "sort" && k !== "order" && !!filters[k]);
return (
<section aria-labelledby="search-releases" className="min-w-0">
<div className="mb-3 flex flex-wrap items-end justify-between gap-2">
<div className="min-w-0">
<h2 id="search-releases" className="text-body font-semibold text-foreground">
资源
{first ? (
<span className="ml-2 text-caption font-normal text-muted-foreground tabular-nums">
{first.total === first.unfilteredTotal
? `${first.total} 条`
: `${first.total} / ${first.unfilteredTotal} 条`}
</span>
) : null}
</h2>
{first ? (
<p className="mt-0.5 text-meta text-muted-foreground tabular-nums">
{first.cached ? "缓存结果" : "站点实时结果"} · 抓取于 {relTime(first.fetchedAt)} · {first.tookMs} ms
</p>
) : null}
</div>
<Button
variant="outline"
size="sm"
disabled={refresh.isPending || res.isFetching}
onClick={() => refresh.mutate(api, { onError: (e) => toast.error(errorMessage(e, "刷新失败")) })}
>
<RefreshCw className={cn(refresh.isPending && "animate-spin")} />
{refresh.isPending ? "查询中…" : "刷新"}
</Button>
</div>
{first ? <CoverageNotice page={first} /> : null}
{first ? (
<Filters page={first} filters={filters} setFilter={setFilter} onReset={filtered ? resetFilters : undefined} />
) : null}
<div className="pg-slab min-w-0 overflow-hidden rounded-xl">
{res.isPending ? (
<div className="p-4">
<p className="mb-3 text-meta text-muted-foreground">正在查询各站点,最慢约 15 秒…</p>
<LoadingState rows={4} />
</div>
) : res.isError && !rows.length ? (
<ErrorState plain error={res.error} onRetry={() => res.refetch()} />
) : rows.length === 0 ? (
<EmptyState
plain
icon={PackageSearch}
title={filtered ? "没有符合筛选条件的资源" : "各站点都没有找到资源"}
description={filtered ? "放宽筛选条件再看。" : "换个关键词,或到站点工作台检查站点状态。"}
className="py-12"
/>
) : (
<ul className="divide-y divide-border/60">
{rows.map((r) => (
<ReleaseRow key={r.key} r={r} onDownload={() => setDl(r)} />
))}
</ul>
)}
{rows.length > 0 && res.hasNextPage ? (
<div className="flex justify-center border-t border-border/60 px-4 py-3">
<Button variant="outline" size="sm" onClick={() => res.fetchNextPage()} disabled={res.isFetchingNextPage}>
{res.isFetchingNextPage ? "加载中…" : "加载更多"}
</Button>
</div>
) : null}
</div>
{dl ? <SearchDownloadDialog release={dl} open={!!dl} onOpenChange={(o) => !o && setDl(null)} /> : null}
</section>
);
}
/** 部分站点失败 / 跳过:列出站点,其余结果照常 */
function CoverageNotice({ page }: { page: ReleaseSearchPage }) {
const [open, setOpen] = useState(false);
const skipped = page.sites.filter((s) => s.status === "skipped");
const ok = page.sites.filter((s) => s.status === "ok").length;
return (
<div className="mb-3 space-y-2">
{page.partial ? (
<div className="flex gap-2 rounded-xl border border-warning/40 bg-warning/10 px-3 py-2.5 text-caption text-foreground">
<TriangleAlert className="mt-0.5 h-4 w-4 shrink-0 text-warning" />
<div className="min-w-0">
<p>
{page.failedSites.length} 个站点没有返回结果,下面只有 {ok} 个站点的资源。
</p>
<ul className="mt-1 flex flex-wrap gap-x-3 gap-y-0.5 text-meta text-muted-foreground">
{page.failedSites.map((s) => (
<li key={s.siteId} className="break-all" title={s.error ?? undefined}>
{s.siteName}:{SITE_STATUS[s.status]}
{s.error ? `(${s.error})` : ""}
</li>
))}
</ul>
</div>
</div>
) : null}
{skipped.length ? (
<div className="text-meta text-muted-foreground">
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
className="inline-flex cursor-pointer items-center gap-1 hover:text-foreground"
>
<ChevronDown className={cn("h-3 w-3 transition-transform duration-micro", open && "rotate-180")} />
{skipped.length} 个站点未参与搜索
</button>
{open ? (
<ul className="mt-1 space-y-0.5 pl-4">
{skipped.map((s) => (
<li key={s.siteId} className="break-all">
{s.siteName}:{s.error ?? "站点不支持搜索"}
</li>
))}
</ul>
) : null}
</div>
) : null}
</div>
);
}
const facetOpts = (items: FacetCount[]) =>
items.map((f) => ({ value: f.value, label: `${f.value === "unknown" ? "未知" : f.label} ${f.count}` }));
function Filters({
page,
filters,
setFilter,
onReset,
}: {
page: ReleaseSearchPage;
filters: ReleaseFilterState;
setFilter: (k: keyof ReleaseFilterState, v: string) => void;
onReset?: () => void;
}) {
const [minGb, setMinGb] = useState(filters.minGb);
const [maxGb, setMaxGb] = useState(filters.maxGb);
useEffect(() => setMinGb(filters.minGb), [filters.minGb]);
useEffect(() => setMaxGb(filters.maxGb), [filters.maxGb]);
const facets: { key: "sites" | "resolution" | "source"; label: string; items: FacetCount[] }[] = [
{ key: "sites", label: "站点", items: page.facets.sites },
{ key: "resolution", label: "分辨率", items: page.facets.resolutions },
{ key: "source", label: "来源", items: page.facets.sources },
];
const desc = filters.order !== "asc";
return (
<div className="mb-3 space-y-2.5">
{facets
.filter((f) => f.items.length > 0)
.map((f) => (
<div key={f.key} className="flex min-w-0 flex-wrap items-start gap-2">
<span className="w-12 shrink-0 pt-1 text-meta text-muted-foreground">{f.label}</span>
<div className="min-w-0 flex-1">
<ChipToggleGroup
label={f.label}
options={facetOpts(f.items)}
value={csvList(filters[f.key])}
onChange={(v) => setFilter(f.key, v.join(","))}
/>
</div>
</div>
))}
<div className="flex flex-wrap items-center gap-2">
<div className="flex items-center gap-1.5">
<span className="text-meta text-muted-foreground">大小</span>
<Input
inputMode="decimal"
value={minGb}
onChange={(e) => setMinGb(e.target.value)}
onBlur={() => setFilter("minGb", minGb.trim())}
onKeyDown={(e) => e.key === "Enter" && setFilter("minGb", minGb.trim())}
placeholder="最小"
aria-label="最小大小(GB)"
className="h-8 w-20"
/>
<span className="text-meta text-muted-foreground">–</span>
<Input
inputMode="decimal"
value={maxGb}
onChange={(e) => setMaxGb(e.target.value)}
onBlur={() => setFilter("maxGb", maxGb.trim())}
onKeyDown={(e) => e.key === "Enter" && setFilter("maxGb", maxGb.trim())}
placeholder="最大"
aria-label="最大大小(GB)"
className="h-8 w-20"
/>
<span className="text-meta text-muted-foreground">GB</span>
</div>
<FilterSelect
label="免费"
value={filters.free}
onChange={(v) => setFilter("free", v)}
options={[
{ value: "true", label: "只看免费" },
{ value: "false", label: "排除免费" },
]}
/>
<FilterSelect
label="H&R"
value={filters.hr}
onChange={(v) => setFilter("hr", v)}
options={[
{ value: "true", label: "只看 H&R" },
{ value: "false", label: "排除 H&R" },
]}
/>
<div className="flex items-center gap-1">
<Select value={filters.sort || "default"} onValueChange={(v) => setFilter("sort", v === "default" ? "" : v)}>
<SelectTrigger size="sm" aria-label="排序" className="min-w-32">
<span className="text-muted-foreground">排序</span>
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
{SORT_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
aria-label={desc ? "降序,点击改为升序" : "升序,点击改为降序"}
title={desc ? "降序" : "升序"}
onClick={() => setFilter("order", desc ? "asc" : "")}
>
{desc ? <ArrowDownWideNarrow className="h-4 w-4" /> : <ArrowUpNarrowWide className="h-4 w-4" />}
</Button>
</div>
{onReset ? (
<Button variant="ghost" size="sm" onClick={onReset}>
清除筛选
</Button>
) : null}
</div>
</div>
);
}
function ReleaseRow({ r, onDownload }: { r: SearchRelease; onDownload: () => void }) {
const promo = promoLabel(r.promotion.type);
const inv = r.inventory ? INVENTORY_STATE[r.inventory.state] : null;
const p = r.parsed;
const tech = [p.resolution, p.source, p.videoCodec, ...p.hdr, p.releaseGroup].filter(Boolean).join(" · ");
return (
<li className="px-4 py-3">
<div className="flex min-w-0 flex-wrap items-start gap-x-2 gap-y-1">
<span className="min-w-0 flex-1 text-sm font-medium break-all text-foreground">{r.title}</span>
<span className="flex flex-wrap gap-1">
{promo ? <StatusBadge tone={r.free ? "success" : "neutral"}>{promo}</StatusBadge> : null}
{r.hr === true ? <StatusBadge tone="warning">H&R</StatusBadge> : null}
{r.hr === "unknown" ? <StatusBadge>H&R 未知</StatusBadge> : null}
</span>
</div>
{r.subtitle ? <p className="mt-0.5 line-clamp-1 text-caption text-muted-foreground">{r.subtitle}</p> : null}
<div className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-meta text-muted-foreground tabular-nums">
<span className="font-medium text-foreground">{r.siteName}</span>
{r.category.name ? <span>{r.category.name}</span> : null}
<span>{r.sizeBytes === null ? "大小未知" : formatBytes(r.sizeBytes, 1)}</span>
<span className="inline-flex items-center gap-0.5" title="做种 / 下载">
<ArrowUp className="h-3 w-3" />
{r.seeders ?? "?"}
<ArrowDown className="ml-1 h-3 w-3" />
{r.leechers ?? "?"}
</span>
<span title="站点发布时间">发布 {relTime(r.uploadedAt)}</span>
{tech ? <span className="break-all">{tech}</span> : null}
</div>
<div className="mt-2 flex flex-wrap items-center justify-between gap-2">
<span className="flex min-w-0 flex-wrap items-center gap-1.5 text-meta">
{r.entity ? (
<>
<Link
to={`/console/library/${r.entity.id}`}
className="truncate text-foreground underline-offset-4 hover:underline"
>
{r.entity.title}
{r.entity.year ? ` (${r.entity.year})` : ""}
</Link>
{r.entityMatch === "alias" ? <StatusBadge>未确认</StatusBadge> : null}
{inv ? <StatusBadge tone={inv.tone}>{inv.label}</StatusBadge> : null}
</>
) : (
<span className="text-muted-foreground">未关联本地作品</span>
)}
</span>
<span className="flex flex-wrap items-center gap-1">
{r.sourceReleaseId ? (
<Button asChild variant="ghost" size="sm">
<Link to={`/console/sites/${r.siteId}`} title="在站点工作台查看">
<ExternalLink />
工作台
</Link>
</Button>
) : null}
<Button variant="ghost" size="sm" onClick={() => delegateToAgent(r)}>
<Bot />
委托 Agent
</Button>
<Button size="sm" onClick={onDownload}>
<Download />
下载
</Button>
</span>
</div>
</li>
);
}
@@ -0,0 +1,172 @@
import type { ManualAcquisitionResult, SearchRelease } from "@umt/contracts";
import { Bot, Download } from "lucide-react";
import { useState } from "react";
import { Link } from "react-router";
import { toast } from "sonner";
import { usePlanFromSearch } from "~/api/search.ts";
import { useSlots } from "~/api/slots.ts";
import { openAgentDrawer } from "~/components/agent/agent-store.ts";
import { FilterSelect } from "~/components/console/filter-bar.tsx";
import { SLOT_STATUS } from "~/components/library/labels.ts";
import { Button } from "~/components/ui/button.tsx";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog.tsx";
import { errorMessage } from "~/lib/api.ts";
import { formatBytes } from "~/lib/utils.ts";
// 与站点工作台同一套下载说明;请求走 POST /search/releases/{siteId}/{siteReleaseId}/plan(同 /acquisition-plans 流程)
const STEPS = [
"生成下载计划,预留版本目标和预算;每个版本目标同时只有一个在途计划",
"已有相同资源或相同 hash 的任务时直接接管,不重复提交",
"缓存卷超过硬阈值或读不到磁盘状态时无法提交",
"提交前复核免费、做种等易变字段",
];
/** 委托 Agent:和工作台一样带上资源、作品、站点 */
export function delegateToAgent(r: SearchRelease) {
openAgentDrawer({
label: `资源:${r.title}`,
releaseId: r.sourceReleaseId ?? undefined,
entityId: r.entity?.id,
siteId: r.siteId,
goal: r.sourceReleaseId
? "判断这个资源是否值得下载;合适就按正常流程提交"
: `判断站点 ${r.siteName} 的资源「${r.title}」是否值得下载;合适就按正常流程提交`,
});
}
export function SearchDownloadDialog({
release,
open,
onOpenChange,
}: {
release: SearchRelease;
open: boolean;
onOpenChange: (o: boolean) => void;
}) {
const plan = usePlanFromSearch();
const entityId = release.entity?.id;
const slots = useSlots({ mediaEntityId: entityId }, open && !!entityId);
const slotList = slots.data?.items ?? [];
const [slotId, setSlotId] = useState("");
const [rejected, setRejected] = useState<ManualAcquisitionResult["rejected"] | null>(null);
const [done, setDone] = useState<string | null>(null);
const slotName = (id: string | null) => slotList.find((s) => s.id === id)?.name ?? null;
const go = () =>
plan.mutate(
{
siteId: release.siteId,
siteReleaseId: release.siteReleaseId,
...(slotId ? { slotId } : entityId ? { entityId } : {}),
},
{
onSuccess: (r) => {
if (r.submitted && r.plan) {
setRejected(null);
setDone(r.plan.id);
toast.success(`已提交${slotName(r.slotId) ? `(${slotName(r.slotId)})` : ""},进度见“下载队列”`);
} else {
setRejected(r.rejected);
toast.warning("没有生成下载计划");
}
},
onError: (e) => toast.error(errorMessage(e)),
},
);
return (
<Dialog
open={open}
onOpenChange={(o) => {
if (!o) {
setRejected(null);
setDone(null);
}
onOpenChange(o);
}}
>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>下载这个资源</DialogTitle>
<DialogDescription className="break-all">
{release.siteName} · {release.title}
{release.sizeBytes !== null ? ` · ${formatBytes(release.sizeBytes, 1)}` : ""}
</DialogDescription>
</DialogHeader>
<ol className="list-decimal space-y-1 pl-5 text-caption text-muted-foreground">
{STEPS.map((s) => (
<li key={s}>{s}</li>
))}
</ol>
{!entityId ? (
<p className="text-meta text-muted-foreground">资源未关联本地作品。先在“身份与元数据”指定。</p>
) : slots.isPending ? (
<p className="text-meta text-muted-foreground">正在读取版本目标…</p>
) : slotList.length ? (
<div className="flex flex-wrap items-center gap-2">
<FilterSelect
label="版本目标"
value={slotId}
onChange={setSlotId}
allLabel="自动匹配"
options={slotList.map((s) => ({
value: s.id,
label: `${s.name}(${SLOT_STATUS[s.status]?.label ?? s.status})`,
}))}
/>
</div>
) : (
<p className="text-meta text-muted-foreground">这个作品没有版本目标。先在订阅里新建。</p>
)}
{release.entityMatch === "alias" ? (
<p className="text-meta text-warning">作品关联来自标题别名,尚未确认。</p>
) : null}
{rejected ? (
<div className="rounded-xl border border-warning/40 bg-warning/10 px-3 py-2.5 text-caption text-warning">
<p className="mb-1">没有生成下载计划:</p>
<ul className="list-disc space-y-0.5 pl-5">
{rejected.map((r, i) => (
<li key={`${r.slotId}-${r.code}-${i}`} className="break-words">
{slotName(r.slotId) ? `${slotName(r.slotId)}:` : ""}
{r.message}
</li>
))}
</ul>
</div>
) : null}
{done ? (
<p className="text-caption text-success">
计划已生成。
<Link to="/console/downloads/plans" className="underline underline-offset-4">
查看
</Link>
</p>
) : null}
<DialogFooter className="gap-2">
<Button
variant="outline"
onClick={() => {
onOpenChange(false);
delegateToAgent(release);
}}
>
<Bot />
委托 Agent
</Button>
<Button onClick={go} disabled={plan.isPending || !!done || !entityId}>
<Download />
{plan.isPending ? "提交中…" : "提交下载"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,65 @@
import type { LocalMediaHit, MediaKind, SearchInventoryState, SearchSiteStatus } from "@umt/contracts";
import { Compass, type LucideIcon, Zap } from "lucide-react";
import type { StatusTone } from "~/components/console/status-badge.tsx";
import { MEDIA_KIND } from "~/components/library/labels.ts";
// 搜索结果的展示口径
export const mediaKindLabel = (k: MediaKind) => MEDIA_KIND[k] ?? k;
/** 本地作品的一行摘要:订阅 / 版本 / 下载中 / 缺口 */
export function slotSummary(h: LocalMediaHit): string {
const c = h.slotCounts;
const parts: string[] = [];
if (h.subscribed) parts.push("已订阅");
if (h.versionCount) parts.push(`${h.versionCount} 个版本`);
if (h.downloadingCount) parts.push(`${h.downloadingCount} 个下载中`);
const gaps = c.empty + c.attention + c.upgrade_pending;
if (gaps) parts.push(`${gaps} 个版本目标未满足`);
return parts.join(" · ") || "未订阅";
}
export const actionIcon = (kind: "page" | "action"): LucideIcon => (kind === "page" ? Compass : Zap);
export const INVENTORY_STATE: Record<SearchInventoryState, { label: string; tone: StatusTone }> = {
downloading: { label: "下载中", tone: "warning" },
gap: { label: "有缺口", tone: "warning" },
owned: { label: "已有版本", tone: "success" },
subscribed: { label: "已订阅", tone: "neutral" },
none: { label: "本地无版本", tone: "neutral" },
};
export const SITE_STATUS: Record<SearchSiteStatus, string> = {
ok: "正常",
failed: "失败",
timeout: "超时",
skipped: "跳过",
};
export const SORT_OPTIONS = [
{ value: "default", label: "默认" },
{ value: "seeders", label: "做种数" },
{ value: "size", label: "大小" },
{ value: "uploadedAt", label: "发布时间" },
{ value: "site", label: "站点" },
] as const;
const PROMO: Record<string, string> = {
free: "免费",
"2xfree": "2x 免费",
"50%": "50%",
"2x50%": "2x 50%",
"30%": "30%",
"2x": "2x 上传",
};
/** 促销文案;none/unknown 返回 null */
export function promoLabel(type: string): string | null {
if (!type || type === "none" || type === "unknown") return null;
return PROMO[type] ?? type;
}
const GB = 1024 ** 3;
export const gbToBytes = (s: string): number | undefined => {
const n = Number(s);
return s.trim() && Number.isFinite(n) && n >= 0 ? Math.round(n * GB) : undefined;
};
@@ -0,0 +1,175 @@
import type { TmdbCandidate } from "@umt/contracts";
import { ArrowRight, Film, Plus } from "lucide-react";
import { Link, useNavigate } from "react-router";
import { toast } from "sonner";
import { useSearch } from "~/api/search.ts";
import { useCreateSubscription } from "~/api/subscriptions.ts";
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { settingsPath } from "~/components/console/nav.ts";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { Button } from "~/components/ui/button.tsx";
import { errorMessage } from "~/lib/api.ts";
import { actionIcon, mediaKindLabel, slotSummary } from "./search-labels.ts";
// 结果页的"作品"与"操作"两段(资源段见 release-results.tsx)
function SectionTitle({ id, title, extra }: { id: string; title: string; extra?: string }) {
return (
<h2 id={id} className="mb-3 text-body font-semibold text-foreground">
{title}
{extra ? <span className="ml-2 text-caption font-normal text-muted-foreground tabular-nums">{extra}</span> : null}
</h2>
);
}
/** 从 TMDB 候选建订阅:按标题追踪(建档后自动关联作品) */
export function useSubscribeTmdb() {
const create = useCreateSubscription();
const navigate = useNavigate();
return {
pending: create.isPending,
subscribe: (c: TmdbCandidate, onDone?: () => void) => {
const kind = c.entityType === "movie" ? "movie" : "series";
create.mutate(
{
titleQuery: c.year ? `${c.title} ${c.year}` : c.title,
mediaKind: kind,
mode: kind === "movie" ? "once" : "continuous",
seasonScope: kind === "movie" ? "current_season" : "following_seasons",
priority: "new_work",
origin: "user",
},
{
onSuccess: () => {
toast.success(`已订阅「${c.title}」`, {
action: { label: "查看", onClick: () => navigate("/console/subscriptions") },
});
onDone?.();
},
onError: (e) => toast.error(errorMessage(e, "订阅失败")),
},
);
},
};
}
export function MediaSection({ q }: { q: string }) {
const res = useSearch(q, "media", 20);
const sub = useSubscribeTmdb();
const local = res.data?.media?.local.items ?? [];
const tmdb = res.data?.media?.tmdb;
return (
<section aria-labelledby="search-media" className="min-w-0">
<SectionTitle
id="search-media"
title="作品"
extra={res.data ? `${local.length + (tmdb?.items.length ?? 0)} 条` : undefined}
/>
<div className="pg-slab min-w-0 overflow-hidden rounded-xl">
{res.isPending ? (
<LoadingState rows={2} className="p-4" />
) : res.isError ? (
<ErrorState plain error={res.error} onRetry={() => res.refetch()} />
) : local.length === 0 && !tmdb?.items.length ? (
<EmptyState
plain
icon={Film}
title="没有匹配的作品"
description="换个译名、原名或别名再搜。"
className="py-8"
/>
) : (
<ul className="divide-y divide-border/60">
{local.map((h) => (
<li key={h.entity.id}>
<Link
to={`/console/library/${h.entity.id}`}
className="flex min-w-0 items-center gap-3 px-4 py-3 transition-colors duration-micro hover:bg-foreground/[0.04]"
>
<Film className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-foreground">
{h.entity.title}
{h.entity.year ? <span className="text-muted-foreground"> ({h.entity.year})</span> : null}
</p>
<p className="truncate text-meta text-muted-foreground">
{mediaKindLabel(h.entity.kind)} · {slotSummary(h)}
{h.matchedAlias && h.matchedAlias !== h.entity.title ? ` · 命中别名 ${h.matchedAlias}` : ""}
</p>
</div>
<StatusBadge>本地</StatusBadge>
</Link>
</li>
))}
{(tmdb?.items ?? []).map((c) => (
<li key={`${c.entityType}-${c.externalId}`} className="flex min-w-0 items-center gap-3 px-4 py-3">
<Plus className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-foreground">
{c.title}
{c.year ? <span className="text-muted-foreground"> ({c.year})</span> : null}
</p>
<p className="line-clamp-1 text-meta text-muted-foreground">
TMDB · {c.entityType === "movie" ? "电影" : "剧集"}
{c.originalTitle && c.originalTitle !== c.title ? ` · ${c.originalTitle}` : ""}
{c.overview ? ` · ${c.overview}` : ""}
</p>
</div>
{c.localEntityId ? (
<Button asChild variant="outline" size="sm">
<Link to={`/console/library/${c.localEntityId}`}>查看</Link>
</Button>
) : (
<Button variant="outline" size="sm" disabled={sub.pending} onClick={() => sub.subscribe(c)}>
订阅
</Button>
)}
</li>
))}
</ul>
)}
{tmdb && !tmdb.configured ? (
<p className="border-t border-border/60 px-4 py-2.5 text-meta text-muted-foreground">
TMDB 未配置,只搜了本地作品。
<Link to={settingsPath("metadata")} className="ml-1 underline underline-offset-4">
去配置
</Link>
</p>
) : null}
{tmdb?.error ? (
<p className="border-t border-border/60 px-4 py-2.5 text-meta text-warning">TMDB 查询失败:{tmdb.error}</p>
) : null}
</div>
</section>
);
}
export function ActionsSection({ q }: { q: string }) {
const res = useSearch(q, "actions", 12);
const items = res.data?.actions?.items ?? [];
if (res.isPending || res.isError || items.length === 0) return null;
return (
<section aria-labelledby="search-actions" className="min-w-0">
<SectionTitle id="search-actions" title="操作" />
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
{items.map((a) => {
const Icon = actionIcon(a.kind);
return (
<Link
key={a.id}
to={a.href}
className="pg-slab flex min-w-0 items-center gap-3 rounded-xl px-4 py-3 transition-colors duration-micro hover:bg-foreground/[0.04]"
>
<Icon className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-foreground">{a.title}</span>
<span className="block truncate text-meta text-muted-foreground">{a.group}</span>
</span>
<ArrowRight className="h-4 w-4 shrink-0 text-muted-foreground" />
</Link>
);
})}
</div>
</section>
);
}
@@ -0,0 +1,48 @@
import { useSyncExternalStore } from "react";
// 命令面板的全局开关:顶栏搜索框、Ctrl/⌘+K 共用。Agent 抽屉用 Ctrl/⌘+J,两者互不占用。
type State = { open: boolean; initial: string };
let state: State = { open: false, initial: "" };
const listeners = new Set<() => void>();
const emit = () => {
for (const l of listeners) l();
};
export function openSearchPalette(initial = "") {
state = { open: true, initial };
emit();
}
export function setSearchPaletteOpen(open: boolean) {
state = { ...state, open };
emit();
}
export function useSearchPalette(): State {
return useSyncExternalStore(
(l) => {
listeners.add(l);
return () => listeners.delete(l);
},
() => state,
() => state,
);
}
/** 快捷键文案:Mac 显示 ⌘K,其余 Ctrl K */
export function searchShortcutLabel(): string {
const mac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
return mac ? "⌘K" : "Ctrl K";
}
/** Ctrl/⌘+K(不带 Shift/Alt);输入框里同样生效 */
export function isSearchShortcut(
e: Pick<KeyboardEvent, "key" | "ctrlKey" | "metaKey" | "shiftKey" | "altKey">,
): boolean {
return (e.ctrlKey || e.metaKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === "k";
}
/** 结果页地址 */
export const searchPagePath = (q: string) => `/console/search?q=${encodeURIComponent(q.trim())}`;
@@ -0,0 +1,41 @@
import { Search } from "lucide-react";
import { useEffect, useState } from "react";
import { Button } from "~/components/ui/button.tsx";
import { cn } from "~/lib/utils.ts";
import { openSearchPalette, searchShortcutLabel } from "./search-store.ts";
/** 顶栏搜索入口:桌面是带快捷键提示的输入框样按钮,手机是图标按钮;都打开命令面板 */
export function SearchTrigger({ className }: { className?: string }) {
// 快捷键文案依赖 navigator,挂载后再算,避免水合不一致
const [shortcut, setShortcut] = useState("Ctrl K");
useEffect(() => setShortcut(searchShortcutLabel()), []);
return (
<>
<button
type="button"
onClick={() => openSearchPalette()}
aria-label="搜索"
aria-keyshortcuts="Control+K Meta+K"
className={cn(
"hidden h-8 w-56 cursor-pointer items-center gap-2 rounded-full border border-border/80 bg-foreground/[0.03] px-3 text-caption text-muted-foreground transition-colors duration-micro outline-none hover:bg-foreground/[0.06] hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 md:flex lg:w-72",
className,
)}
>
<Search className="h-3.5 w-3.5 shrink-0" />
<span className="flex-1 truncate text-left">搜索作品、资源、页面</span>
<kbd className="rounded-tight border border-border/70 px-1 font-sans text-label leading-4 font-medium">
{shortcut}
</kbd>
</button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 md:hidden"
aria-label="搜索"
onClick={() => openSearchPalette()}
>
<Search className="h-4 w-4" />
</Button>
</>
);
}
+3 -1
View File
@@ -1,9 +1,10 @@
import { LibraryRootsTable } from "./instances.tsx";
import { NamingSection } from "./naming.tsx";
import { str } from "./resource-section.tsx";
import { SettingsBlock } from "./settings-block.tsx";
import { type CardSpec, SettingsCard, SettingsGate } from "./settings-card.tsx";
// 媒体库与目录:库根(含哨兵状态)+ 默认整理方式
// 媒体库与目录:库根(含哨兵状态)+ 默认整理方式 + 命名模板
const ORGANIZE_CARD: CardSpec = {
anchor: "organize",
@@ -45,6 +46,7 @@ export function LibrarySection() {
<LibraryRootsTable />
</SettingsBlock>
<SettingsGate>{(s) => <SettingsCard section="library" spec={ORGANIZE_CARD} settings={s} />}</SettingsGate>
<NamingSection />
</div>
);
}
+234
View File
@@ -0,0 +1,234 @@
import type { LibraryRoot, NamingKind, NamingOverride, NamingTemplatesView } from "@umt/contracts";
import { Save, Trash2 } from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { useLibraryRoots } from "~/api/library.ts";
import { useNamingTemplates, useSaveGlobalNaming, useSaveRootNaming } from "~/api/naming.ts";
import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { NAMING_KINDS, overrideBody, SOURCE_LABEL, splitNamingErrors } from "~/components/naming/model.ts";
import { NAMING_KIND_LABEL, NAMING_NOTE, NamingTemplateEditor } from "~/components/naming/template-editor.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
import { Switch } from "~/components/ui/switch.tsx";
import { errorMessage } from "~/lib/api.ts";
import { SettingsBlock } from "./settings-block.tsx";
// 设置 → 媒体库与目录 → 命名模板:全局模板 + 库根覆盖(作品覆盖在作品详情)。
const KINDS = NAMING_KINDS;
export function NamingSection() {
return (
<>
<GlobalNaming />
<RootNaming />
</>
);
}
function GlobalNaming() {
const q = useNamingTemplates();
const save = useSaveGlobalNaming();
const [draft, setDraft] = useState<Record<NamingKind, string> | null>(null);
const [lock, setLock] = useState(false);
const [errors, setErrors] = useState<Partial<Record<NamingKind, string>> | undefined>();
useEffect(() => {
if (q.data && !draft) {
setDraft({ movie: q.data.global.movie, tv: q.data.global.tv, variety: q.data.global.variety });
setLock(q.data.global.lockFileNameForBound);
}
}, [q.data, draft]);
const saved = q.data?.global;
const dirtyKinds = Object.fromEntries(KINDS.map((k) => [k, !!saved && !!draft && draft[k] !== saved[k]]));
const dirty = KINDS.some((k) => dirtyKinds[k]) || (!!saved && lock !== saved.lockFileNameForBound);
const onSave = async () => {
if (!draft) return;
setErrors(undefined);
try {
const r = await save.mutateAsync({ ...draft, lockFileNameForBound: lock });
setDraft({ movie: r.movie, tv: r.tv, variety: r.variety });
setLock(r.lockFileNameForBound);
toast.success("命名模板已保存");
} catch (err) {
const split = splitNamingErrors(errorMessage(err));
if (split) setErrors(split);
toast.error(split ? "模板有错误,未保存" : errorMessage(err, "保存失败"));
}
};
return (
<SettingsBlock
section="library"
anchor="naming"
title="命名模板"
description={NAMING_NOTE}
dirty={dirty}
actions={
<Button size="sm" onClick={onSave} disabled={!dirty || save.isPending || !draft}>
<Save />
{save.isPending ? "保存中…" : "保存"}
</Button>
}
>
{!q.data || !draft ? (
q.isError ? (
<ErrorState plain error={q.error} onRetry={() => q.refetch()} />
) : (
<LoadingState rows={4} />
)
) : (
<div className="space-y-5">
<NamingTemplateEditor
kinds={KINDS}
values={draft}
onChange={(k, v) => {
setDraft((d) => (d ? { ...d, [k]: v } : d));
setErrors((e) => (e?.[k] ? { ...e, [k]: undefined } : e));
}}
defaults={q.data.defaults}
saveErrors={errors}
dirtyKinds={dirtyKinds}
/>
<label
htmlFor="naming-lock-filename"
className="flex cursor-pointer items-start justify-between gap-4 border-t border-border/60 pt-4"
>
<span className="min-w-0">
<span className="block text-body text-foreground">已有目录的作品固定文件名</span>
<span className="block text-meta text-muted-foreground">
开启后,这些作品的新文件按建立目录时的模板版本命名
</span>
</span>
<Switch
id="naming-lock-filename"
checked={lock}
onCheckedChange={setLock}
aria-label="已有目录的作品固定文件名"
/>
</label>
</div>
)}
</SettingsBlock>
);
}
function RootNaming() {
const roots = useLibraryRoots();
const [rootId, setRootId] = useState("");
const list = roots.data?.items ?? [];
const root = list.find((r) => r.id === rootId) ?? list[0];
return (
<SettingsBlock
section="library"
anchor="naming-roots"
title="库根命名覆盖"
description="按类型覆盖全局模板;留空的类型沿用全局。"
>
{roots.isPending ? (
<LoadingState rows={2} />
) : roots.isError ? (
<ErrorState plain error={roots.error} onRetry={() => roots.refetch()} />
) : !root ? (
<p className="text-caption text-muted-foreground">还没有库根。先在上方「库根」添加。</p>
) : (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-2">
<Select value={root.id} onValueChange={setRootId}>
<SelectTrigger size="sm" aria-label="库根" className="max-w-full min-w-48">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
{list.map((r) => (
<SelectItem key={r.id} value={r.id}>
{r.name}
{r.naming && Object.keys(r.naming).length ? "(已覆盖)" : ""}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="min-w-0 truncate font-mono text-meta text-muted-foreground">{root.localPath}</span>
</div>
<RootNamingEditor key={root.id} root={root} />
</div>
)}
</SettingsBlock>
);
}
function RootNamingEditor({ root }: { root: LibraryRoot }) {
const q = useNamingTemplates({ libraryRootId: root.id });
const save = useSaveRootNaming();
const [draft, setDraft] = useState<Partial<Record<NamingKind, string>> | null>(null);
const [errors, setErrors] = useState<Partial<Record<NamingKind, string>> | undefined>();
useEffect(() => {
if (q.data && !draft) setDraft({ ...(q.data.root ?? {}) });
}, [q.data, draft]);
if (q.isError) return <ErrorState plain error={q.error} onRetry={() => q.refetch()} />;
if (q.isPending || !draft) return <LoadingState rows={3} />;
const view: NamingTemplatesView = q.data;
const saved = view.root ?? {};
const dirtyKinds = Object.fromEntries(KINDS.map((k) => [k, (draft[k] ?? "") !== (saved[k] ?? "")]));
const dirty = KINDS.some((k) => dirtyKinds[k]);
const hasOverride = !!view.root && Object.keys(view.root).length > 0;
const put = async (body: NamingOverride | null) => {
setErrors(undefined);
try {
await save.mutateAsync({ id: root.id, naming: body });
setDraft({ ...(body ?? {}) });
toast.success(body ? "库根命名覆盖已保存" : "已清除库根命名覆盖");
} catch (err) {
const split = splitNamingErrors(errorMessage(err));
if (split) setErrors(split);
toast.error(split ? "模板有错误,未保存" : errorMessage(err, "保存失败"));
}
};
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-2">
{KINDS.map((k) => (
<StatusBadge key={k} tone={saved[k] ? "success" : "neutral"}>
{NAMING_KIND_LABEL[k]}:{saved[k] ? "库根覆盖" : "沿用全局"}
</StatusBadge>
))}
<div className="ml-auto flex items-center gap-2">
<Button
variant="ghost"
size="sm"
className="hover:text-destructive"
disabled={!hasOverride || save.isPending}
onClick={() => put(null)}
>
<Trash2 />
清除覆盖
</Button>
<Button size="sm" disabled={!dirty || save.isPending} onClick={() => put(overrideBody(draft))}>
<Save />
{save.isPending ? "保存中…" : "保存覆盖"}
</Button>
</div>
</div>
<NamingTemplateEditor
kinds={KINDS}
values={draft}
onChange={(k, v) => {
setDraft((d) => ({ ...d, [k]: v }));
setErrors((e) => (e?.[k] ? { ...e, [k]: undefined } : e));
}}
defaults={view.defaults}
inherited={Object.fromEntries(KINDS.map((k) => [k, { template: view.global[k], source: SOURCE_LABEL.global }]))}
libraryRootId={root.id}
saveErrors={errors}
dirtyKinds={dirtyKinds}
/>
</div>
);
}
+1
View File
@@ -36,6 +36,7 @@ export default [
route("audit", "routes/console/audit.tsx"),
route("settings", "routes/console/settings.tsx"),
route("schedules", "routes/console/schedules.tsx"),
route("search", "routes/console/search.tsx"),
route("status", "routes/console/status.tsx"),
]),
]),
+4
View File
@@ -10,6 +10,8 @@ import { ErrorState } from "~/components/console/empty-state.tsx";
import { InboxBell } from "~/components/console/inbox-bell.tsx";
import { findNavItem, NAV_GROUPS, type NavGroup, pageTitle } from "~/components/console/nav.ts";
import PrismLeak from "~/components/prism-leak.tsx";
import { CommandPalette } from "~/components/search/command-palette.tsx";
import { SearchTrigger } from "~/components/search/search-trigger.tsx";
import { ThemeToggle } from "~/components/theme-toggle.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "~/components/ui/sheet.tsx";
@@ -233,6 +235,7 @@ function ConsoleShell({ session }: { session: SessionInfo }) {
<span className="truncate text-sm font-semibold text-foreground">{activeLabel}</span>
</div>
<div className="ml-auto flex items-center gap-2">
<SearchTrigger />
{/* 桌面有侧栏主入口与右下角悬浮按钮,顶栏小按钮只在窄屏保留 */}
<InboxBell />
<AgentDrawerTrigger className="xl:hidden" />
@@ -249,6 +252,7 @@ function ConsoleShell({ session }: { session: SessionInfo }) {
</main>
<AgentDrawer />
<AgentLauncher />
<CommandPalette />
</div>
</div>
);
@@ -53,6 +53,7 @@ import {
} from "~/components/library/labels.ts";
import { Chip, ChipList, CoverPlaceholder, Panel, ProgressLine, relTime, Tone } from "~/components/library/parts.tsx";
import { SlotCard } from "~/components/library/slot-card.tsx";
import { EntityNamingButton } from "~/components/naming/entity-naming-dialog.tsx";
import { Button } from "~/components/ui/button.tsx";
import { errorMessage } from "~/lib/api.ts";
import { formatBytes, formatDateTime } from "~/lib/utils.ts";
@@ -644,6 +645,7 @@ export default function LibraryDetailPage() {
合并作品
</Button>
) : null}
<EntityNamingButton entityId={e.id} kind={e.kind} />
</div>
</header>
+12 -408
View File
@@ -1,413 +1,17 @@
import type { PreferenceKind, PreferenceSuppression, PreferenceTag } from "@umt/contracts";
import { Ban, History, ListChecks, Plus, ShieldOff, Tags } from "lucide-react";
import { useMemo, useState } from "react";
import { Link } from "react-router";
import { toast } from "sonner";
import {
useDeleteTag,
useLiftSuppression,
usePreferenceTags,
useSuppressions,
useTagRevisions,
useUpdateTag,
} from "~/api/preferences.ts";
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
import { DetailSection, DetailSheet } from "~/components/console/detail-sheet.tsx";
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx";
import { JsonView } from "~/components/console/json-view.tsx";
import { KvList } from "~/components/console/kv-list.tsx";
import { consolePath } from "~/components/console/nav.ts";
import { PageHeader } from "~/components/console/page-header.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { Timeline, type TimelineItem } from "~/components/console/timeline.tsx";
import { MEDIA_KIND, opts, PREF_KIND, PREF_ORIGIN, PREF_STATUS } from "~/components/library/labels.ts";
import { Chip, Panel, relTime, Tone } from "~/components/library/parts.tsx";
import { PreferenceFormDialog } from "~/components/library/preferences-form.tsx";
import { Button } from "~/components/ui/button.tsx";
import { errorMessage } from "~/lib/api.ts";
import { cn, formatDateTime } from "~/lib/utils.ts";
import { redirect } from "react-router";
// 偏好标签(REQ §6.7):一个偏好一个标签;停用/删除/改语义留抑制记录,Agent 不据同一旧证据自动加回。
// 偏好标签已并入规则页(docs/design/subscriptions.md §6):单条规则以标签形态显示在“规则组”Tab。
// 旧链接带 origin 筛选时透传,规则页按来源筛选。
const SUP_REASON: Record<string, string> = {
deleted: "删除",
"status:disabled": "停用",
"status:deleted": "删除",
edited: "修改语义",
};
const supReason = (r: string | null) => (r ? (SUP_REASON[r] ?? r) : "未注明");
const actorName = (s: string | null) => (s ? s.replace(/^[a-z_]+:/, "") || s : "—");
/** scope 摘要:{} = 全局;mediaKinds 翻中文;其余键原样 */
function scopeSummary(scope: Record<string, unknown>): string {
const keys = Object.keys(scope);
if (keys.length === 0) return "全局";
const parts: string[] = [];
for (const k of keys) {
const v = scope[k];
if (k === "mediaKinds" && Array.isArray(v))
parts.push(v.map((x) => MEDIA_KIND[x as keyof typeof MEDIA_KIND] ?? String(x)).join("、"));
else parts.push(`${k}: ${Array.isArray(v) ? v.join("、") : typeof v === "object" ? "…" : String(v)}`);
}
return parts.join(" · ");
export function clientLoader({ request }: { request: Request }) {
const from = new URL(request.url).searchParams;
const to = new URLSearchParams();
const origin = from.get("origin");
if (origin) to.set("origin", origin);
const qs = to.toString();
return redirect(`/console/rules${qs ? `?${qs}` : ""}`);
}
function TagCard({ tag, onOpen }: { tag: PreferenceTag; onOpen: () => void }) {
const inactive = tag.status !== "active";
return (
<button
type="button"
onClick={onOpen}
className={cn(
"min-w-0 cursor-pointer rounded-xl border border-border/80 bg-foreground/[0.02] p-3 text-left transition-colors duration-micro hover:bg-foreground/[0.05] focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none",
inactive && "opacity-70",
)}
>
<div className="flex items-start justify-between gap-2">
<span className={cn("min-w-0 font-medium break-words text-foreground", inactive && "line-through")}>
{tag.label}
</span>
<Tone v={PREF_STATUS[tag.status]} className="shrink-0" />
</div>
<div className="mt-2 flex flex-wrap gap-1.5">
<Chip>{PREF_KIND[tag.kind].label}</Chip>
<Chip>{scopeSummary(tag.scope)}</Chip>
<Chip>{PREF_ORIGIN[tag.origin]}</Chip>
</div>
{tag.description ? (
<p className="mt-2 line-clamp-2 text-meta break-words text-muted-foreground">{tag.description}</p>
) : null}
<p className="mt-2 text-micro text-muted-foreground tabular-nums">
r{tag.revision} · 更新于 {relTime(tag.updatedAt)}
</p>
</button>
);
}
export default function PreferencesPage() {
const [status, setStatus] = useState("");
const [kind, setKind] = useState("");
const tags = usePreferenceTags({
status: (status || undefined) as "active" | "disabled" | undefined,
kind: (kind || undefined) as PreferenceKind | undefined,
});
const deleted = usePreferenceTags({ status: "deleted" });
const allTags = usePreferenceTags({});
const sups = useSuppressions();
const [selectedId, setSelectedId] = useState<string | null>(null);
const [form, setForm] = useState<{ open: boolean; tag: PreferenceTag | null }>({ open: false, tag: null });
const update = useUpdateTag();
const del = useDeleteTag();
const lift = useLiftSuppression();
const tagById = useMemo(() => {
const m = new Map<string, PreferenceTag>();
for (const t of [...(allTags.data?.items ?? []), ...(deleted.data?.items ?? [])]) m.set(t.id, t);
return m;
}, [allTags.data, deleted.data]);
const selected = selectedId ? (tagById.get(selectedId) ?? tags.data?.items.find((t) => t.id === selectedId)) : null;
const toggle = async (t: PreferenceTag) => {
const next = t.status === "active" ? "disabled" : "active";
try {
await update.mutateAsync({ id: t.id, status: next });
toast.success(next === "active" ? "已启用,抑制已解除" : "已停用");
} catch (err) {
toast.error(errorMessage(err));
}
};
const history = useMemo<TimelineItem[]>(() => {
const items: (TimelineItem & { ts: number })[] = [];
for (const s of sups.data?.items ?? []) {
const label = s.preferenceTagId ? (tagById.get(s.preferenceTagId)?.label ?? "未知标签") : "未关联标签";
items.push({
id: `sup-${s.id}`,
at: s.createdAt,
ts: Date.parse(s.createdAt),
tone: "warning",
title: `「${label}」加入抑制(${supReason(s.reason)})`,
meta: actorName(s.suppressedBy),
});
if (s.liftedAt)
items.push({
id: `lift-${s.id}`,
at: s.liftedAt,
ts: Date.parse(s.liftedAt),
tone: "success",
title: `「${label}」的抑制已解除`,
meta: actorName(s.liftedBy),
});
}
for (const t of deleted.data?.items ?? []) {
if ((sups.data?.items ?? []).some((s) => s.preferenceTagId === t.id && s.reason === "deleted")) continue;
items.push({
id: `del-${t.id}`,
at: t.updatedAt,
ts: Date.parse(t.updatedAt),
tone: "neutral",
title: `「${t.label}」已删除`,
});
}
return items.sort((a, b) => b.ts - a.ts).slice(0, 30);
}, [sups.data, deleted.data, tagById]);
const activeSups = (sups.data?.items ?? []).filter((s) => s.active);
const filtered = !!status || !!kind;
return (
<>
<PageHeader
title="偏好标签"
description="软偏好影响排序,硬规则直接排除。手动取消的偏好 Agent 不会再加回。"
actions={
<>
<Button asChild variant="ghost" size="sm">
<Link to={consolePath("rules")}>
<ListChecks />
收录策略
</Link>
</Button>
<Button onClick={() => setForm({ open: true, tag: null })}>
<Plus />
添加偏好
</Button>
</>
}
/>
<FilterBar
onReset={
filtered
? () => {
setStatus("");
setKind("");
}
: undefined
}
>
<FilterSelect
label="状态"
value={status}
onChange={setStatus}
allLabel="未删除"
options={[
{ value: "active", label: "生效中" },
{ value: "disabled", label: "已停用" },
]}
/>
<FilterSelect label="类型" value={kind} onChange={setKind} options={opts(PREF_KIND)} />
</FilterBar>
<div className="space-y-6">
{tags.isPending ? (
<div className="pg-slab rounded-xl p-4">
<LoadingState rows={3} />
</div>
) : tags.isError ? (
<ErrorState error={tags.error} onRetry={() => tags.refetch()} />
) : tags.data.items.length === 0 ? (
<EmptyState
icon={Tags}
title={filtered ? "没有符合条件的偏好" : "还没有偏好标签"}
description="点「添加偏好」,或在 Agent 会话里说明长期偏好。"
/>
) : (
<Panel title="偏好" description={`${tags.data.items.length} 个标签`}>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{tags.data.items.map((t) => (
<TagCard key={t.id} tag={t} onOpen={() => setSelectedId(t.id)} />
))}
</div>
</Panel>
)}
<Panel title="抑制记录" description={`${activeSups.length} 条生效中`} bodyClassName="space-y-3">
<div className="flex gap-2 rounded-lg border border-warning/30 bg-warning/10 px-3 py-2 text-caption text-foreground">
<ShieldOff className="mt-0.5 h-4 w-4 shrink-0 text-warning" />
<p>停用、删除或修改语义的偏好会留下抑制记录,Agent 不会按旧证据加回。重新启用、重新添加或解除抑制后恢复。</p>
</div>
{sups.isPending ? (
<LoadingState rows={2} />
) : sups.isError ? (
<ErrorState plain error={sups.error} onRetry={() => sups.refetch()} />
) : sups.data.items.length === 0 ? (
<EmptyState plain icon={Ban} title="没有抑制记录" className="py-8" />
) : (
<ul className="divide-y divide-border/60">
{sups.data.items.map((s) => (
<SuppressionRow
key={s.id}
s={s}
label={s.preferenceTagId ? tagById.get(s.preferenceTagId)?.label : undefined}
onLift={async () => {
try {
await lift.mutateAsync({ id: s.id, reason: "用户明确解除" });
toast.success("已解除抑制");
} catch (err) {
toast.error(errorMessage(err));
throw err;
}
}}
/>
))}
</ul>
)}
</Panel>
<Panel title="历史" description="停用、删除、解除抑制(新 → 旧)">
{sups.isPending || deleted.isPending ? (
<LoadingState rows={2} />
) : history.length === 0 ? (
<EmptyState plain icon={History} title="还没有变更历史" className="py-8" />
) : (
<Timeline items={history} />
)}
</Panel>
</div>
<DetailSheet
open={!!selected}
onOpenChange={(o) => !o && setSelectedId(null)}
title={selected?.label ?? ""}
description={selected ? `${PREF_KIND[selected.kind].label} · ${PREF_KIND[selected.kind].hint}` : undefined}
footer={
selected && selected.status !== "deleted" ? (
<>
<ConfirmDialog
trigger={<Button variant="destructive">删除</Button>}
title={`删除「${selected.label}」?`}
description="删除后留下抑制记录,Agent 不会按旧证据加回。"
confirmLabel="删除"
destructive
onConfirm={async () => {
try {
await del.mutateAsync(selected.id);
toast.success("已删除");
setSelectedId(null);
} catch (err) {
toast.error(errorMessage(err));
throw err;
}
}}
/>
<Button variant="outline" onClick={() => toggle(selected)} disabled={update.isPending}>
{selected.status === "active" ? "停用" : "重新启用"}
</Button>
<Button onClick={() => setForm({ open: true, tag: selected })}>编辑</Button>
</>
) : undefined
}
>
{selected ? (
<>
<KvList
items={[
{ label: "状态", value: <Tone v={PREF_STATUS[selected.status]} /> },
{ label: "来源", value: PREF_ORIGIN[selected.origin] },
{ label: "作用范围", value: scopeSummary(selected.scope) },
{ label: "修订", value: `r${selected.revision}` },
{ label: "创建", value: formatDateTime(selected.createdAt) },
{ label: "更新", value: formatDateTime(selected.updatedAt) },
{ label: "说明", value: selected.description, wide: true },
{ label: "语义指纹", value: selected.fingerprint, mono: true, wide: true },
]}
/>
<DetailSection title="规则">
<JsonView value={selected.rule} />
</DetailSection>
<DetailSection title="作用范围">
<JsonView value={selected.scope} />
</DetailSection>
<DetailSection title="证据">
<JsonView value={Object.keys(selected.evidence).length ? selected.evidence : null} />
</DetailSection>
<DetailSection title="修订历史">
<TagRevisions id={selected.id} />
</DetailSection>
</>
) : null}
</DetailSheet>
<PreferenceFormDialog open={form.open} tag={form.tag} onOpenChange={(o) => setForm((f) => ({ ...f, open: o }))} />
</>
);
}
const REV_ACTION = { created: "创建", updated: "修改", deleted: "删除" } as const;
const FIELD_LABEL: Record<string, string> = {
label: "名称",
kind: "类型",
scope: "作用范围",
rule: "规则",
description: "说明",
evidence: "证据",
status: "状态",
};
function TagRevisions({ id }: { id: string }) {
const q = useTagRevisions(id);
if (q.isPending) return <LoadingState rows={2} />;
if (q.isError) return <ErrorState plain error={q.error} onRetry={() => q.refetch()} />;
if (q.data.length === 0)
return <p className="text-caption text-muted-foreground">没有修订记录。W4 之前创建的标签没有历史。</p>;
return (
<ul className="space-y-2">
{q.data.map((r) => (
<li key={r.id} className="rounded-lg border border-border/70 px-3 py-2 text-caption">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium text-foreground">{REV_ACTION[r.action]}</span>
{r.revision ? <span className="text-muted-foreground tabular-nums">r{r.revision}</span> : null}
<span className="text-muted-foreground">{r.actorType === "api_key" ? "API Key" : "用户"}</span>
<span className="ml-auto text-meta text-muted-foreground tabular-nums">{formatDateTime(r.at)}</span>
</div>
<p className="mt-1 text-meta text-muted-foreground">
{r.changedFields.length
? `改动:${r.changedFields.map((f) => FIELD_LABEL[f] ?? f).join("、")}`
: `名称:${r.snapshot.label} · 状态:${r.snapshot.status}`}
</p>
</li>
))}
</ul>
);
}
function SuppressionRow({
s,
label,
onLift,
}: {
s: PreferenceSuppression;
label: string | undefined;
onLift: () => Promise<void>;
}) {
return (
<li className="flex flex-wrap items-center gap-x-3 gap-y-1 py-2.5">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="min-w-0 font-medium break-words text-foreground">{label ?? "未关联标签"}</span>
<Chip>{supReason(s.reason)}</Chip>
{s.active ? <StatusBadge tone="warning">抑制中</StatusBadge> : <StatusBadge>已解除</StatusBadge>}
</div>
<p className="mt-0.5 text-meta text-muted-foreground tabular-nums">
{actorName(s.suppressedBy)} · {formatDateTime(s.createdAt)}
{s.liftedAt ? ` · ${actorName(s.liftedBy)} 于 ${formatDateTime(s.liftedAt)} 解除` : ""}
</p>
</div>
{s.active ? (
<ConfirmDialog
trigger={
<Button variant="outline" size="sm">
解除抑制
</Button>
}
title="解除这条抑制?"
description="解除后,Agent 可按证据再次提出这个偏好,仍需授权。标签不会恢复。"
confirmLabel="解除"
onConfirm={onLift}
/>
) : null}
</li>
);
export default function PreferencesRedirect() {
return null;
}
+180 -19
View File
@@ -1,38 +1,199 @@
import { FilePlus2, Tags } from "lucide-react";
import { useState } from "react";
import { Link } from "react-router";
import { consolePath } from "~/components/console/nav.ts";
import type { RuleGroup, RuleGroupKind, RuleGroupSource } from "@umt/contracts";
import { FilePlus2, Plus } from "lucide-react";
import { useMemo, useState } from "react";
import { useSearchParams } from "react-router";
import { useRuleGroups, useRuleRefs } from "~/api/rules.ts";
import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx";
import { PageHeader } from "~/components/console/page-header.tsx";
import { PolicyWorkspace } from "~/components/library/subscriptions-policy.tsx";
import { RuleGroupEditor } from "~/components/rules/group-editor.tsx";
import { RuleGroupTable, RuleTags } from "~/components/rules/group-list.tsx";
import { isTagLike, RULE_KIND, RULE_SOURCE } from "~/components/rules/labels.ts";
import { AtomsTab, DefaultsTab, SuppressionsTab } from "~/components/rules/tabs.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs.tsx";
// 收录策略(REQ §6.5):草案 → 预览 → 发布;偏好标签在 /console/preferences,排序时叠加生效。
// 规则(docs/design/subscriptions.md §6):收录策略 + 偏好标签合一。
// Tab:规则组(含单条规则的标签形态)/ 自定义条件 / 默认值 / 收录策略 / 抑制记录。
export const meta = () => [{ title: "收录策略 · UHD 媒体自动化控制台" }];
export const meta = () => [{ title: "规则 · UHD 媒体自动化控制台" }];
const TABS = ["groups", "atoms", "defaults", "policies", "suppressions"] as const;
type Tab = (typeof TABS)[number];
/** 旧 /preferences?origin= 透传过来的来源 */
const ORIGIN_TO_SOURCE: Record<string, RuleGroupSource> = {
manual: "manual",
conversation: "chat",
agent_inferred: "agent",
};
export default function RulesPage() {
const [creating, setCreating] = useState(false);
const [params, setParams] = useSearchParams();
const tabParam = params.get("tab") ?? "groups";
const tab: Tab = (TABS as readonly string[]).includes(tabParam) ? (tabParam as Tab) : "groups";
const setParam = (k: string, v: string) =>
setParams(
(p) => {
if (v) p.set(k, v);
else p.delete(k);
return p;
},
{ replace: true },
);
const kind = params.get("kind") ?? "";
const source = params.get("source") ?? ORIGIN_TO_SOURCE[params.get("origin") ?? ""] ?? "";
const enabled = params.get("enabled") ?? "";
const [q, setQ] = useState(params.get("q") ?? "");
const filtered = !!(kind || source || enabled || q.trim());
const groups = useRuleGroups({
kind: (kind || undefined) as RuleGroupKind | undefined,
source: (source || undefined) as RuleGroupSource | undefined,
enabled: (enabled || undefined) as "true" | "false" | undefined,
q: q.trim() || undefined,
});
const all = useRuleGroups({});
const refs = useRuleRefs();
const [editor, setEditor] = useState<{ open: boolean; id: string | null }>({ open: false, id: null });
const [creatingPolicy, setCreatingPolicy] = useState(false);
const items = groups.data?.items;
const tags = useMemo(() => (items ?? []).filter(isTagLike), [items]);
const rest = useMemo(() => items?.filter((g) => !isTagLike(g)), [items]);
const editing: RuleGroup | null = editor.id
? ((items ?? []).find((g) => g.id === editor.id) ?? all.data?.items.find((g) => g.id === editor.id) ?? null)
: null;
const open = (g: RuleGroup) => setEditor({ open: true, id: g.id });
const create = () => setEditor({ open: true, id: null });
return (
<>
<PageHeader
title="收录策略"
description="草案发布后才生效。回滚会用旧版本内容发布一个新版本。"
title="规则"
description="规则组决定哪些资源能下载、同一作品优先挑哪个。收录策略决定哪些新作品自动收录。"
actions={
<>
<Button asChild variant="ghost" size="sm">
<Link to={consolePath("preferences")}>
<Tags />
偏好标签
</Link>
</Button>
<Button onClick={() => setCreating(true)}>
tab === "policies" ? (
<Button onClick={() => setCreatingPolicy(true)}>
<FilePlus2 />
新建策略草案
</Button>
</>
) : (
<Button onClick={create}>
<Plus />
新建规则组
</Button>
)
}
/>
<PolicyWorkspace creating={creating} onCreatingChange={setCreating} />
<Tabs
value={tab}
onValueChange={(v) =>
setParams(
(p) => {
if (v === "groups") p.delete("tab");
else p.set("tab", v);
return p;
},
{ replace: true },
)
}
>
<TabsList aria-label="规则分节">
<TabsTrigger value="groups">规则组</TabsTrigger>
<TabsTrigger value="atoms">自定义条件</TabsTrigger>
<TabsTrigger value="defaults">默认值</TabsTrigger>
<TabsTrigger value="policies">收录策略</TabsTrigger>
<TabsTrigger value="suppressions">抑制记录</TabsTrigger>
</TabsList>
<TabsContent value="groups">
<FilterBar
search={{
value: q,
onChange: (v) => {
setQ(v);
setParam("q", v);
},
placeholder: "搜索名称",
}}
onReset={
filtered
? () => {
setQ("");
setParams(new URLSearchParams(), { replace: true });
}
: undefined
}
>
<FilterSelect
label="类型"
value={kind}
onChange={(v) => setParam("kind", v)}
options={(Object.keys(RULE_KIND) as RuleGroupKind[]).map((k) => ({
value: k,
label: RULE_KIND[k].label,
}))}
/>
<FilterSelect
label="来源"
value={source}
onChange={(v) => {
setParam("origin", "");
setParam("source", v);
}}
options={(Object.keys(RULE_SOURCE) as RuleGroupSource[]).map((k) => ({
value: k,
label: RULE_SOURCE[k],
}))}
/>
<FilterSelect
label="状态"
value={enabled}
onChange={(v) => setParam("enabled", v)}
options={[
{ value: "true", label: "启用" },
{ value: "false", label: "停用" },
]}
/>
</FilterBar>
<div className="space-y-6">
{groups.isSuccess && (tags.length > 0 || !filtered) ? <RuleTags rows={tags} onOpen={open} /> : null}
<RuleGroupTable
rows={rest}
loading={groups.isPending}
error={groups.error}
onRetry={() => groups.refetch()}
refs={refs.data}
filtered={filtered}
onOpen={open}
onCreate={create}
/>
</div>
</TabsContent>
<TabsContent value="atoms">
<AtomsTab />
</TabsContent>
<TabsContent value="defaults">
<DefaultsTab groups={all.data?.items} />
</TabsContent>
<TabsContent value="policies">
<PolicyWorkspace creating={creatingPolicy} onCreatingChange={setCreatingPolicy} />
</TabsContent>
<TabsContent value="suppressions">
<SuppressionsTab />
</TabsContent>
</Tabs>
<RuleGroupEditor
open={editor.open}
group={editing}
defaultKind={(kind || "acquisition") as RuleGroupKind}
onOpenChange={(o) => setEditor((e) => ({ ...e, open: o }))}
/>
</>
);
}
+61
View File
@@ -0,0 +1,61 @@
import { Search } from "lucide-react";
import { type FormEvent, useEffect, useState } from "react";
import { useSearchParams } from "react-router";
import { EmptyState } from "~/components/console/empty-state.tsx";
import { PageHeader } from "~/components/console/page-header.tsx";
import { useQueryState } from "~/components/console/use-query-state.ts";
import { RELEASE_FILTER_KEYS, type ReleaseFilterState, ReleaseResults } from "~/components/search/release-results.tsx";
import { ActionsSection, MediaSection } from "~/components/search/search-sections.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Input } from "~/components/ui/input.tsx";
// 全局搜索结果页(W9-G):/console/search?q= 三段 —— 作品 / 资源 / 操作;q 与资源筛选都写在 URL 里。
export const meta = () => [{ title: "搜索 · UHD 媒体自动化控制台" }];
export default function SearchPage() {
const [params, setParams] = useSearchParams();
const q = (params.get("q") ?? "").trim();
const [input, setInput] = useState(q);
useEffect(() => setInput(q), [q]);
const f = useQueryState(RELEASE_FILTER_KEYS);
const submit = (e: FormEvent) => {
e.preventDefault();
const next = input.trim();
if (!next || next === q) return;
// 换关键词时筛选随之清空(站点/分辨率分面随结果变化)
setParams(new URLSearchParams({ q: next }));
};
return (
<>
<PageHeader title="搜索" description="本地作品、TMDB、各站点资源和控制台页面。" />
<form onSubmit={submit} className="mb-6 flex gap-2">
<div className="relative min-w-0 flex-1 sm:max-w-xl">
<Search className="pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="作品名、别名或资源标题"
aria-label="搜索关键词"
className="pl-9"
/>
</div>
<Button type="submit" disabled={!input.trim()}>
搜索
</Button>
</form>
{!q ? (
<EmptyState icon={Search} title="输入关键词开始搜索" description="也可以在任意页面按 Ctrl/⌘ K 打开搜索。" />
) : (
<div key={q} className="space-y-8">
<MediaSection q={q} />
<ReleaseResults q={q} filters={f.values as ReleaseFilterState} setFilter={f.set} resetFilters={f.reset} />
<ActionsSection q={q} />
</div>
)}
</>
);
}
+8 -1
View File
@@ -37,7 +37,10 @@ const STATIC_ROUTES = [
"/console/subscriptions",
"/console/subscriptions?group=pending&sort=missing",
"/console/rules",
"/console/preferences",
"/console/rules?tab=atoms",
"/console/rules?tab=defaults",
"/console/rules?tab=policies",
"/console/rules?tab=suppressions",
"/console/sites",
"/console/sites?view=list",
"/console/sites?filter=attention",
@@ -52,6 +55,7 @@ const STATIC_ROUTES = [
"/console/agent?mock=1",
"/console/audit",
"/console/status",
"/console/search?q=沙丘",
"/console/settings",
"/console/settings?tab=pi",
// 设置各分区(hash 定位,IA-2)
@@ -91,6 +95,9 @@ const REDIRECT_ROUTES = [
{ from: "/console/tasks?tab=plans", to: "/console/downloads/plans" },
{ from: "/console/tasks?tab=tasks", to: "/console/tasks" },
{ from: "/console/subscriptions?tab=policies", to: "/console/rules" },
// 偏好标签并入规则页(origin 筛选透传)
{ from: "/console/preferences", to: "/console/rules" },
{ from: "/console/preferences?origin=agent_inferred", to: "/console/rules?origin=agent_inferred" },
{ from: "/console/sources?tab=snapshots", to: "/console/sources" },
{ from: "/console/sources?tab=snapshots&siteId=:id", to: "/console/sites/:id?tab=snapshots", list: "/api/v1/sites" },
// 旧的下载详情地址:先查业务任务(404 属预期),再跳到下载详情
+1
View File
@@ -10,6 +10,7 @@
| ContentUnit | 季 / 集;统称"内容单元" | 单元、分集单元 |
| Slot | 版本目标 | Slot、收录目标、目标槽。给模型的说明可写一次"版本目标(slot)"对应参数名 |
| AcquisitionPolicy | 收录策略(版本号称"策略版本") | 收录规则、规则集 |
| RuleGroup | 规则组;条件叫"关键字"(内置)/"自定义条件";只有一条规则的组在规则页以"偏好"标签显示 | 偏好标签、过滤规则、规则集 |
| Subscription | 订阅 | 追剧、关注 |
| Site / SiteAccount | 站点 / 站点账号 | PT 源 |
| FeedSource | Feed | RSS 源、订阅源 |