diff --git a/apps/web/app/api/feeds.ts b/apps/web/app/api/feeds.ts index 30bc440a..9f8e78f7 100644 --- a/apps/web/app/api/feeds.ts +++ b/apps/web/app/api/feeds.ts @@ -6,6 +6,7 @@ import { type FeedSourceCreate, type FeedSourceUpdate, Id, + Observation, } from "@umt/contracts"; import { z } from "zod"; import { TTL_SECOND } from "~/lib/api.ts"; @@ -40,6 +41,18 @@ export function useFeed(id: string | null, poll = 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 { diff --git a/apps/web/app/api/sites.ts b/apps/web/app/api/sites.ts index 69a3b7f6..38e0aae5 100644 --- a/apps/web/app/api/sites.ts +++ b/apps/web/app/api/sites.ts @@ -151,7 +151,11 @@ 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 }), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: siteKeys.all }); + // 非 PT 站添加时可能一并建了 RSS + void qc.invalidateQueries({ queryKey: ["feeds"] }); + }, }); } diff --git a/apps/web/app/components/agent/quick-goals.ts b/apps/web/app/components/agent/quick-goals.ts index 8f69af6f..a5436bc9 100644 --- a/apps/web/app/components/agent/quick-goals.ts +++ b/apps/web/app/components/agent/quick-goals.ts @@ -14,11 +14,11 @@ const RULES: Rule[] = [ }, { match: /^\/console\/sites\/[^/]+/, - goals: ["从这个站点找 ", "看看这个站点最近有哪些新的 4K 资源", "检查这个站点的登录和抓取是否正常"], + goals: ["从这个站点找 ", "看看这个站点最近有哪些新的 4K 资源", "检查这个站点的登录和 RSS 抓取是否正常"], }, { match: /^\/console\/sites\/?$/, - goals: ["在所有站点里找 ", "哪个站点最近抓取失败了,原因是什么", "对比各站点最近一周的新资源数量"], + goals: ["在所有站点里找 ", "哪些站点或 RSS 最近抓取失败了,原因是什么", "对比各站点最近一周的新资源数量"], }, { match: /^\/console\/tasks\/[^/]+/, @@ -48,10 +48,6 @@ const RULES: Rule[] = [ match: /^\/console\/subscriptions/, goals: ["帮我新建一个订阅:", "哪些订阅很久没有新收录了", "检查订阅的收录版本是否合理"], }, - { - match: /^\/console\/sources/, - goals: ["哪些来源不新鲜了,原因是什么", "检查所有 RSS 的抓取状态", "最近一天各来源抓到了什么"], - }, { match: /^\/console\/engine/, goals: ["这个任务为什么失败", "哪些工作流一直在重试", "汇总最近一小时失败的运行"], diff --git a/apps/web/app/components/console/nav.test.ts b/apps/web/app/components/console/nav.test.ts index 08e2d01a..ff47d139 100644 --- a/apps/web/app/components/console/nav.test.ts +++ b/apps/web/app/components/console/nav.test.ts @@ -1,7 +1,15 @@ import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; import { LEGACY_REDIRECTS, legacyRedirect } from "../../routes/console/redirects.ts"; -import { consolePath, findNavItem, NAV_GROUPS, NAV_ITEMS, pageTitle, settingsPath } from "./nav.ts"; +import { + ACQUIRE_GROUP_TITLE, + consolePath, + findNavItem, + NAV_GROUPS, + NAV_ITEMS, + pageTitle, + settingsPath, +} from "./nav.ts"; /** routes.ts 里登记的控制台路由(相对 /console) */ const ROUTE_PATTERNS = [ @@ -16,17 +24,17 @@ const matchesRoute = (pathname: string) => { }; describe("控制台导航", () => { - it("IA-3:5 组,按定稿清单逐项列出,路径唯一且都在 /console 下", () => { - expect(NAV_GROUPS.map((g) => g.title)).toEqual(["概览", "资源", "订阅", "整理", "系统"]); + it("IA-2:4 组,任务归系统,站点 / 订阅 / 规则合为「获取」,没有独立 RSS 项", () => { + expect(NAV_GROUPS.map((g) => g.title)).toEqual(["概览", ACQUIRE_GROUP_TITLE, "整理", "系统"]); + expect(ACQUIRE_GROUP_TITLE).toBe("获取"); expect(NAV_GROUPS.map((g) => g.items.map((i) => i.label))).toEqual([ - ["首页", "任务", "计划", "智能体会话"], - ["站点", "RSS"], - ["订阅", "规则"], + ["首页", "计划", "智能体会话"], + ["站点", "订阅", "规则"], ["下载面板", "整理记录", "文件管理", "媒体库"], - ["设置", "审计", "运行状态"], + ["任务", "设置", "审计", "运行状态"], ]); - // IA-3 逐项清单 14 项 + FE-6 计划页 - expect(NAV_ITEMS).toHaveLength(15); + expect(NAV_ITEMS).toHaveLength(14); + expect(NAV_ITEMS.map((i) => i.to)).not.toContain("/console/sources"); expect(new Set(NAV_ITEMS.map((i) => i.to)).size).toBe(NAV_ITEMS.length); expect(NAV_ITEMS.every((i) => i.to.startsWith("/console"))).toBe(true); }); @@ -87,6 +95,7 @@ describe("旧链接重定向", () => { expect(legacyRedirect("/console/tasks", "?tab=failed")).toBeNull(); expect(legacyRedirect("/console/library", "?filter=unidentified")).toBeNull(); expect(legacyRedirect("/console/subscriptions", "")).toBeNull(); - expect(legacyRedirect("/console/sources", "?health=failing")).toBeNull(); + // 旧 RSS 页的 ?feed= 由 sources.tsx 查所属站点后跳转 + expect(legacyRedirect("/console/sources", "?feed=f1")).toBeNull(); }); }); diff --git a/apps/web/app/components/console/nav.ts b/apps/web/app/components/console/nav.ts index 5405f755..532fb2ea 100644 --- a/apps/web/app/components/console/nav.ts +++ b/apps/web/app/components/console/nav.ts @@ -12,7 +12,6 @@ import { ListChecks, ListTodo, type LucideIcon, - Rss, ScrollText, Settings, } from "lucide-react"; @@ -35,7 +34,13 @@ export function consolePath(...segments: (string | number)[]): string { } /** - * 侧栏 5 组 15 项(IA-3 定稿清单 14 项 + FE-6 从任务页拆出的"计划",docs/plan/00-first-version-plan.md §8):概览 → 资源 → 订阅 → 整理 → 系统。 + * 「获取」组的组名(IA-2):站点、订阅、规则合成一组。用户原话"都算做规则",组名可能再改,只改这一处。 + */ +export const ACQUIRE_GROUP_TITLE = "获取"; + +/** + * 侧栏 4 组 14 项(IA-2,docs/plan/00-first-version-plan.md 末尾 IA-2 条目):概览 → 获取 → 整理 → 系统。 + * 任务归系统;RSS 属于站点(站点详情的 RSS Tab),不再单独占一项。 * 待处理不进侧栏,由顶栏收件箱打开;移动端抽屉用同一份表。 */ export const NAV_GROUPS: NavGroup[] = [ @@ -48,12 +53,6 @@ export const NAV_GROUPS: NavGroup[] = [ icon: House, description: "在忙什么、要我处理什么、最近入库了什么", }, - { - to: "/console/tasks", - label: "任务", - icon: ListTodo, - description: "最近处理了什么、有没有失败", - }, { to: "/console/schedules", label: "计划", @@ -69,20 +68,14 @@ export const NAV_GROUPS: NavGroup[] = [ ], }, { - title: "资源", + title: ACQUIRE_GROUP_TITLE, items: [ { to: "/console/sites", label: "站点", icon: Globe, - description: "站点账号、资源与 RSS", + description: "PT 站、公开 BT 站与 RSS 源:账号、资源与 RSS", }, - { to: "/console/sources", label: "RSS", icon: Rss, description: "全部 RSS 与抓取状态" }, - ], - }, - { - title: "订阅", - items: [ { to: "/console/subscriptions", label: "订阅", icon: CalendarClock, description: "单次收录与持续追踪" }, { to: "/console/rules", label: "规则", icon: ListChecks, description: "收录策略与偏好标签" }, ], @@ -114,6 +107,12 @@ export const NAV_GROUPS: NavGroup[] = [ { title: "系统", items: [ + { + to: "/console/tasks", + label: "任务", + icon: ListTodo, + description: "最近处理了什么、有没有失败", + }, { to: "/console/settings", label: "设置", diff --git a/apps/web/app/components/downloads/business-tasks.tsx b/apps/web/app/components/downloads/business-tasks.tsx index cc2d2e99..04a001fe 100644 --- a/apps/web/app/components/downloads/business-tasks.tsx +++ b/apps/web/app/components/downloads/business-tasks.tsx @@ -81,7 +81,8 @@ export function subjectLink(t: Pick): string case "site": return consolePath("sites", t.subjectId); case "feed_source": - return consolePath("sources"); + // RSS 属于站点(IA-2):按 ?feed= 查所属站点后跳到站点的 RSS Tab + return `${consolePath("sources")}?feed=${encodeURIComponent(t.subjectId)}`; default: return null; } diff --git a/apps/web/app/components/search/site-picker-dialog.tsx b/apps/web/app/components/search/site-picker-dialog.tsx index 2ff10283..eceb2327 100644 --- a/apps/web/app/components/search/site-picker-dialog.tsx +++ b/apps/web/app/components/search/site-picker-dialog.tsx @@ -75,7 +75,10 @@ export function SitePickerDialog({ }) { const sitesQ = useSiteOptions(); const settings = useSettings(); - const sites = useMemo(() => (sitesQ.data?.items ?? []).filter((s) => s.enabled), [sitesQ.data]); + const sites = useMemo( + () => (sitesQ.data?.items ?? []).filter((s) => s.enabled && s.capabilities.includes("search")), + [sitesQ.data], + ); const [pick, setPick] = useState(null); // 打开时取初始值:本机上次 > 全局设置 > 默认 useEffect(() => { diff --git a/apps/web/app/components/sites/search-default-dialog.tsx b/apps/web/app/components/sites/search-default-dialog.tsx index 4bb4a238..02c83a28 100644 --- a/apps/web/app/components/sites/search-default-dialog.tsx +++ b/apps/web/app/components/sites/search-default-dialog.tsx @@ -42,7 +42,7 @@ export function SearchDefaultDialog({ setMode(settings.data.search?.defaultSiteMode ?? "default"); setIds(settings.data.search?.siteIds ?? []); }, [open, settings.data]); - const enabled = sites.filter((s) => s.enabled); + const enabled = sites.filter((s) => s.enabled && s.capabilities.includes("search")); const save = () => update.mutate( diff --git a/apps/web/app/components/sites/site-add-dialog.tsx b/apps/web/app/components/sites/site-add-dialog.tsx index a790a49e..8e49a3bf 100644 --- a/apps/web/app/components/sites/site-add-dialog.tsx +++ b/apps/web/app/components/sites/site-add-dialog.tsx @@ -1,8 +1,19 @@ -import type { SiteDefinitionView, SiteHealthResult, SiteListItem, SiteLoginResult } from "@umt/contracts"; -import { ArrowLeft, Check, LoaderCircle, Search } from "lucide-react"; +import { + cursorPage, + FeedSource, + SITE_KINDS, + type SiteDefinitionView, + type SiteHealthResult, + type SiteKind, + type SiteListItem, + type SiteLoginResult, +} from "@umt/contracts"; +import { ArrowLeft, Check, Globe, LoaderCircle, Rss, Search, Server, ShieldCheck } from "lucide-react"; import { useMemo, useState } from "react"; import { useNavigate } from "react-router"; import { toast } from "sonner"; +import { useFetchFeedNow } from "~/api/feeds.ts"; +import { apiParsed } from "~/api/parse.ts"; import { useCreateSite, useHealthCheck, @@ -31,12 +42,26 @@ import { type CredentialDraft, CredentialFields, credentialBody, emptyCredential import { LoginResultView } from "./site-dialogs.tsx"; import { ADAPTER_LABEL, hostOf, type LoginKind, supportedKinds } from "./site-fields.ts"; -// 添加站点向导(site-center §4.3):① 选定义 → ② 名称 / 域名 / 凭证 → ③ 测试(保存站点与凭证,刷新登录 + 检测健康) +// 添加站点向导(site-center §4.3 + IA-2 站点类型): +// ⓪ 选类型 → PT 站:① 选定义 → ② 名称 / 域名 / 凭证 → ③ 测试(保存站点与凭证,刷新登录 + 检测健康); +// 公开 BT 站 / 自定义 RSS 源:① 名称 / 地址 / RSS 地址 → 保存后立即抓一次,进站点的 RSS Tab。 +// 旧的"未适配站点(自定义 RSS 站点)"入口并入「自定义 RSS 源」类型。 -const CUSTOM = "__custom_rss"; -type Step = 1 | 2 | 3; -const STEP_LABEL = ["选择站点", "填写凭证", "测试"] as const; +type Step = 0 | 1 | 2 | 3; +const PT_STEPS = ["选择类型", "选择站点", "填写凭证", "测试"] as const; +const FEED_STEPS = ["选择类型", "填写地址"] as const; const GROUP_ORDER = ["nexusphp", "mteam_api", "api", "generic_rss"] as const; +const FeedPage = cursorPage(FeedSource); +const KIND_ORDER: SiteKind[] = ["pt", "public_bt", "rss_feed", "torznab"]; +const KIND_ICON = { pt: ShieldCheck, public_bt: Globe, rss_feed: Rss, torznab: Server } as const; + +function isHttpUrl(v: string): boolean { + try { + return /^https?:$/.test(new URL(v.trim()).protocol); + } catch { + return false; + } +} /** 馒头默认 API Key,其余默认 Cookie(验证码站点账密大概率失败) */ function defaultKind(kinds: LoginKind[]): LoginKind { @@ -44,10 +69,13 @@ function defaultKind(kinds: LoginKind[]): LoginKind { return kinds.includes("cookie") ? "cookie" : (kinds[0] ?? "cookie"); } -export function SiteAddDialog({ onClose }: { onClose: () => void }) { +export function SiteAddDialog({ onClose, initialKind }: { onClose: () => void; initialKind?: SiteKind | null }) { const defs = useSiteDefinitions(); const navigate = useNavigate(); - const [step, setStep] = useState(1); + const startKind = initialKind && SITE_KINDS[initialKind]?.implemented ? initialKind : null; + const [kind, setKind] = useState(startKind); + const [step, setStep] = useState(startKind ? 1 : 0); + const [rssUrl, setRssUrl] = useState(""); const [q, setQ] = useState(""); const [defKey, setDefKey] = useState(null); const [form, setForm] = useState({ name: "", baseUrl: "", key: "" }); @@ -63,11 +91,55 @@ export function SiteAddDialog({ onClose }: { onClose: () => void }) { const put = usePutCredentials(); const refresh = useRefreshLogin(); const check = useHealthCheck(); + const fetchFeed = useFetchFeedNow(); + const feedKind = kind !== null && kind !== "pt"; + const stepLabels: readonly string[] = feedKind ? FEED_STEPS : PT_STEPS; + + const chooseKind = (k: SiteKind) => { + setKind(k); + setErrors({}); + if (k !== "pt") setForm({ name: "", baseUrl: "", key: "" }); + setStep(1); + }; + + /** 公开 BT 站 / 自定义 RSS 源:建站点 + RSS(服务端一个事务),立即抓一次,进 RSS Tab */ + const saveFeedSite = async () => { + const fe: Record = {}; + const url = rssUrl.trim(); + const base = form.baseUrl.trim(); + if (!form.name.trim()) fe.name = "填写名称"; + if (kind === "rss_feed" && !isHttpUrl(url)) fe.rssUrl = "填写完整的 RSS 地址(http / https)"; + if (kind === "public_bt" && url && !isHttpUrl(url)) fe.rssUrl = "填写完整的 RSS 地址(http / https)"; + if (kind === "public_bt" && !isHttpUrl(base)) fe.baseUrl = "填完整地址,如 https://example.org"; + if (kind === "rss_feed" && base && !isHttpUrl(base)) fe.baseUrl = "填完整地址,如 https://example.org"; + setErrors(fe); + if (Object.keys(fe).length || !kind) return; + try { + setPhase("保存中…"); + const site = await create.mutateAsync({ + kind, + adapter: "generic_rss", + name: form.name.trim(), + baseUrl: base || new URL(url).origin, + ...(url ? { rssUrl: url } : {}), + }); + if (url) { + const feeds = await apiParsed(FeedPage, "/api/v1/feed-sources", { params: { siteId: site.id } }); + for (const f of feeds.items) fetchFeed.mutate(f.id); + } + toast.success(`已添加「${site.name}」`); + onClose(); + navigate(`${consolePath("sites", site.id)}?tab=rss`); + } catch (err) { + toast.error(errorMessage(err, "保存失败")); + } finally { + setPhase(null); + } + }; const items = defs.data?.items ?? []; const def = items.find((d) => d.key === defKey); - const custom = defKey === CUSTOM; - const kinds: LoginKind[] = custom ? [] : supportedKinds(def); + const kinds: LoginKind[] = supportedKinds(def); const busy = phase !== null; const groups = useMemo(() => { @@ -88,6 +160,93 @@ export function SiteAddDialog({ onClose }: { onClose: () => void }) { setErrors({}); setStep(2); }; + const kindBody = ( +
    + {KIND_ORDER.map((k) => { + const spec = SITE_KINDS[k]; + const Icon = KIND_ICON[k]; + return ( +
  • + +
  • + ); + })} +
+ ); + const feedBody = ( +
{ + e.preventDefault(); + void saveFeedSite(); + }} + className="min-w-0" + > + + setForm((f) => ({ ...f, name: e.target.value }))} + disabled={busy} + /> + + + setRssUrl(e.target.value)} + disabled={busy} + /> + + + setForm((f) => ({ ...f, baseUrl: e.target.value }))} + disabled={busy} + /> + +

不需要账号。保存后立即抓一次 RSS。

+
+ ); const validate = () => { const fe: Record = {}; @@ -98,11 +257,10 @@ export function SiteAddDialog({ onClose }: { onClose: () => void }) { } catch { fe.baseUrl = "填完整地址,如 https://example.org"; } - if (custom && !/^[a-z][a-z0-9_-]*$/.test(form.key.trim())) fe.key = "小写字母开头,只含小写字母、数字、- 和 _"; let body = null; if (kinds.length) { // 账号标签用站点标识:服务端 secret 名按标签生成且全局唯一,多站都叫 default 会冲突 - const b = credentialBody(cred, (custom ? form.key.trim() : def?.key) || "default"); + const b = credentialBody(cred, def?.key || "default"); if (b.errors) Object.assign(fe, b.errors); body = b.body; } @@ -122,9 +280,10 @@ export function SiteAddDialog({ onClose }: { onClose: () => void }) { const base = { name: form.name.trim(), baseUrl: form.baseUrl.trim() }; if (!site) { site = await create.mutateAsync({ - key: custom ? form.key.trim() : (def?.key ?? form.key), - adapter: custom ? "generic_rss" : (def?.adapter ?? "nexusphp"), - snapshotEnabled: !custom, + kind: "pt", + key: def?.key ?? form.key, + adapter: def?.adapter ?? "nexusphp", + snapshotEnabled: true, ...base, }); setCreated(site); @@ -161,25 +320,25 @@ export function SiteAddDialog({ onClose }: { onClose: () => void }) { 添加站点
    - {STEP_LABEL.map((l, i) => ( + {stepLabels.map((l, i) => (
  1. i + 1 + step > i ? "border-foreground bg-foreground text-background" - : step === i + 1 + : step === i ? "border-foreground" : "border-border", )} > - {step > i + 1 ? : i + 1} + {step > i ? : i + 1} {l}
  2. @@ -188,7 +347,11 @@ export function SiteAddDialog({ onClose }: { onClose: () => void }) { - {step === 1 ? ( + {step === 0 ? ( + kindBody + ) : feedKind ? ( + feedBody + ) : step === 1 ? (
    @@ -251,14 +414,14 @@ export function SiteAddDialog({ onClose }: { onClose: () => void }) { ) : null}

    - 未适配站点 + 没有这个站点

    @@ -288,13 +451,7 @@ export function SiteAddDialog({ onClose }: { onClose: () => void }) { disabled={busy} /> - + void }) { disabled={busy} /> - {custom ? ( - - setForm((f) => ({ ...f, key: e.target.value }))} - disabled={busy} - /> - - ) : null}
    {kinds.length ? ( <> @@ -335,7 +480,7 @@ export function SiteAddDialog({ onClose }: { onClose: () => void }) { ) : (

    - 不需要登录凭证。添加后在站点详情的"抓取与 RSS"里新建 RSS。 + 不需要登录凭证。添加后在站点详情的「RSS」里新建 RSS。

    )} @@ -362,7 +507,22 @@ export function SiteAddDialog({ onClose }: { onClose: () => void }) { )} - {step === 2 ? ( + {step === 0 ? ( + + ) : feedKind ? ( + <> + + + + ) : step === 2 ? ( <> ) : ( - )} diff --git a/apps/web/app/components/sites/site-card.tsx b/apps/web/app/components/sites/site-card.tsx index 2db342b7..efd35c8a 100644 --- a/apps/web/app/components/sites/site-card.tsx +++ b/apps/web/app/components/sites/site-card.tsx @@ -14,6 +14,7 @@ import { import { type ReactNode, useState } from "react"; import { Link } from "react-router"; import { toast } from "sonner"; +import { useFeeds } from "~/api/feeds.ts"; import { snapshotStale, useSnapshotSite, useToggleSite, useUpdateSite } from "~/api/sites.ts"; import { DataTable } from "~/components/console/data-table.tsx"; import { consolePath } from "~/components/console/nav.ts"; @@ -29,13 +30,16 @@ import { ACCOUNT_OVERALL, bytes, dataAccount, + hasLogin, healthLine, healthMessage, hostOf, + kindLabel, loginFailureText, overallStatus, primaryAccount, ratioText, + siteCan, } from "./site-fields.ts"; type Checks = ReturnType; @@ -135,18 +139,22 @@ export function SiteMoreMenu({ site, onDialog }: { site: SiteListItem; onDialog: - {item(, updateCredLabel(site), () => onDialog({ kind: "credentials", site }))} + {hasLogin(site) + ? item(, updateCredLabel(site), () => onDialog({ kind: "credentials", site })) + : null} {item(, "编辑站点", () => onDialog({ kind: "edit", site }))} - {item( - , - "抓取最新资源", - () => - snap.mutate( - { id: site.id }, - { onSuccess: () => toast.success("已开始抓取"), onError: (e) => toast.error(errorMessage(e)) }, - ), - { disabled: !site.enabled }, - )} + {siteCan(site, "list") + ? item( + , + "抓取最新资源", + () => + snap.mutate( + { id: site.id }, + { onSuccess: () => toast.success("已开始抓取"), onError: (e) => toast.error(errorMessage(e)) }, + ), + { disabled: !site.enabled }, + ) + : null} {item(, site.enabled ? "停用站点" : "启用站点", () => toggle.mutate( { id: site.id, enabled: !site.enabled }, @@ -194,6 +202,7 @@ function CheckButtons({ site, checks, compact }: Omit & { com } function AccountBadges({ site }: { site: SiteListItem }) { + if (!hasLogin(site)) return null; const st = ACCOUNT_OVERALL[overallStatus(site)]; // 正常不标;登录方式不做徽章(DEAI-2) return st.tone === "success" ? null : 账号{st.label}; @@ -208,7 +217,73 @@ function Stat({ label, children, className }: { label: string; children: ReactNo ); } +/** 没有登录的站点(公开 BT、自定义 RSS 源):卡片只看 RSS */ +function useSiteFeeds(siteId: string) { + // 列表页所有卡片共用一次 /feed-sources 请求 + const feeds = useFeeds(); + const mine = (feeds.data?.items ?? []).filter((f) => f.siteId === siteId); + const enabled = mine.filter((f) => f.enabled); + const failing = enabled.filter((f) => f.healthStatus === "failing" || f.healthStatus === "circuit_open").length; + const lastOk = mine + .map((f) => f.lastSuccessAt) + .filter((x): x is string => !!x) + .sort() + .at(-1); + return { loaded: !!feeds.data, count: mine.length, enabled: enabled.length, failing, lastOk }; +} + +function FeedSiteCard({ site, onDialog }: Omit) { + const f = useSiteFeeds(site.id); + return ( +
    +
    +
    + + {site.name} + +

    {hostOf(site.baseUrl)}

    +
    + +
    +
    + {kindLabel(site.kind)} + {f.failing ? {f.failing} 个 RSS 抓取失败 : null} +
    +
    +
    +
    RSS
    +
    + {!f.loaded + ? "—" + : f.count + ? `${f.count} 个${f.enabled !== f.count ? `,${f.enabled} 个启用` : ""}` + : "还没有"} +
    +
    +
    +
    上次成功
    +
    {relTime(f.lastOk)}
    +
    +
    +
    + + +
    +
    + ); +} + export function SiteCard({ site, checks, onDialog }: Props) { + if (!hasLogin(site)) return ; + return ; +} + +function AccountSiteCard({ site, checks, onDialog }: Props) { const acc = primaryAccount(site.accounts); const dAcc = dataAccount(site.accounts); const u = dAcc?.userData ?? null; @@ -242,6 +317,7 @@ export function SiteCard({ site, checks, onDialog }: Props) {
    + {site.kind && site.kind !== "pt" ? {kindLabel(site.kind)} : null} {u?.vip ? VIP : null} {circuit ? {circuit} : null} @@ -311,7 +387,7 @@ export function SiteCard({ site, checks, onDialog }: Props) {
    - + {siteCan(site, "search") ? : } {acc ? ( ) : ( @@ -371,6 +447,12 @@ export function SiteTable({
    ), }, + { + key: "kind", + header: "类型", + hideBelow: "sm", + cell: (s) => {kindLabel(s.kind)}, + }, { key: "account", header: "账号", @@ -425,6 +507,7 @@ export function SiteTable({ header: "状态", hideBelow: "md", cell: (s) => { + if (!hasLogin(s)) return —; const h = healthLine(s.lastHealth, s.lastHealthAt); return ( @@ -440,7 +523,13 @@ export function SiteTable({ hideBelow: "lg", cell: (s) => ( - {!s.snapshotEnabled ? "未开启" : s.lastSnapshotAt ? relTime(s.lastSnapshotAt) : "还没有"} + {!siteCan(s, "list") + ? "只有 RSS" + : !s.snapshotEnabled + ? "未开启" + : s.lastSnapshotAt + ? relTime(s.lastSnapshotAt) + : "还没有"} ), }, @@ -451,7 +540,7 @@ export function SiteTable({ cell: (s) => (
    - {primaryAccount(s.accounts) ? : null} + {hasLogin(s) && primaryAccount(s.accounts) ? : null}
    ), diff --git a/apps/web/app/components/sites/site-feed-dialog.tsx b/apps/web/app/components/sites/site-feed-dialog.tsx index 3b2df6bd..79f2b7be 100644 --- a/apps/web/app/components/sites/site-feed-dialog.tsx +++ b/apps/web/app/components/sites/site-feed-dialog.tsx @@ -137,8 +137,10 @@ export function SiteFeedDialog({ 保存后显示为 {shown} - ) : ( + ) : site.kind === "pt" ? ( "站点「控制面板 → RSS」里生成的链接" + ) : ( + "RSS 或 Atom 地址" ) } > @@ -149,11 +151,13 @@ export function SiteFeedDialog({ spellCheck={false} value={url} aria-invalid={!!error} - placeholder={`${site.baseUrl.replace(/\/$/, "")}/torrentrss.php?passkey=…`} + placeholder={ + site.kind === "pt" ? `${site.baseUrl.replace(/\/$/, "")}/torrentrss.php?passkey=…` : "https://" + } onChange={(e) => setUrl(e.target.value)} /> - {mismatch ? ( + {mismatch && site.kind !== "rss_feed" ? (

    地址域名与 {site.name}({new URL(site.baseUrl).hostname})不一致,仍可保存;确认没有填错站点。

    diff --git a/apps/web/app/components/sites/site-fields.ts b/apps/web/app/components/sites/site-fields.ts index 6c5ac83a..19a2241e 100644 --- a/apps/web/app/components/sites/site-fields.ts +++ b/apps/web/app/components/sites/site-fields.ts @@ -1,13 +1,16 @@ -import type { - SiteAccount, - SiteAccountOverall, - SiteAdapter, - SiteAuthKind, - SiteDefinitionView, - SiteLastHealth, - SiteListItem, - SiteLoginFailureReason, - SiteRateLimit, +import { + SITE_KINDS, + type SiteAccount, + type SiteAccountOverall, + type SiteAdapter, + type SiteAuthKind, + type SiteCapability, + type SiteDefinitionView, + type SiteKind, + type SiteLastHealth, + type SiteListItem, + type SiteLoginFailureReason, + type SiteRateLimit, } from "@umt/contracts"; import type { StatusTone } from "~/components/console/status-badge.tsx"; import { relTime } from "~/components/workbench/release-fields.ts"; @@ -22,6 +25,21 @@ export const ADAPTER_LABEL: Record = { generic_rss: "通用 RSS", }; +/** 站点类型名(IA-2);旧数据缺 kind 时按 PT 站 */ +export function kindLabel(kind: SiteKind | undefined): string { + return SITE_KINDS[kind ?? "pt"].label; +} + +/** 站点支持某种获取方式(服务端按类型 ∩ 站点定义算好的 capabilities) */ +export function siteCan(site: Pick, cap: SiteCapability): boolean { + return (site.capabilities ?? []).includes(cap); +} + +/** 有登录与账号(凭证、账号数据、刷新登录、检测健康)的站点 */ +export function hasLogin(site: Pick): boolean { + return siteCan(site, "login"); +} + export const ACCOUNT_OVERALL: Record = { active: { label: "正常", tone: "success" }, expired: { label: "已失效", tone: "destructive" }, @@ -80,8 +98,9 @@ export function overallStatus(site: SiteListItem): SiteAccountOverall { return acc?.enabled ? acc.status : "unconfigured"; } -/** 需处理:账号非正常(已配置)或健康故障 */ +/** 需处理:账号非正常(已配置)或健康故障;没有登录的站点只看健康 */ export function needsAttention(site: SiteListItem): boolean { + if (!hasLogin(site)) return site.enabled && site.healthStatus === "failing"; const st = overallStatus(site); return ( site.enabled && (st === "expired" || st === "invalid" || st === "challenge" || site.healthStatus === "failing") diff --git a/apps/web/app/components/sites/site-overview.tsx b/apps/web/app/components/sites/site-overview.tsx index 015b3481..f21a120c 100644 --- a/apps/web/app/components/sites/site-overview.tsx +++ b/apps/web/app/components/sites/site-overview.tsx @@ -1,4 +1,5 @@ import type { SiteListItem } from "@umt/contracts"; +import { useFeeds } from "~/api/feeds.ts"; import { accessSummary, useSiteRequestStats, useSiteUserData } from "~/api/sites.ts"; import { DetailSection } from "~/components/console/detail-sheet.tsx"; import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx"; @@ -11,17 +12,77 @@ import { AUTH_KIND_LABEL, bytes, dataAccount, + hasLogin, healthLine, healthMessage, + kindLabel, overallStatus, primaryAccount, rateLimitText, ratioText, + siteCan, } from "./site-fields.ts"; import { UserDataChart } from "./user-data-chart.tsx"; -/** 站点详情"概览":账号数据磁贴 + 30 天趋势 + 状态 */ +const CAP_LABEL = [ + ["search", "搜索"], + ["list", "列表抓取"], + ["rss", "RSS"], + ["detail", "详情页"], + ["login", "登录"], + ["userinfo", "账号数据"], +] as const; + +/** 可用的获取方式:"搜索 · 列表抓取 · RSS" */ +export function capabilityText(site: Pick): string { + const on = CAP_LABEL.filter(([c]) => siteCan(site, c)).map(([, l]) => l); + return on.length ? on.join(" · ") : "—"; +} + +/** 站点详情"概览":账号数据磁贴 + 30 天趋势 + 状态;没有登录的站点(公开 BT、自定义 RSS 源)只看 RSS */ export function SiteOverview({ site }: { site: SiteListItem }) { + return hasLogin(site) ? : ; +} + +function FeedSiteOverview({ site }: { site: SiteListItem }) { + const feeds = useFeeds({ siteId: site.id }); + const stats = useSiteRequestStats(site.id); + const items = feeds.data?.items ?? []; + const enabled = items.filter((f) => f.enabled); + const failing = enabled.filter((f) => f.healthStatus === "failing" || f.healthStatus === "circuit_open").length; + const lastOk = items + .map((f) => f.lastSuccessAt) + .filter((x): x is string => !!x) + .sort() + .at(-1); + return ( +
    +
    + + + + +
    + + + +
    + ); +} + +function AccountSiteOverview({ site }: { site: SiteListItem }) { const dAcc = dataAccount(site.accounts); const acc = primaryAccount(site.accounts); const u = dAcc?.userData ?? null; @@ -117,6 +178,8 @@ export function SiteOverview({ site }: { site: SiteListItem }) { ), }, { label: "代理", value: site.proxy ? "使用" : "不使用" }, + ...(site.kind && site.kind !== "pt" ? [{ label: "类型", value: kindLabel(site.kind) }] : []), + { label: "获取方式", value: capabilityText(site) }, ]} /> diff --git a/apps/web/app/components/tasks/labels.ts b/apps/web/app/components/tasks/labels.ts index 2e2cbcb9..1ad84733 100644 --- a/apps/web/app/components/tasks/labels.ts +++ b/apps/web/app/components/tasks/labels.ts @@ -119,7 +119,8 @@ export function businessLink(b: EngineRun["business"], input?: unknown): { to: s case "schedule": return { to: `/console/schedules?job=${id}`, label: "定时任务" }; case "feed": - return { to: consolePath("sources"), label: "RSS" }; + // RSS 属于站点(IA-2):旧 RSS 页按 ?feed= 查所属站点后跳到站点的 RSS Tab + return { to: `${consolePath("sources")}?feed=${encodeURIComponent(id)}`, label: "RSS" }; case "site": return { to: `/console/sites/${id}`, label: "站点" }; case "decision": diff --git a/apps/web/app/components/workbench/feed-form.tsx b/apps/web/app/components/workbench/feed-form.tsx index 3950d35b..2742b258 100644 --- a/apps/web/app/components/workbench/feed-form.tsx +++ b/apps/web/app/components/workbench/feed-form.tsx @@ -111,7 +111,8 @@ export function FeedFormDialog({ const raw = { ...(d.name.trim() ? { name: d.name.trim() } : {}), purpose: d.purpose, - siteId: d.siteId || null, + // RSS 属于站点(IA-2):不能改成"不关联";没选站点时由服务端归入同主机的自定义 RSS 源 + ...(d.siteId ? { siteId: d.siteId } : {}), siteAccountId: d.siteAccountId || null, pollIntervalSec: Math.round(Number(d.pollMin) * 60), enabled: d.enabled, @@ -242,12 +243,14 @@ export function FeedFormDialog({ - 不关联站点 - {sites.map((s) => ( - - {s.name} - - ))} + {!d.siteId ? 按地址归入自定义 RSS 源 : null} + {sites + .filter((s) => s.capabilities.includes("rss")) + .map((s) => ( + + {s.name} + + ))} diff --git a/apps/web/app/components/workbench/feed-table.tsx b/apps/web/app/components/workbench/feed-table.tsx index 0d505228..47181581 100644 --- a/apps/web/app/components/workbench/feed-table.tsx +++ b/apps/web/app/components/workbench/feed-table.tsx @@ -1,16 +1,18 @@ import { useQueryClient } from "@tanstack/react-query"; -import { EventType, type FeedHealth, type FeedSource } from "@umt/contracts"; +import { EventType, type FeedHealth, type FeedSource, type Observation } from "@umt/contracts"; import { RotateCcw, Rss, Zap } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { toast } from "sonner"; -import { feedGapHint, feedKeys, useFeedAction, useFeeds, useFetchFeedNow } from "~/api/feeds.ts"; +import { feedGapHint, feedKeys, useFeedAction, useFeedObservations, useFeeds, useFetchFeedNow } from "~/api/feeds.ts"; import { useSiteAccounts, useSites } from "~/api/sites.ts"; import { type DataColumn, DataTable } from "~/components/console/data-table.tsx"; import { DetailSection, DetailSheet } from "~/components/console/detail-sheet.tsx"; +import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx"; import { JsonView } from "~/components/console/json-view.tsx"; import { KvList } from "~/components/console/kv-list.tsx"; import { StatusBadge, type StatusTone } from "~/components/console/status-badge.tsx"; import { SiteFeedDialog } from "~/components/sites/site-feed-dialog.tsx"; +import { hasLogin } from "~/components/sites/site-fields.ts"; import { Button } from "~/components/ui/button.tsx"; import { Switch } from "~/components/ui/switch.tsx"; import { errorMessage } from "~/lib/api.ts"; @@ -78,8 +80,42 @@ function FeedActions({ f }: { f: FeedSource }) { ); } +const OBS_STATUS: Record = { + new: { label: "待识别", tone: "neutral" }, + linked: null, + ignored: { label: "已忽略", tone: "neutral" }, + error: { label: "出错", tone: "warning" }, +}; + +/** RSS 详情里的最近条目:标题 + 发布时间;已关联资源的正常态不加徽章 */ +function RecentItems({ feedId }: { feedId: string }) { + const obs = useFeedObservations(feedId); + if (obs.isPending) return ; + if (obs.error) return void obs.refetch()} />; + // 按发布时间(没有时用首次发现)新的在前;接口按首次发现排序,同一次抓取的条目发现时间相同 + const at = (o: Observation) => o.sourcePublishedAt ?? o.firstSeenAt; + const items = [...(obs.data?.items ?? [])].sort((a, b) => at(b).localeCompare(at(a))); + if (!items.length) return

    还没有抓到条目。

    ; + return ( +
      + {items.map((o) => { + const st = OBS_STATUS[o.status]; + return ( +
    • +

      {o.title}

      + + {st ? {st.label} : null} + {relTime(o.sourcePublishedAt ?? o.firstSeenAt)} + +
    • + ); + })} +
    + ); +} + /** - * Feed 列表 + 详情抽屉 + 新建/编辑对话框。来源健康页列全部,站点详情按 siteId 过滤。 + * RSS 列表 + 详情抽屉 + 新建/编辑对话框。站点详情的 RSS Tab 按 siteId 过滤(IA-2 起没有跨站点的 RSS 页)。 */ /** 抓取完成 / 健康变化(outbox → SSE 桥)到达时节流刷新 Feed 列表 */ function useLiveFeeds() { @@ -106,7 +142,10 @@ export function FeedTable({ creating, onCreatingChange, filtered = false, + openFeedId, }: { + /** 深链打开的 RSS(?feed=) */ + openFeedId?: string | null; siteId?: string; health?: FeedHealth; creating: boolean; @@ -119,7 +158,10 @@ export function FeedTable({ const sites = useSites(); const accounts = useSiteAccounts(); const [editingFeed, setEditingFeed] = useState(null); - const [openId, setOpenId] = useState(null); + const [openId, setOpenId] = useState(openFeedId ?? null); + useEffect(() => { + if (openFeedId) setOpenId(openFeedId); + }, [openFeedId]); const action = useFeedAction(); const siteName = new Map((sites.data?.items ?? []).map((s) => [s.id, s.name])); const accountName = new Map((accounts.data?.items ?? []).map((a) => [a.id, a.label])); @@ -167,17 +209,25 @@ export function FeedTable({ hideBelow: "md", cell: (f) => (f.purpose === "generic" ? null : PURPOSE_LABEL[f.purpose]), }, - { - key: "site", - header: "站点 / 账号", - hideBelow: "lg", - cell: (f) => ( - - {f.siteId ? (siteName.get(f.siteId) ?? "未知站点") : "—"} - {f.siteAccountId ? ` · ${accountName.get(f.siteAccountId) ?? "账号"}` : ""} - - ), - }, + // 站点详情里只列账号(站点已知);没有登录的站点不显示这一列 + ...(scopedSite && !hasLogin(scopedSite) + ? [] + : [ + { + key: "site", + header: siteId ? "账号" : "站点 / 账号", + hideBelow: "lg" as const, + cell: (f: FeedSource) => ( + + {siteId + ? f.siteAccountId + ? (accountName.get(f.siteAccountId) ?? "账号") + : "—" + : `${f.siteId ? (siteName.get(f.siteId) ?? "未知站点") : "—"}${f.siteAccountId ? ` · ${accountName.get(f.siteAccountId) ?? "账号"}` : ""}`} + + ), + }, + ]), { key: "poll", header: "轮询", @@ -287,6 +337,9 @@ export function FeedTable({ ]} /> + + + diff --git a/apps/web/app/routes/console/redirects.ts b/apps/web/app/routes/console/redirects.ts index 344e9985..891f2851 100644 --- a/apps/web/app/routes/console/redirects.ts +++ b/apps/web/app/routes/console/redirects.ts @@ -1,6 +1,6 @@ import { redirect } from "react-router"; -// 控制台 IA 重构后的旧链接 → 新路由(IA-1:docs/design/console-ia.md §3.3;IA-3:§7)。 +// 控制台 IA 重构后的旧链接 → 新路由(IA-1:docs/design/console-ia.md §3.3;IA-3:§7;IA-2 站点类型:RSS 并入站点)。 // 纯函数,各路由模块的 clientLoader 调用;nav.test 与冒烟都读 LEGACY_REDIRECTS。 // 设置页的 ?tab= 旧链接由设置页自己处理(IA-2)。 @@ -26,6 +26,15 @@ export function legacyRedirect(pathname: string, search: string): string | null // IA-3:身份与元数据取消,待识别 / 外部身份 / 别名并入媒体概览 if (path === "/console/identity") return withRest(`/console/library?${IDENTITY_TAB[tab ?? ""] ?? IDENTITY_TAB.pool}`); + // IA-2:独立 RSS 页取消,RSS 在站点详情的 RSS Tab。?feed= 要查所属站点,由 sources.tsx 的 clientLoader 处理 + if (path === "/console/sources") { + const siteId = q.get("siteId"); + if (q.get("feed")) return null; + if (siteId) return `/console/sites/${encodeURIComponent(siteId)}?tab=${tab === "snapshots" ? "snapshots" : "rss"}`; + if (q.get("new")) return "/console/sites?new=rss_feed"; + return "/console/sites"; + } + if (!tab) return null; if (path === "/console/tasks") { // FE-6:计划从任务页拆出,回到独立的计划页(?job= 透传) @@ -37,11 +46,6 @@ export function legacyRedirect(pathname: string, search: string): string | null return null; } if (path === "/console/subscriptions" && tab === "policies") return withRest("/console/rules"); - if (path === "/console/sources" && (tab === "snapshots" || tab === "feeds")) { - const siteId = q.get("siteId"); - if (tab === "snapshots" && siteId) return `/console/sites/${encodeURIComponent(siteId)}?tab=snapshots`; - return withRest("/console/sources"); - } return null; } @@ -53,8 +57,13 @@ export const LEGACY_REDIRECTS: { from: string; to: string }[] = [ { from: "/console/tasks?tab=tasks", to: "/console/tasks" }, { from: "/console/tasks?tab=business", to: "/console/tasks" }, { from: "/console/subscriptions?tab=policies", to: "/console/rules" }, - { from: "/console/sources?tab=snapshots", to: "/console/sources" }, + { from: "/console/sources?tab=snapshots", to: "/console/sites" }, { from: "/console/sources?tab=snapshots&siteId=abc", to: "/console/sites/abc?tab=snapshots" }, + { from: "/console/sources", to: "/console/sites" }, + { from: "/console/sources?health=failing", to: "/console/sites" }, + { from: "/console/sources?siteId=abc", to: "/console/sites/abc?tab=rss" }, + { from: "/console/sources?tab=feeds&siteId=abc", to: "/console/sites/abc?tab=rss" }, + { from: "/console/sources?new=1", to: "/console/sites?new=rss_feed" }, { from: "/console/engine", to: "/console/tasks" }, { from: "/console/engine?status=failed", to: "/console/tasks?status=failed" }, { from: "/console/engine/run-1", to: "/console/tasks?run=run-1" }, diff --git a/apps/web/app/routes/console/sites.$siteId.tsx b/apps/web/app/routes/console/sites.$siteId.tsx index 95f57b40..47973f3e 100644 --- a/apps/web/app/routes/console/sites.$siteId.tsx +++ b/apps/web/app/routes/console/sites.$siteId.tsx @@ -1,4 +1,4 @@ -import type { ResolutionStatus, WorkbenchRelease } from "@umt/contracts"; +import type { FeedHealth, ResolutionStatus, SiteListItem, WorkbenchRelease } from "@umt/contracts"; import { Activity, ArrowLeft, @@ -20,7 +20,6 @@ import { type ReleaseFilters, useSiteReleases } from "~/api/releases.ts"; import { accessSummary, snapshotStale, useSiteRequestStats, useSites, useSnapshotSite } from "~/api/sites.ts"; import { openAgentDrawer } from "~/components/agent/agent-store.ts"; import { SiteOriginPanel } from "~/components/browser/desktop-view.tsx"; -import { DetailSection } from "~/components/console/detail-sheet.tsx"; import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx"; import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx"; import { consolePath } from "~/components/console/nav.ts"; @@ -31,11 +30,20 @@ import { formatQuickFilter, selectedCount, useQuickFilters } from "~/components/ import { SiteAccountsTab } from "~/components/sites/site-accounts-tab.tsx"; import { SiteMoreMenu } from "~/components/sites/site-card.tsx"; import { type SiteDialog, SiteDialogHost, useSiteChecks } from "~/components/sites/site-dialogs.tsx"; -import { ACCOUNT_OVERALL, healthLine, hostOf, overallStatus, primaryAccount } from "~/components/sites/site-fields.ts"; +import { + ACCOUNT_OVERALL, + hasLogin, + healthLine, + hostOf, + kindLabel, + overallStatus, + primaryAccount, + siteCan, +} from "~/components/sites/site-fields.ts"; import { SiteOverview } from "~/components/sites/site-overview.tsx"; import { Button } from "~/components/ui/button.tsx"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs.tsx"; -import { FeedTable } from "~/components/workbench/feed-table.tsx"; +import { FEED_HEALTH, FeedTable } from "~/components/workbench/feed-table.tsx"; import { ReleaseDetailSheet } from "~/components/workbench/release-detail-sheet.tsx"; import { categoryLabel, circuitLabel, isFree, relTime } from "~/components/workbench/release-fields.ts"; import { ReleaseRow } from "~/components/workbench/release-list.tsx"; @@ -91,24 +99,53 @@ function useDebounced(value: T, ms = 400) { return v; } -const TABS = ["overview", "resources", "origin", "snapshots", "accounts"] as const; -/** 旧页签(整理视图)落到"资源" */ -const LEGACY_TAB: Record = { organized: "resources" }; +const TABS = ["overview", "resources", "origin", "snapshots", "rss", "accounts"] as const; +type Tab = (typeof TABS)[number]; +/** 旧页签(整理视图)落到"资源";旧 RSS 页的 ?tab=feeds 落到 RSS */ +const LEGACY_TAB: Record = { organized: "resources", feeds: "rss" }; + +/** 按站点类型与可用获取方式显示的 Tab(IA-2):自定义 RSS 源没有原站视图、抓取、账号 */ +function visibleTabs(s: SiteListItem | undefined): Tab[] { + if (!s) return [...TABS]; + return TABS.filter((t) => { + if (t === "origin") return s.kind !== "rss_feed"; + if (t === "snapshots") return siteCan(s, "list"); + if (t === "rss") return siteCan(s, "rss"); + if (t === "accounts") return hasLogin(s); + return true; + }); +} export const meta = () => [{ title: "站点 · UHD 媒体自动化控制台" }]; +const TAB_LABEL: Record = { + overview: "概览", + resources: "资源", + origin: "原站视图", + snapshots: "抓取", + rss: "RSS", + accounts: "账号", +}; + export default function SiteWorkbenchPage() { const { siteId = "" } = useParams(); const [params, setParams] = useSearchParams(); const navigate = useNavigate(); - const rawTab = params.get("tab") ?? ""; - const mapped = LEGACY_TAB[rawTab] ?? rawTab; - const tab = (TABS as readonly string[]).includes(mapped) ? mapped : "overview"; const [creatingFeed, setCreatingFeed] = useState(false); + const [feedHealth, setFeedHealth] = useState(""); const [dialog, setDialog] = useState(null); const checks = useSiteChecks(setDialog); const sites = useSites(); const snap = useSnapshotSite(); + const site = sites.data?.items.find((x) => x.id === siteId); + const tabs = visibleTabs(site); + const rawTab = params.get("tab") ?? ""; + let mapped = LEGACY_TAB[rawTab] ?? rawTab; + // 只有 RSS 的站点:旧的"抓取与 RSS"深链落到 RSS + if (mapped === "snapshots" && site && !siteCan(site, "list")) mapped = "rss"; + const tab: Tab = (tabs as string[]).includes(mapped) ? (mapped as Tab) : "overview"; + /** 旧 RSS 页 / 任务页的 ?feed=:打开该 RSS 的详情 */ + const feedParam = params.get("feed"); const [qInput, setQInput] = useState(""); const q = useDebounced(qInput.trim()); @@ -152,7 +189,7 @@ export default function SiteWorkbenchPage() { return [...m].map(([value, label]) => ({ value, label })); }, [all, category]); - const s = sites.data?.items.find((x) => x.id === siteId); + const s = site; const refreshFailed = !!releases.error && !!releases.data; // 刷新失败时整页数据都标过期;否则早于 2 个快照周期确认的条目标过期 const staleBefore = refreshFailed ? now + 1 : s ? now - (s.snapshotIntervalSec ?? 3600) * 2000 : null; @@ -187,8 +224,11 @@ export default function SiteWorkbenchPage() { /> ); - const account = s ? ACCOUNT_OVERALL[overallStatus(s)] : null; - const health = s ? healthLine(s.lastHealth, s.lastHealthAt) : null; + const login = !!s && hasLogin(s); + /** 有站点适配器(不只 RSS)才有首页健康检测 */ + const checkable = !!s && s.capabilities.some((c) => c !== "rss"); + const account = s && login ? ACCOUNT_OVERALL[overallStatus(s)] : null; + const health = s && checkable ? healthLine(s.lastHealth, s.lastHealthAt) : null; const circuit = s ? circuitLabel(s) : null; const hasAccount = !!s?.accounts?.some((a) => a.enabled && a.authKind !== "passkey"); @@ -207,6 +247,7 @@ export default function SiteWorkbenchPage() { > {hostOf(s.baseUrl)} + {s.kind !== "pt" ? {kindLabel(s.kind)} : null} {account ? 账号{account.label} : null} {health ? 健康 {health.text} : null} {!s.enabled ? 站点已停用,只显示历史数据 : null} @@ -225,7 +266,7 @@ export default function SiteWorkbenchPage() { 站点 - {s ? ( + {s && checkable ? ( <> - - + {login ? ( + + ) : null} ) : null} + {s ? : null} - + {s && !siteCan(s, "list") ? null : ( + + )} + {s && !siteCan(s, "search") ? null : ( + + )} } > @@ -375,7 +423,9 @@ export default function SiteWorkbenchPage() { ? "没有符合筛选条件的资源" : q ? "本地资源里没有匹配的资源" - : "还没抓取过" + : s && !siteCan(s, "list") + ? "还没有资源" + : "还没抓取过" } description={ all.length @@ -384,9 +434,11 @@ export default function SiteWorkbenchPage() { ? "减少快速筛选条件再看。" : coverage?.liveError ? `去站点搜失败(${coverage.liveError}),站点上可能仍有该资源。` - : q - ? "点「去站点搜」直接在站点搜索。" - : "点「刷新」抓取站点列表。" + : s && !siteCan(s, "list") + ? "RSS 抓到的资源会出现在这里。" + : q + ? "点「去站点搜」直接在站点搜索。" + : "点「刷新」抓取站点列表。" } /> ) : ( @@ -438,21 +490,34 @@ export default function SiteWorkbenchPage() { {s ? : } - -
    - {s ? : } - setCreatingFeed(true)}> - - 新建 RSS - - } - > - - -
    + {s ? : } + + + {/* 原独立 RSS 页的全部能力(IA-2):新建 / 编辑 / 启停 / 立即抓取 / 抓取状态 / 最近条目 */} + setFeedHealth("") : undefined} + actions={ + + } + > + ({ value, label: h.label }))} + /> + + diff --git a/apps/web/app/routes/console/sites.tsx b/apps/web/app/routes/console/sites.tsx index 092259ea..a91250cc 100644 --- a/apps/web/app/routes/console/sites.tsx +++ b/apps/web/app/routes/console/sites.tsx @@ -1,3 +1,4 @@ +import { SITE_KINDS, type SiteKind } from "@umt/contracts"; import { Globe, LayoutGrid, List, PackageSearch, Plus, RefreshCw } from "lucide-react"; import { useEffect, useState } from "react"; import { useSearchParams } from "react-router"; @@ -13,11 +14,15 @@ import { needsAttention, type SortKey, sortSites } from "~/components/sites/site import { Button } from "~/components/ui/button.tsx"; import { cn } from "~/lib/utils.ts"; -// 资源 → 站点:站点、凭证、健康、账号数据的唯一入口(docs/design/site-center.md §4) +// 获取 → 站点:站点、凭证、健康、账号数据、RSS 的唯一入口(docs/design/site-center.md §4); +// IA-2 起站点有类型(PT 站 / 公开 BT 站 / 自定义 RSS 源),列表可按类型筛选,?new=<类型> 直接打开添加。 const VIEW_KEY = "umt.sites.view"; type View = "cards" | "list"; type Filter = "all" | "enabled" | "attention"; +const KIND_OPTIONS = (Object.keys(SITE_KINDS) as SiteKind[]) + .filter((k) => SITE_KINDS[k].implemented) + .map((k) => ({ value: k, label: SITE_KINDS[k].label })); const FILTERS: { value: Filter; label: string }[] = [ { value: "all", label: "全部" }, @@ -45,7 +50,8 @@ export const meta = () => [{ title: "站点 · UHD 媒体自动化控制台" }]; export default function SitesPage() { const sites = useSites(); const [params, setParams] = useSearchParams(); - const [adding, setAdding] = useState(false); + const newParam = params.get("new"); + const [adding, setAdding] = useState(null); const [dialog, setDialog] = useState(null); const checks = useSiteChecks(setDialog); @@ -58,6 +64,7 @@ export default function SitesPage() { const view: View = urlView === "list" || urlView === "cards" ? urlView : storedView; const filter = (FILTERS.find((f) => f.value === params.get("filter"))?.value ?? "all") as Filter; const sort = (SORTS.find((s) => s.value === params.get("sort"))?.value ?? "name") as SortKey; + const kindFilter = KIND_OPTIONS.find((k) => k.value === params.get("kind"))?.value ?? ""; const setParam = (k: string, v: string | null) => setParams( @@ -78,6 +85,14 @@ export default function SitesPage() { setParam("view", v); }; + // 命令面板 / 旧 RSS 页的 ?new=1、?new=rss_feed:打开添加(带类型时跳过选类型) + // biome-ignore lint/correctness/useExhaustiveDependencies: 只在参数出现时处理一次 + useEffect(() => { + if (!newParam) return; + setAdding(newParam in SITE_KINDS ? (newParam as SiteKind) : "any"); + setParam("new", null); + }, [newParam]); + // 待处理"改用 Cookie"深链:?credentials= const credFor = params.get("credentials"); // biome-ignore lint/correctness/useExhaustiveDependencies: 只在数据到达时处理一次 @@ -92,7 +107,11 @@ export default function SitesPage() { const [searchDefaults, setSearchDefaults] = useState(false); const attention = all.filter(needsAttention).length; const shown = sortSites( - all.filter((s) => (filter === "enabled" ? s.enabled : filter === "attention" ? needsAttention(s) : true)), + all.filter( + (s) => + (filter === "enabled" ? s.enabled : filter === "attention" ? needsAttention(s) : true) && + (!kindFilter || s.kind === kindFilter), + ), sort, ); @@ -110,7 +129,7 @@ export default function SitesPage() { 搜索站点 - @@ -130,6 +149,14 @@ export default function SitesPage() { label: f.value === "attention" && attention ? `${f.label} ${attention}` : f.label, }))} /> + {new Set(all.map((s) => s.kind)).size > 1 || kindFilter ? ( + setParam("kind", v || null)} + options={KIND_OPTIONS} + /> + ) : null} setAdding(true)}> + @@ -171,8 +198,8 @@ export default function SitesPage() { ) : !shown.length ? ( ) : view === "list" ? ( @@ -184,7 +211,7 @@ export default function SitesPage() {
    )} - {adding ? setAdding(false)} /> : null} + {adding ? setAdding(null)} /> : null} setDialog(null)} /> diff --git a/apps/web/app/routes/console/sources.tsx b/apps/web/app/routes/console/sources.tsx index 0a285961..49809d22 100644 --- a/apps/web/app/routes/console/sources.tsx +++ b/apps/web/app/routes/console/sources.tsx @@ -1,76 +1,25 @@ -import type { FeedHealth } from "@umt/contracts"; -import { Plus, RefreshCw } from "lucide-react"; -import { useState } from "react"; -import { useFeeds } from "~/api/feeds.ts"; -import { useSites } from "~/api/sites.ts"; -import { DetailSection } from "~/components/console/detail-sheet.tsx"; -import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx"; -import { PageHeader } from "~/components/console/page-header.tsx"; -import { useQueryState } from "~/components/console/use-query-state.ts"; -import { Button } from "~/components/ui/button.tsx"; -import { FEED_HEALTH, FeedTable } from "~/components/workbench/feed-table.tsx"; -import { legacyRedirectLoader } from "./redirects.ts"; +import { FeedSource } from "@umt/contracts"; +import { redirect } from "react-router"; +import { apiParsed } from "~/api/parse.ts"; +import { consolePath } from "~/components/console/nav.ts"; +import { legacyRedirect } from "./redirects.ts"; -// RSS:跨站点的 RSS 列表;站点健康只在站点页(DEAI-2 去掉重复的站点表)。 +// IA-2:独立 RSS 页取消,RSS 属于站点(站点详情的 RSS Tab)。这里只做旧链接跳转,深链不失效: +// ?siteId= → 该站点 RSS Tab;?feed= → 查 RSS 所属站点后打开它的详情;其余 → 站点列表。 export const meta = () => [{ title: "RSS · UHD 媒体自动化控制台" }]; -// 旧的 ?tab=snapshots&siteId= 跳到站点详情的"快照与 Feed" -export const clientLoader = legacyRedirectLoader; +export async function clientLoader({ request }: { request: Request }) { + const url = new URL(request.url); + const feed = url.searchParams.get("feed"); + if (feed) { + const f = await apiParsed(FeedSource, "/api/v1/feed-sources/:id", { pathParams: { id: feed } }).catch(() => null); + if (f?.siteId) return redirect(`${consolePath("sites", f.siteId)}?tab=rss&feed=${encodeURIComponent(feed)}`); + return redirect(consolePath("sites")); + } + return redirect(legacyRedirect(url.pathname, url.search) ?? consolePath("sites")); +} -export default function SourcesPage() { - const f = useQueryState(["health", "siteId"] as const); - const [creating, setCreating] = useState(false); - const sites = useSites(); - const feeds = useFeeds({ - health: (f.values.health || undefined) as FeedHealth | undefined, - siteId: f.values.siteId || undefined, - }); - - return ( - <> - setCreating(true)}> - - 新建 RSS - - } - /> -
    - - void feeds.refetch()} disabled={feeds.isFetching}> - - 刷新 - - } - > - f.set("health", v)} - options={Object.entries(FEED_HEALTH).map(([value, h]) => ({ value, label: h.label }))} - /> - f.set("siteId", v)} - options={(sites.data?.items ?? []).map((s) => ({ value: s.id, label: s.name }))} - /> - - - -
    - - ); +export default function LegacySourcesRedirect() { + return null; }