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 });
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user