- 版本目标 → 收录版本(前后端用户可见字符串、能力描述、给模型的提示) - Agent 上游 / 上游 → 模型服务;界面上的 Agent / Agent 会话 → 智能体 / 智能体会话 - 身份歧义 / 识别有歧义 / 无法确定作品 → 没识别出作品;待识别(池) → 未识别 - 建档 → 加入媒体库;资产 → 文件;库根 → 媒体库目录;纳管 → 本工具 / 外部任务; 哨兵 → 标记文件 / 检查目录;站点快照 → 抓取;Feed / RSS 订阅 / 来源健康 → RSS - 代码标识符、API 字段名、迁移文件不改;glossary.md 同步;smoke 选择器跟随 Co-Authored-By: Claude Opus 5.5 <[email protected]>
288 lines
10 KiB
TypeScript
288 lines
10 KiB
TypeScript
import { keepPreviousData, useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import {
|
|
AcquisitionSlot,
|
|
cursorPage,
|
|
type EpisodeTotalSet,
|
|
MediaEntity,
|
|
Subscription,
|
|
type SubscriptionBatch,
|
|
SubscriptionBatchResult,
|
|
SubscriptionCalendar,
|
|
SubscriptionCoverage,
|
|
type SubscriptionCreate,
|
|
type SubscriptionGroup,
|
|
type SubscriptionMediaKind,
|
|
type SubscriptionOrigin,
|
|
SubscriptionOverviewPage,
|
|
SubscriptionProgress,
|
|
SubscriptionResetResult,
|
|
type SubscriptionSort,
|
|
type SubscriptionStatus,
|
|
type SubscriptionUpdate,
|
|
} from "@umt/contracts";
|
|
import { z } from "zod";
|
|
import { api } from "~/lib/api.ts";
|
|
import { getParsed, parseOrThrow } from "./library.ts";
|
|
|
|
// 订阅:单次收录 / 持续追踪,仅本季 / 接续后续季(REQ §6.5)
|
|
|
|
export const subscriptionKeys = {
|
|
all: ["subscriptions"] as const,
|
|
list: (f: SubscriptionFilters) => ["subscriptions", "list", f] as const,
|
|
overview: (f: OverviewFilters) => ["subscriptions", "overview", f] as const,
|
|
detail: (id: string) => ["subscriptions", "detail", id] as const,
|
|
coverage: (id: string) => ["subscriptions", "coverage", id] as const,
|
|
calendar: (from: string, to: string, kind?: SubscriptionMediaKind) =>
|
|
["subscriptions", "calendar", from, to, kind ?? ""] as const,
|
|
};
|
|
|
|
export type SubscriptionFilters = {
|
|
status?: SubscriptionStatus;
|
|
mediaEntityId?: string;
|
|
mediaKind?: SubscriptionMediaKind;
|
|
/** 标题 / 原名 / 别名 / 未建档检索词 */
|
|
q?: string;
|
|
};
|
|
|
|
const Page = cursorPage(Subscription);
|
|
|
|
export function useSubscriptions(filters: SubscriptionFilters) {
|
|
return useInfiniteQuery({
|
|
queryKey: subscriptionKeys.list(filters),
|
|
initialPageParam: undefined as string | undefined,
|
|
queryFn: ({ pageParam, signal }) =>
|
|
getParsed(Page, "/subscriptions", { ...filters, cursor: pageParam, limit: 50 }, signal),
|
|
getNextPageParam: (last) => last.nextCursor ?? undefined,
|
|
placeholderData: keepPreviousData,
|
|
});
|
|
}
|
|
|
|
export function useCreateSubscription() {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: (body: SubscriptionCreate) =>
|
|
api.post<unknown>("/subscriptions", body).then((d) => parseOrThrow(Subscription, d, "/subscriptions")),
|
|
onSuccess: () => {
|
|
void qc.invalidateQueries({ queryKey: subscriptionKeys.all });
|
|
void qc.invalidateQueries({ queryKey: ["slots"] });
|
|
void qc.invalidateQueries({ queryKey: ["library"] });
|
|
},
|
|
});
|
|
}
|
|
|
|
export function useUpdateSubscription() {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: ({ id, ...body }: SubscriptionUpdate & { id: string }) =>
|
|
api.patch<unknown>(`/subscriptions/${id}`, body).then((d) => parseOrThrow(Subscription, d, "/subscriptions")),
|
|
onSuccess: () => {
|
|
void qc.invalidateQueries({ queryKey: subscriptionKeys.all });
|
|
// best_version 写到收录版本的升级策略
|
|
void qc.invalidateQueries({ queryKey: ["slots"] });
|
|
},
|
|
});
|
|
}
|
|
|
|
/** 订阅行上显示作品标题(旧 ID 自动跳转);按 ID 缓存 */
|
|
export function useEntityTitle(id: string | null | undefined) {
|
|
return useQuery({
|
|
queryKey: ["library", "entity-row", id ?? ""],
|
|
enabled: !!id,
|
|
staleTime: 5 * 60_000,
|
|
queryFn: ({ signal }) => getParsed(MediaEntity, `/media-entities/${id}`, undefined, signal),
|
|
});
|
|
}
|
|
|
|
/** 订阅进度:季总集数账本 + 每集状态(已覆盖/在途/缺失/疑似缺集)+ Slot 在途与延后(W5-C) */
|
|
export function useSubscriptionProgress(id: string | null | undefined, enabled = true) {
|
|
return useQuery({
|
|
queryKey: ["subscriptions", "progress", id ?? ""],
|
|
enabled: !!id && enabled,
|
|
queryFn: ({ signal }) => getParsed(SubscriptionProgress, `/subscriptions/${id}/progress`, undefined, signal),
|
|
});
|
|
}
|
|
|
|
/** 手动主动搜索:按标题/别名在已开启站点搜索,结果走同一识别评估链 */
|
|
export function useSearchSubscription() {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: (id: string) => api.post<{ runId: string; deduplicated: boolean }>(`/subscriptions/${id}/search`),
|
|
onSuccess: () => void qc.invalidateQueries({ queryKey: ["subscriptions", "overview"] }),
|
|
});
|
|
}
|
|
|
|
/** 手动设置季总集数(不锁定时只升;锁定后元数据刷新不再改写) */
|
|
export function useSetEpisodeTotal() {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: ({ id, ...body }: EpisodeTotalSet & { id: string }) =>
|
|
api
|
|
.post<unknown>(`/subscriptions/${id}/episode-total`, body)
|
|
.then((d) => parseOrThrow(SubscriptionProgress, d, "/subscriptions/episode-total")),
|
|
onSuccess: (_d, v) => {
|
|
void qc.invalidateQueries({ queryKey: ["subscriptions", "progress", v.id] });
|
|
void qc.invalidateQueries({ queryKey: subscriptionKeys.coverage(v.id) });
|
|
void qc.invalidateQueries({ queryKey: ["subscriptions", "overview"] });
|
|
},
|
|
});
|
|
}
|
|
|
|
// ── W9-E 总览 / 覆盖矩阵 / 动作(docs/runbook/subscriptions.md) ──
|
|
|
|
export type OverviewFilters = {
|
|
mediaKind?: SubscriptionMediaKind;
|
|
group?: SubscriptionGroup;
|
|
sort?: SubscriptionSort;
|
|
order?: "asc" | "desc";
|
|
siteId?: string;
|
|
ruleGroupId?: string;
|
|
origin?: SubscriptionOrigin;
|
|
upgrading?: "true" | "false";
|
|
q?: string;
|
|
};
|
|
|
|
/** 总览:条目带分组/进度/版本徽标;counts 是当前筛选(不含 group)下各分组数量 */
|
|
export function useSubscriptionOverview(filters: OverviewFilters) {
|
|
return useInfiniteQuery({
|
|
queryKey: subscriptionKeys.overview(filters),
|
|
initialPageParam: undefined as string | undefined,
|
|
queryFn: ({ pageParam, signal }) =>
|
|
getParsed(SubscriptionOverviewPage, "/subscriptions", { ...filters, cursor: pageParam, limit: 100 }, signal),
|
|
getNextPageParam: (last) => last.nextCursor ?? undefined,
|
|
placeholderData: keepPreviousData,
|
|
});
|
|
}
|
|
|
|
export function useSubscription(id: string | undefined) {
|
|
return useQuery({
|
|
queryKey: subscriptionKeys.detail(id ?? ""),
|
|
enabled: !!id,
|
|
queryFn: ({ signal }) => getParsed(Subscription, `/subscriptions/${id}`, undefined, signal),
|
|
});
|
|
}
|
|
|
|
/** 单条总览条目(详情页页头:进度/徽标/自动补全来源);按作品筛选后取同 id */
|
|
export function useSubscriptionOverviewItem(sub: Subscription | undefined) {
|
|
return useQuery({
|
|
queryKey: ["subscriptions", "overview-item", sub?.id ?? "", sub?.updatedAt ?? ""],
|
|
enabled: !!sub,
|
|
queryFn: async ({ signal }) => {
|
|
const page = await getParsed(
|
|
SubscriptionOverviewPage,
|
|
"/subscriptions",
|
|
{
|
|
...(sub!.mediaEntityId ? { mediaEntityId: sub!.mediaEntityId } : { q: sub!.titleQuery ?? undefined }),
|
|
...(sub!.status === "cancelled" ? { status: "cancelled" } : {}),
|
|
limit: 100,
|
|
},
|
|
signal,
|
|
);
|
|
return page.items.find((x) => x.id === sub!.id) ?? null;
|
|
},
|
|
});
|
|
}
|
|
|
|
/** 覆盖矩阵;refresh() 跳过服务端 30s 缓存 */
|
|
export function useSubscriptionCoverage(id: string | undefined) {
|
|
return useQuery({
|
|
queryKey: subscriptionKeys.coverage(id ?? ""),
|
|
enabled: !!id,
|
|
queryFn: ({ signal }) => getParsed(SubscriptionCoverage, `/subscriptions/${id}/coverage`, undefined, signal),
|
|
});
|
|
}
|
|
|
|
export function useRefreshCoverage() {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: (id: string) => getParsed(SubscriptionCoverage, `/subscriptions/${id}/coverage`, { refresh: "true" }),
|
|
onSuccess: (d, id) => qc.setQueryData(subscriptionKeys.coverage(id), d),
|
|
});
|
|
}
|
|
|
|
function useInvalidateSubs() {
|
|
const qc = useQueryClient();
|
|
return () => {
|
|
void qc.invalidateQueries({ queryKey: subscriptionKeys.all });
|
|
void qc.invalidateQueries({ queryKey: ["slots"] });
|
|
};
|
|
}
|
|
|
|
export type SubscriptionAction = "pause" | "resume" | "resubscribe";
|
|
|
|
/** 暂停 / 恢复 / 重新订阅 */
|
|
export function useSubscriptionAction() {
|
|
const done = useInvalidateSubs();
|
|
return useMutation({
|
|
mutationFn: ({ id, action }: { id: string; action: SubscriptionAction }) =>
|
|
api
|
|
.post<unknown>(`/subscriptions/${id}/${action}`)
|
|
.then((d) => parseOrThrow(Subscription, d, `/subscriptions/${action}`)),
|
|
onSuccess: done,
|
|
});
|
|
}
|
|
|
|
/** 批量:search / pause / resume / delete(删除 = 取消,写审计);逐条返回结果 */
|
|
export function useSubscriptionBatch() {
|
|
const done = useInvalidateSubs();
|
|
return useMutation({
|
|
mutationFn: (body: SubscriptionBatch) =>
|
|
api
|
|
.post<unknown>("/subscriptions/batch", body)
|
|
.then((d) => parseOrThrow(SubscriptionBatchResult, d, "/subscriptions/batch")),
|
|
onSuccess: done,
|
|
});
|
|
}
|
|
|
|
/** 手动补全某版本:不受自动补全开关与补全预算限制 */
|
|
export function useFillSlot() {
|
|
const done = useInvalidateSubs();
|
|
return useMutation({
|
|
mutationFn: ({ id, slotId, unitKeys }: { id: string; slotId: string; unitKeys?: string[] }) =>
|
|
api.post<{ runId: string; deduplicated: boolean }>(
|
|
`/subscriptions/${id}/slots/${slotId}/fill`,
|
|
unitKeys?.length ? { unitKeys } : {},
|
|
),
|
|
onSuccess: done,
|
|
});
|
|
}
|
|
|
|
/** 收录版本排序:第一个成为主版本 */
|
|
export function useSetSlotOrder() {
|
|
const done = useInvalidateSubs();
|
|
return useMutation({
|
|
mutationFn: ({ id, slotIds }: { id: string; slotIds: string[] }) =>
|
|
api
|
|
.put<unknown>(`/subscriptions/${id}/slot-order`, { slotIds })
|
|
.then((d) => parseOrThrow(z.array(AcquisitionSlot), d, "/subscriptions/slot-order")),
|
|
onSuccess: done,
|
|
});
|
|
}
|
|
|
|
/** 重置进度:取消未提交/下载已不在的计划,清延后,重评;仍在下载的保留,不删资产 */
|
|
export function useResetSubscription() {
|
|
const done = useInvalidateSubs();
|
|
return useMutation({
|
|
mutationFn: (id: string) =>
|
|
api
|
|
.post<unknown>(`/subscriptions/${id}/reset`)
|
|
.then((d) => parseOrThrow(SubscriptionResetResult, d, "/subscriptions/reset")),
|
|
onSuccess: done,
|
|
});
|
|
}
|
|
|
|
/** 日历:from / to 为 YYYY-MM-DD,跨度不超过 62 天 */
|
|
export function useSubscriptionCalendar(from: string, to: string, mediaKind?: SubscriptionMediaKind) {
|
|
return useQuery({
|
|
queryKey: subscriptionKeys.calendar(from, to, mediaKind),
|
|
queryFn: ({ signal }) =>
|
|
getParsed(SubscriptionCalendar, "/subscriptions/calendar", { from, to, mediaKind }, signal),
|
|
placeholderData: keepPreviousData,
|
|
});
|
|
}
|
|
|
|
/** 409 重复订阅:消息里带已有订阅 id */
|
|
export function duplicateSubscriptionId(message: string): string | null {
|
|
const m = /已有订阅[::]\s*([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i.exec(message);
|
|
return m?.[1] ?? null;
|
|
}
|