生产 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]>
131 lines
4.7 KiB
TypeScript
131 lines
4.7 KiB
TypeScript
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||
import {
|
||
cursorPage,
|
||
type FeedHealth,
|
||
FeedSource,
|
||
type FeedSourceCreate,
|
||
type FeedSourceUpdate,
|
||
Id,
|
||
Observation,
|
||
} from "@umt/contracts";
|
||
import { z } from "zod";
|
||
import { TTL_SECOND } from "~/lib/api.ts";
|
||
import { apiMutate, apiParsed, cleanParams, fetchAllCursorPages } from "./parse.ts";
|
||
|
||
// Feed 来源(W2-B:/feed-sources)
|
||
|
||
const FeedPage = cursorPage(FeedSource);
|
||
|
||
export const feedKeys = {
|
||
all: ["feeds"] as const,
|
||
list: (f: { siteId?: string; health?: FeedHealth }) => ["feeds", "list", f] as const,
|
||
};
|
||
|
||
export function useFeeds(f: { siteId?: string; health?: FeedHealth } = {}) {
|
||
return useQuery({
|
||
queryKey: feedKeys.list(f),
|
||
queryFn: ({ signal }) =>
|
||
fetchAllCursorPages((cursor) =>
|
||
apiParsed(FeedPage, "/api/v1/feed-sources", { params: cleanParams({ ...f, limit: 200, cursor }), signal }),
|
||
),
|
||
refetchInterval: 30 * TTL_SECOND,
|
||
});
|
||
}
|
||
|
||
/** 单个 Feed;poll 为真时每 2 秒刷新(站点内添加后等首抓结果) */
|
||
export function useFeed(id: string | null, poll = false) {
|
||
return useQuery({
|
||
queryKey: [...feedKeys.all, "one", id] as const,
|
||
enabled: !!id,
|
||
queryFn: ({ signal }) =>
|
||
apiParsed(FeedSource, "/api/v1/feed-sources/:id", { pathParams: { id: id ?? "" }, signal }),
|
||
refetchInterval: poll ? 2 * TTL_SECOND : false,
|
||
});
|
||
}
|
||
|
||
const ObservationPage = cursorPage(Observation);
|
||
|
||
/** RSS 最近条目(观察记录,新的在前);只在详情打开时请求 */
|
||
export function useFeedObservations(feedId: string | null, limit = 20) {
|
||
return useQuery({
|
||
queryKey: [...feedKeys.all, "observations", feedId, limit] as const,
|
||
enabled: !!feedId,
|
||
queryFn: ({ signal }) =>
|
||
apiParsed(ObservationPage, "/api/v1/observations", { params: { feedId: feedId ?? "", limit }, signal }),
|
||
});
|
||
}
|
||
|
||
/** 同站判定的主机键(与服务端 feedDefaultsForSite 一致):去 www.,取最后两级;IP 原样 */
|
||
export function feedHostKey(u: string): string | null {
|
||
try {
|
||
const host = new URL(u).hostname.toLowerCase().replace(/^www\./, "");
|
||
if (/^[\d.]+$/.test(host) || host.includes(":")) return host;
|
||
return host.split(".").slice(-2).join(".");
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/** 游标窗口里的条目数(最近一次成功抓取的 Feed 条目数);没抓过为 null */
|
||
export function feedWindowCount(f: FeedSource): number | null {
|
||
const w = f.cursor.window as { lastGuidSet?: unknown[] } | undefined;
|
||
return Array.isArray(w?.lastGuidSet) ? w.lastGuidSet.length : null;
|
||
}
|
||
|
||
export function useCreateFeed() {
|
||
const qc = useQueryClient();
|
||
return useMutation({
|
||
mutationFn: (body: FeedSourceCreate) =>
|
||
apiMutate("/api/v1/feed-sources", { body }).then((d) => FeedSource.parse(d)),
|
||
onSuccess: () => qc.invalidateQueries({ queryKey: feedKeys.all }),
|
||
});
|
||
}
|
||
|
||
export function useUpdateFeed() {
|
||
const qc = useQueryClient();
|
||
return useMutation({
|
||
mutationFn: ({ id, body }: { id: string; body: FeedSourceUpdate }) =>
|
||
apiMutate("/api/v1/feed-sources/:id", { method: "PATCH", pathParams: { id }, body }).then((d) =>
|
||
FeedSource.parse(d),
|
||
),
|
||
onSuccess: () => qc.invalidateQueries({ queryKey: feedKeys.all }),
|
||
});
|
||
}
|
||
|
||
/** 启停 / 熔断复位 */
|
||
export function useFeedAction() {
|
||
const qc = useQueryClient();
|
||
return useMutation({
|
||
mutationFn: ({ id, action }: { id: string; action: "enable" | "disable" | "reset" }) =>
|
||
apiMutate(`/api/v1/feed-sources/:id/${action}`, { pathParams: { id } }).then((d) => FeedSource.parse(d)),
|
||
onSuccess: () => qc.invalidateQueries({ queryKey: feedKeys.all }),
|
||
});
|
||
}
|
||
|
||
/** 立即抓取(仍受站点限流),返回任务 ID */
|
||
export function useFetchFeedNow() {
|
||
const qc = useQueryClient();
|
||
return useMutation({
|
||
mutationFn: (id: string) =>
|
||
apiMutate("/api/v1/feed-sources/:id/fetch", { pathParams: { id } }).then((d) =>
|
||
z.object({ taskId: Id }).parse(d),
|
||
),
|
||
onSuccess: () => qc.invalidateQueries({ queryKey: feedKeys.all }),
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 疑似缺口:服务端在 Feed 窗口与上次零重叠时写 possibleGap 并排站点补漏;下一次抓取衔接上后清空。
|
||
* 没有服务端标记但"距上次成功超过 3 个周期"时,只作推断提示(标"可能")。
|
||
*/
|
||
export function feedGapHint(f: FeedSource, now = Date.now()): string | null {
|
||
if (f.possibleGap) {
|
||
const at = new Date(f.possibleGap.detectedAt).toLocaleString("zh-CN", { hour12: false });
|
||
return `疑似缺口(${at} 检测,${f.possibleGap.backfillQueued ? "已排补漏" : "RSS 未关联站点,无法自动补漏"})`;
|
||
}
|
||
if (!f.enabled || !f.lastSuccessAt) return null;
|
||
const gapMs = now - new Date(f.lastSuccessAt).getTime();
|
||
if (gapMs > f.pollIntervalSec * 1000 * 3) return "距上次成功已超过 3 个周期,可能漏项";
|
||
return null;
|
||
}
|