Files
UHD_Media_Tool/apps/web/app/api/sites.ts
T
rootandClaude Opus 5.5 ddb15145d8 feat(web): make the site workbench the home of sites
- 站点工作台: 登记站点 dialog (built-in site definitions or custom).
- Site detail tabs: 整理视图 / 原站视图 / 快照与 Feed / 账号 (account list +
  credential form).
- 来源健康 (was 来源/Feed/快照): cross-site health table + all feeds; site
  names deep-link to the site's 快照与 Feed tab. Feed table extracted to
  components/workbench/feed-table.tsx.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-28 02:46:55 +08:00

111 lines
4.0 KiB
TypeScript

import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { cursorPage, Id, Site, SiteAccount, type SiteCreate, SiteDefinitionView, SiteSnapshot } from "@umt/contracts";
import { z } from "zod";
import { TTL_SECOND } from "~/lib/api.ts";
import { apiMutate, apiParsed } from "./parse.ts";
// 站点、站点账号、站点快照(W2-B:/sites、/site-accounts、/sites/{id}/snapshot(s))
const SitePage = cursorPage(Site);
const AccountPage = cursorPage(SiteAccount);
const SnapshotPage = cursorPage(SiteSnapshot);
export const siteKeys = {
all: ["sites"] as const,
list: () => ["sites", "list"] as const,
one: (id: string) => ["sites", "one", id] as const,
accounts: (siteId?: string) => ["sites", "accounts", siteId ?? "all"] as const,
snapshots: (siteId: string) => ["sites", "snapshots", siteId] as const,
definitions: () => ["sites", "definitions"] as const,
};
export function useSites() {
return useQuery({
queryKey: siteKeys.list(),
queryFn: ({ signal }) => apiParsed(SitePage, "/api/v1/sites", { params: { limit: 200 }, signal }),
});
}
export function useSite(id: string | undefined) {
return useQuery({
queryKey: siteKeys.one(id ?? ""),
enabled: !!id && Id.safeParse(id).success,
queryFn: ({ signal }) => apiParsed(Site, "/api/v1/sites/:id", { pathParams: { id: id ?? "" }, signal }),
});
}
export function useSiteAccounts(siteId?: string) {
return useQuery({
queryKey: siteKeys.accounts(siteId),
queryFn: ({ signal }) =>
apiParsed(AccountPage, "/api/v1/site-accounts", { params: { limit: 200, siteId }, signal }),
});
}
export function useSiteDefinitions() {
return useQuery({
queryKey: siteKeys.definitions(),
staleTime: 5 * 60 * TTL_SECOND,
queryFn: ({ signal }) =>
apiParsed(z.object({ items: z.array(SiteDefinitionView) }), "/api/v1/site-definitions", { signal }),
});
}
/** 快照/补查记录;有 running 时 5 秒轮询 */
export function useSiteSnapshots(siteId: string | undefined, limit = 20) {
return useQuery({
queryKey: siteKeys.snapshots(siteId ?? ""),
enabled: !!siteId,
queryFn: ({ signal }) =>
apiParsed(SnapshotPage, "/api/v1/sites/:id/snapshots", {
pathParams: { id: siteId ?? "" },
params: { limit },
signal,
}),
refetchInterval: (q) => (q.state.data?.items.some((s) => s.status === "running") ? 5 * TTL_SECOND : false),
});
}
/** 启用 / 停用站点 */
export function useToggleSite() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, enabled }: { id: string; enabled: boolean }) =>
apiMutate(`/api/v1/sites/:id/${enabled ? "enable" : "disable"}`, { pathParams: { id } }).then((d) =>
Site.parse(d),
),
onSuccess: () => qc.invalidateQueries({ queryKey: siteKeys.all }),
});
}
/** 手动快照(受站点限流),返回任务 ID */
export function useSnapshotSite() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, maxPages }: { id: string; maxPages?: number }) =>
apiMutate(`/api/v1/sites/:id/snapshot`, { pathParams: { id }, body: maxPages ? { maxPages } : {} }).then((d) =>
z.object({ taskId: Id }).parse(d),
),
onSuccess: (_d, v) => {
qc.invalidateQueries({ queryKey: siteKeys.snapshots(v.id) });
qc.invalidateQueries({ queryKey: siteKeys.list() });
},
});
}
/** 快照新鲜度:从未快照或超过 2 个周期视为过期(与服务端 WorkbenchCoverage.stale 同口径) */
export function snapshotStale(site: Pick<Site, "lastSnapshotAt" | "snapshotIntervalSec">, now = Date.now()) {
if (!site.lastSnapshotAt) return true;
const interval = (site.snapshotIntervalSec ?? 3600) * 1000;
return now - new Date(site.lastSnapshotAt).getTime() > interval * 2;
}
/** 登记站点(POST /sites) */
export function useCreateSite() {
const qc = useQueryClient();
return useMutation({
mutationFn: (body: SiteCreate) => apiMutate("/api/v1/sites", { body }).then((d) => Site.parse(d)),
onSuccess: () => qc.invalidateQueries({ queryKey: siteKeys.all }),
});
}