Files
UHD_Media_Tool/apps/web/app/api/feeds.ts
T
rootandClaude Opus 5.5 614262378e feat(web): nav regroup, site RSS tab and site types (IA-2)
- sidebar: 概览 / 获取 / 整理 / 系统. Tasks moves to 系统; sites,
  subscriptions and rules form the 获取 group (ACQUIRE_GROUP_TITLE in
  nav.ts, the single place to rename it); standalone RSS nav item removed
- site detail gets an RSS tab with everything the RSS page had (create,
  edit, enable, fetch now, fetch state) plus recent items; tabs and
  header actions follow the site's capabilities
- old /console/sources links redirect to the site's RSS tab (?feed= looks
  up the owning site) or the site list; task links to RSS point at sites
- site list shows and filters by type; add-site wizard starts with a type
  step (PT still picks from the definition library; public BT / custom
  RSS source only need name, URL and RSS URL)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-30 00:29:56 +08:00

129 lines
4.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 } 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 }) =>
apiParsed(FeedPage, "/api/v1/feed-sources", { params: cleanParams({ ...f, limit: 200 }), 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;
}