- Overview: kind tabs, five groups with server counts, card/list views, slot badges, progress (unknown total = "?"), filters in URL, batch actions - Detail /console/subscriptions/🆔 season tabs, episode x version-target matrix with cell sheet (manual fill, plan link), drag slot order, auto-fill switch and fill budget, refresh coverage - Create/edit dialog (basic/advanced): work picker via global search, season/range/total, sites, rule groups, include/exclude, templates, upgrade strategy, downloader/save path; 409 links to existing subscription Co-Authored-By: Claude Opus 5.5 <[email protected]>
262 lines
9.3 KiB
TypeScript
262 lines
9.3 KiB
TypeScript
import { keepPreviousData, useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import {
|
|
AcquisitionSlot,
|
|
cursorPage,
|
|
type EpisodeTotalSet,
|
|
MediaEntity,
|
|
Subscription,
|
|
type SubscriptionBatch,
|
|
SubscriptionBatchResult,
|
|
SubscriptionCoverage,
|
|
type SubscriptionCreate,
|
|
type SubscriptionGroup,
|
|
type SubscriptionMediaKind,
|
|
type SubscriptionOrigin,
|
|
SubscriptionOverviewPage,
|
|
SubscriptionProgress,
|
|
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,
|
|
};
|
|
|
|
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,
|
|
});
|
|
}
|
|
|
|
/** 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;
|
|
}
|