import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { AuditEvent, cursorPage, Id, Site, SiteAccount, type SiteCreate, type SiteCredentialsBody, SiteCredentialsResult, SiteDefinitionView, SiteHealthResult, SiteListItem, SiteRefreshLoginAccepted, SiteRequestStats, SiteSnapshot, type SiteUpdate, SiteUserDataPoint, } 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)) // 站点中心(W9-A):include=accounts、凭证 PUT、健康检测、刷新登录、用户数据、删除 const SitePage = cursorPage(SiteListItem); 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, requestStats: (siteId: string) => ["sites", "request-stats", siteId] as const, definitions: () => ["sites", "definitions"] as const, userData: (siteId: string, accountId?: string, days = 30) => ["sites", "user-data", siteId, accountId ?? "first", days] as const, loginHistory: (accountId: string) => ["sites", "login-history", accountId] as const, }; /** 站点列表(附账号与整体账号状态,站点页一次请求) */ export function useSites() { return useQuery({ queryKey: siteKeys.list(), queryFn: ({ signal }) => apiParsed(SitePage, "/api/v1/sites", { params: { limit: 200, include: "accounts" }, 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), }); } /** 最近 24 小时对该站的请求次数与生效的访问默认(每分钟刷新) */ export function useSiteRequestStats(siteId: string | undefined) { return useQuery({ queryKey: siteKeys.requestStats(siteId ?? ""), enabled: !!siteId, refetchInterval: 60 * TTL_SECOND, queryFn: ({ signal }) => apiParsed(SiteRequestStats, "/api/v1/sites/:id/request-stats", { pathParams: { id: siteId ?? "" }, signal }), }); } /** 访问默认的一句话说明:"快照每 60 分钟 1 页 · 健康检测每 6 小时 · 详情缓存 24 小时" */ export function accessSummary(s: SiteRequestStats): string { const dur = (sec: number) => (sec % 3600 === 0 ? `${sec / 3600} 小时` : `${Math.round(sec / 60)} 分钟`); const parts = [ `抓取每 ${dur(s.snapshotIntervalSec)} ${s.access.snapshotMaxPages} 页`, `健康检测每 ${dur(s.access.healthCheckIntervalSec)}`, ]; if (s.access.detailCacheSec > 0) parts.push(`详情缓存 ${dur(s.access.detailCacheSec)}`); return parts.join(" · "); } /** 启用 / 停用站点 */ 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, 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 }), }); } /** 修改站点(名称、域名、限流、快照、代理) */ export function useUpdateSite() { const qc = useQueryClient(); return useMutation({ mutationFn: ({ id, body }: { id: string; body: SiteUpdate }) => apiMutate("/api/v1/sites/:id", { method: "PATCH", pathParams: { id }, body }).then((d) => Site.parse(d)), onSuccess: () => qc.invalidateQueries({ queryKey: siteKeys.all }), }); } /** 删除站点:资源标 site_disabled 保留,账号与凭证删除 */ export function useDeleteSite() { const qc = useQueryClient(); return useMutation({ mutationFn: (id: string) => apiMutate("/api/v1/sites/:id", { method: "DELETE", pathParams: { id } }), onSuccess: () => qc.invalidateQueries({ queryKey: siteKeys.all }), }); } /** 写凭证(按 kind 判别);只写不回显,账号回到未验证 */ export function usePutCredentials() { const qc = useQueryClient(); return useMutation({ mutationFn: ({ siteId, body }: { siteId: string; body: SiteCredentialsBody }) => apiMutate("/api/v1/sites/:id/credentials", { method: "PUT", pathParams: { id: siteId }, body }).then((d) => SiteCredentialsResult.parse(d), ), onSuccess: () => qc.invalidateQueries({ queryKey: siteKeys.all }), }); } /** 删除站点账号及其凭证 */ export function useDeleteSiteAccount() { const qc = useQueryClient(); return useMutation({ mutationFn: (id: string) => apiMutate("/api/v1/site-accounts/:id", { method: "DELETE", pathParams: { id } }), onSuccess: () => qc.invalidateQueries({ queryKey: siteKeys.all }), }); } /** 检测健康(同步,≤20 s) */ export function useHealthCheck() { const qc = useQueryClient(); return useMutation({ mutationFn: (id: string) => apiMutate("/api/v1/sites/:id/health-check", { pathParams: { id }, body: {} }).then((d) => SiteHealthResult.parse(d), ), onSettled: () => qc.invalidateQueries({ queryKey: siteKeys.all }), }); } /** 刷新登录;界面上用 wait=true 同步拿结果(≤30 s) */ export function useRefreshLogin() { const qc = useQueryClient(); return useMutation({ mutationFn: ({ id, accountId }: { id: string; accountId?: string }) => apiMutate("/api/v1/sites/:id/refresh-login", { pathParams: { id }, body: { wait: true, ...(accountId ? { accountId } : {}) }, }).then((d) => SiteRefreshLoginAccepted.parse(d)), onSettled: () => qc.invalidateQueries({ queryKey: siteKeys.all }), }); } /** 用户数据按天历史 */ export function useSiteUserData(siteId: string | undefined, accountId?: string, days = 30) { return useQuery({ queryKey: siteKeys.userData(siteId ?? "", accountId, days), enabled: !!siteId, queryFn: ({ signal }) => apiParsed(z.object({ items: z.array(SiteUserDataPoint) }), "/api/v1/sites/:id/user-data", { pathParams: { id: siteId ?? "" }, params: { days, accountId }, signal, }), }); } /** 登录历史:审计里该账号的凭证与登录事件 */ export function useLoginHistory(accountId: string | undefined) { return useQuery({ queryKey: siteKeys.loginHistory(accountId ?? ""), enabled: !!accountId, queryFn: ({ signal }) => apiParsed(cursorPage(AuditEvent), "/api/v1/audit-events", { params: { targetType: "site_account", targetId: accountId, limit: 20 }, signal, }), }); }