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