feat(web): subscriptions overview groups, coverage matrix view and v2 form (FE-5)
- 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]>
This commit is contained in:
1 parent
4deaaab6fc
commit
b7a7fd3958
13 files changed
+3241
-726
No files matched your search
@@ -1,15 +1,24 @@
|
||||
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";
|
||||
|
||||
@@ -18,6 +27,9 @@ import { getParsed, parseOrThrow } from "./library.ts";
|
||||
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 = {
|
||||
@@ -59,7 +71,11 @@ export function useUpdateSubscription() {
|
||||
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 }),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: subscriptionKeys.all });
|
||||
// best_version 写到版本目标的升级策略
|
||||
void qc.invalidateQueries({ queryKey: ["slots"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -84,8 +100,10 @@ export function useSubscriptionProgress(id: string | null | undefined, enabled =
|
||||
|
||||
/** 手动主动搜索:按标题/别名在已开启站点搜索,结果走同一识别评估链 */
|
||||
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"] }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -97,6 +115,147 @@ export function useSetEpisodeTotal() {
|
||||
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] }),
|
||||
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;
|
||||
}
|
||||
Reference in new issue
Block a user