生产 v0.3.8~v0.3.13 均由 8013196 加工作区修改构建部署,此前未提交;本提交补齐源码,
并含未部署的 v0.3.14 原地入库。中间各版本源码状态未保存,无法逐版拆分,详见
docs/plan/17-full-upgrade.md 与 docs/plan/18-organize-release.md。
- v0.3.8 全面升级:Deno workspace 依赖、Router 8 / Vite 8、camofox 1.18.0 / Node 24、
PostgreSQL 18.6、备份客户端 18.6、演示 qB 5.2.4
- v0.3.9 媒体整理:分类规则、整理来源与目录快照(0059)、文件管理整理入口
- v0.3.10 手动下载与入库:独立手动下载、详情页资源下载、TMDB / Local 元数据兜底
(metadata.resolve_organize)、正常多版本 / 一次性洗版(0060)
- v0.3.11 搜索详情与桌面黑边修复
- v0.3.12 列表分页、搜索历史、首页概况
- v0.3.13 统一整理弹窗与队列
- v0.3.14 原地入库(0061,未部署)
同批审计修复:
- 刮削入队:确认原记录存在后才删重放产生的新记录;写回 run 关联失败时保持 queued,
由处理器补关联(对齐 MP 先登记、只在入队失败时记失败)
- 识别升级链只在结果更确定时采用,不再用 no_match 顶替原有歧义候选
- bluray_dir 资产以原盘目录本身为刮削根(MP 同做法)
- metadata.resolve_organize 登记到入库排障 Skill 与能力地图
- db/src/types.ts 按迁移重新生成;contracts 整理动作枚举顺序与数据库对齐
- 测试:原地入库用例改为直接查记录表;domain 测试模板补 version
- 前端:通知铃铛弹层与用户菜单统一样式;站点卡片共用卡头、底栏贴底、分页间距
依赖升级证据只提交摘要文件,registry-snapshot.json 移至 .local/(SHA-256 记于文档)。
Co-Authored-By: Claude Opus 5.5 <[email protected]>
257 lines
9.3 KiB
TypeScript
257 lines
9.3 KiB
TypeScript
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, fetchAllCursorPages } 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 }) =>
|
|
fetchAllCursorPages((cursor) =>
|
|
apiParsed(SitePage, "/api/v1/sites", { params: { limit: 200, include: "accounts", cursor }, 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 }) =>
|
|
fetchAllCursorPages((cursor) =>
|
|
apiParsed(AccountPage, "/api/v1/site-accounts", { params: { limit: 200, siteId, cursor }, 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<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: () => {
|
|
void qc.invalidateQueries({ queryKey: siteKeys.all });
|
|
// 非 PT 站添加时可能一并建了 RSS
|
|
void qc.invalidateQueries({ queryKey: ["feeds"] });
|
|
},
|
|
});
|
|
}
|
|
|
|
/** 修改站点(名称、域名、限流、快照、代理) */
|
|
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,
|
|
}),
|
|
});
|
|
}
|