diff --git a/apps/web/app/api/sites.ts b/apps/web/app/api/sites.ts index 975e31bd..83f179ef 100644 --- a/apps/web/app/api/sites.ts +++ b/apps/web/app/api/sites.ts @@ -1,5 +1,5 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { cursorPage, Id, Site, SiteAccount, SiteDefinitionView, SiteSnapshot } from "@umt/contracts"; +import { cursorPage, Id, Site, SiteAccount, type SiteCreate, SiteDefinitionView, SiteSnapshot } from "@umt/contracts"; import { z } from "zod"; import { TTL_SECOND } from "~/lib/api.ts"; import { apiMutate, apiParsed } from "./parse.ts"; @@ -99,3 +99,12 @@ export function snapshotStale(site: Pick 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 }), + }); +} diff --git a/apps/web/app/components/workbench/feed-table.tsx b/apps/web/app/components/workbench/feed-table.tsx new file mode 100644 index 00000000..b7688c9e --- /dev/null +++ b/apps/web/app/components/workbench/feed-table.tsx @@ -0,0 +1,278 @@ +import type { FeedHealth, FeedSource } from "@umt/contracts"; +import { RotateCcw, Rss, Zap } from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; +import { feedGapHint, useFeedAction, 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 { 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 { Button } from "~/components/ui/button.tsx"; +import { Switch } from "~/components/ui/switch.tsx"; +import { errorMessage } from "~/lib/api.ts"; +import { formatDateTime } from "~/lib/utils.ts"; +import { FeedFormDialog, PURPOSE_LABEL } from "./feed-form.tsx"; +import { relTime } from "./release-fields.ts"; + +export const FEED_HEALTH: Record = { + unknown: { label: "未抓取", tone: "neutral" }, + healthy: { label: "健康", tone: "success" }, + degraded: { label: "降级", tone: "warning" }, + failing: { label: "失败", tone: "destructive" }, + circuit_open: { label: "熔断", tone: "destructive" }, + disabled: { label: "已停用", tone: "neutral" }, +}; + +const lastErrorText = (f: FeedSource) => + f.lastError ? String((f.lastError as { message?: unknown }).message ?? JSON.stringify(f.lastError)) : null; + +/** 游标窗口摘要:服务端 cursor.window 的条目数与上次抓取时间 */ +function cursorSummary(f: FeedSource): string { + const w = f.cursor.window as { lastFetchedAt?: string | null; lastGuidSet?: unknown[] } | undefined; + if (!w) return "尚无游标"; + const n = Array.isArray(w.lastGuidSet) ? w.lastGuidSet.length : null; + return `${n !== null ? `窗口 ${n} 条` : "窗口"}${w.lastFetchedAt ? ` · ${relTime(w.lastFetchedAt)}` : ""}`; +} + +function FeedActions({ f }: { f: FeedSource }) { + const fetchNow = useFetchFeedNow(); + const action = useFeedAction(); + return ( +
+ e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + onCheckedChange={(v) => + action.mutate( + { id: f.id, action: v ? "enable" : "disable" }, + { onSuccess: () => toast.success(v ? "已启用" : "已停用"), onError: (e) => toast.error(errorMessage(e)) }, + ) + } + /> + +
+ ); +} + +/** + * Feed 列表 + 详情抽屉 + 新建/编辑对话框。来源健康页列全部,站点详情按 siteId 过滤。 + */ +export function FeedTable({ + siteId, + health, + creating, + onCreatingChange, + filtered = false, +}: { + siteId?: string; + health?: FeedHealth; + creating: boolean; + onCreatingChange: (v: boolean) => void; + /** 列表外层有筛选条件(影响空态文案) */ + filtered?: boolean; +}) { + const feeds = useFeeds({ health, siteId }); + const sites = useSites(); + const accounts = useSiteAccounts(); + const [editingFeed, setEditingFeed] = useState(null); + const [openId, setOpenId] = useState(null); + 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])); + const open = feeds.data?.items.find((f) => f.id === openId) ?? null; + const editing: FeedSource | "new" | null = creating ? "new" : editingFeed; + const setEditing = (v: FeedSource | null) => { + setEditingFeed(v); + if (!v) onCreatingChange(false); + }; + + const columns: DataColumn[] = [ + { + key: "name", + header: "名称", + cell: (f) => ( +
+

{f.name}

+

+ {f.urlDisplay} +

+
+ ), + }, + { + key: "health", + header: "健康", + cell: (f) => { + const gap = feedGapHint(f); + return ( +
+ {FEED_HEALTH[f.healthStatus].label} + {gap ? ( + + {f.possibleGap ? "疑似缺口" : "可能漏项"} + + ) : null} +
+ ); + }, + }, + { key: "purpose", header: "用途", hideBelow: "md", cell: (f) => PURPOSE_LABEL[f.purpose] }, + { + key: "site", + header: "站点 / 账号", + hideBelow: "lg", + cell: (f) => ( + + {f.siteId ? (siteName.get(f.siteId) ?? "未知站点") : "—"} + {f.siteAccountId ? ` · ${accountName.get(f.siteAccountId) ?? "账号"}` : ""} + + ), + }, + { + key: "poll", + header: "轮询", + hideBelow: "lg", + cell: (f) => {Math.round(f.pollIntervalSec / 60)} 分钟, + }, + { + key: "success", + header: "最后成功", + hideBelow: "sm", + cell: (f) => ( +
+

{relTime(f.lastSuccessAt)}

+ {f.consecutiveFailures ? ( +

连续失败 {f.consecutiveFailures}

+ ) : null} +
+ ), + }, + { key: "ops", header: "", align: "right", cell: (f) => }, + ]; + + return ( + <> + f.id} + loading={feeds.isPending} + error={feeds.error} + onRetry={() => void feeds.refetch()} + onRowClick={(f) => setOpenId(f.id)} + selectedKey={openId} + empty={{ + icon: Rss, + title: filtered ? "没有符合筛选的 Feed" : "还没有 Feed", + description: filtered ? undefined : "新建 Feed 后按轮询间隔抓取,新资源进入识别和收录评估。", + }} + /> + + !o && setOpenId(null)} + title={open?.name ?? ""} + description={open ? {open.urlDisplay} : undefined} + footer={ + open ? ( + <> + {open.healthStatus === "circuit_open" || open.healthStatus === "failing" || open.backoffUntil ? ( + + ) : null} + + + ) : null + } + > + {open ? ( + <> +
+ + {FEED_HEALTH[open.healthStatus].label} + + {open.enabled ? "已启用" : "已停用"} + {PURPOSE_LABEL[open.purpose]} +
+ {feedGapHint(open) ? ( +

+ {feedGapHint(open)} +

+ ) : null} + {lastErrorText(open) ? ( +

+ {lastErrorText(open)} +

+ ) : null} + + + + + + + ) : null} +
+ + {editing ? ( + !o && setEditing(null)} + feed={editing === "new" ? null : editing} + sites={sites.data?.items ?? []} + accounts={accounts.data?.items ?? []} + /> + ) : null} + + ); +} diff --git a/apps/web/app/components/workbench/site-accounts.tsx b/apps/web/app/components/workbench/site-accounts.tsx new file mode 100644 index 00000000..24e144b4 --- /dev/null +++ b/apps/web/app/components/workbench/site-accounts.tsx @@ -0,0 +1,171 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { type Site, SiteCredentialsPut } from "@umt/contracts"; +import { KeyRound, LoaderCircle, UserRound } from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; +import { usePutSiteCredentials } from "~/api/instances.ts"; +import { siteKeys, useSiteAccounts } from "~/api/sites.ts"; +import { DataTable } from "~/components/console/data-table.tsx"; +import { DetailSection } from "~/components/console/detail-sheet.tsx"; +import { FormField, zodFieldErrors } from "~/components/console/form-field.tsx"; +import { StatusBadge } from "~/components/console/status-badge.tsx"; +import { Button } from "~/components/ui/button.tsx"; +import { Input } from "~/components/ui/input.tsx"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx"; +import { errorMessage } from "~/lib/api.ts"; +import { ACCOUNT_STATUS, relTime } from "./release-fields.ts"; + +const AUTH_KINDS = [ + { value: "cookie", label: "Cookie" }, + { value: "passkey", label: "Passkey" }, + { value: "api_key", label: "API Key" }, + { value: "password", label: "账号密码" }, +] as const; +type AuthKind = (typeof AUTH_KINDS)[number]["value"]; + +/** 站点详情"账号"页签:已录入账号 + 按标签新建或替换凭证(凭证只写,不回显) */ +export function SiteAccountsPanel({ site }: { site: Site }) { + const accounts = useSiteAccounts(site.id); + const put = usePutSiteCredentials(); + const qc = useQueryClient(); + const [v, setV] = useState({ label: "default", authKind: "cookie" as AuthKind, username: "", credential: "" }); + const [errors, setErrors] = useState>({}); + + const submit = async (e: React.FormEvent) => { + e.preventDefault(); + const parsed = SiteCredentialsPut.safeParse({ + label: v.label.trim() || "default", + authKind: v.authKind, + credential: v.credential.trim(), + username: v.authKind === "password" ? v.username.trim() || undefined : undefined, + }); + if (!parsed.success) { + const fe = zodFieldErrors(parsed.error.issues); + if (fe.credential) fe.credential = "填写凭证"; + setErrors(fe); + return; + } + setErrors({}); + try { + const r = await put.mutateAsync({ siteId: site.id, body: parsed.data }); + toast.success(r.created ? "已添加站点账号" : "已替换凭证"); + setV((s) => ({ ...s, credential: "" })); + void qc.invalidateQueries({ queryKey: siteKeys.all }); + } catch (err) { + toast.error(errorMessage(err, "保存失败")); + } + }; + + const busy = put.isPending; + return ( +
+ + r.id} + loading={accounts.isPending} + error={accounts.error} + onRetry={() => void accounts.refetch()} + empty={{ icon: UserRound, title: "还没有账号", description: "保存凭证后出现在这里。" }} + columns={[ + { + key: "label", + header: "标签", + cell: (r) => ( +
+

{r.label}

+

+ {AUTH_KINDS.find((k) => k.value === r.authKind)?.label ?? r.authKind} +

+
+ ), + }, + { + key: "status", + header: "状态", + cell: (r) => ( +
+ {ACCOUNT_STATUS[r.status].label} + {!r.enabled ? 已停用 : null} +
+ ), + }, + { + key: "verified", + header: "上次验证", + hideBelow: "sm", + cell: (r) => {relTime(r.lastVerifiedAt)}, + }, + ]} + /> +
+
+

+ 同一标签再次保存会替换凭证。凭证加密保存,不回显。 +

+ + setV((s) => ({ ...s, label: e.target.value }))} + disabled={busy} + /> + + + + + {v.authKind === "password" ? ( + + setV((s) => ({ ...s, username: e.target.value }))} + disabled={busy} + /> + + ) : null} + + setV((s) => ({ ...s, credential: e.target.value }))} + disabled={busy} + /> + +
+ +
+
+
+ ); +} diff --git a/apps/web/app/components/workbench/site-register-dialog.tsx b/apps/web/app/components/workbench/site-register-dialog.tsx new file mode 100644 index 00000000..4537445e --- /dev/null +++ b/apps/web/app/components/workbench/site-register-dialog.tsx @@ -0,0 +1,174 @@ +import { SiteAdapter, SiteCreate } from "@umt/contracts"; +import { LoaderCircle } from "lucide-react"; +import { useState } from "react"; +import { useNavigate } from "react-router"; +import { toast } from "sonner"; +import { useCreateSite, useSiteDefinitions, useSites } from "~/api/sites.ts"; +import { FormField, zodFieldErrors } from "~/components/console/form-field.tsx"; +import { consolePath } from "~/components/console/nav.ts"; +import { Button } from "~/components/ui/button.tsx"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "~/components/ui/dialog.tsx"; +import { Input } from "~/components/ui/input.tsx"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx"; +import { Switch } from "~/components/ui/switch.tsx"; +import { errorMessage } from "~/lib/api.ts"; + +export const ADAPTER_LABEL: Record = { + nexusphp: "NexusPHP", + mteam_api: "M-Team API", + api: "站点 API", + generic_rss: "通用 RSS", +}; + +const CUSTOM = "__custom"; +type Draft = { key: string; name: string; adapter: SiteAdapter; baseUrl: string; snapshotEnabled: boolean }; +const EMPTY: Draft = { key: "", name: "", adapter: "nexusphp", baseUrl: "", snapshotEnabled: true }; + +/** 登记站点:从内置站点定义选,或自定义。登记后去站点详情的"账号"页签填凭证 */ +export function SiteRegisterDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (o: boolean) => void }) { + const defs = useSiteDefinitions(); + const sites = useSites(); + const create = useCreateSite(); + const navigate = useNavigate(); + const [preset, setPreset] = useState(CUSTOM); + const [d, setD] = useState(EMPTY); + const [errors, setErrors] = useState>({}); + const taken = new Set((sites.data?.items ?? []).map((s) => s.key)); + const available = (defs.data?.items ?? []).filter((x) => !taken.has(x.key)); + const set = (k: K, v: Draft[K]) => setD((s) => ({ ...s, [k]: v })); + + const pick = (key: string) => { + setPreset(key); + const def = available.find((x) => x.key === key); + setD( + def ? { key: def.key, name: def.name, adapter: def.adapter, baseUrl: def.baseUrl, snapshotEnabled: true } : EMPTY, + ); + setErrors({}); + }; + + const submit = async (e: React.FormEvent) => { + e.preventDefault(); + const parsed = SiteCreate.safeParse({ ...d, key: d.key.trim(), name: d.name.trim(), baseUrl: d.baseUrl.trim() }); + if (!parsed.success) { + const fe = zodFieldErrors(parsed.error.issues); + if (fe.key) fe.key = "小写字母开头,只含小写字母、数字、- 和 _"; + if (fe.baseUrl) fe.baseUrl = "填完整地址,如 https://example.org"; + setErrors(fe); + return; + } + if (taken.has(parsed.data.key)) { + setErrors({ key: "这个标识已被占用" }); + return; + } + setErrors({}); + try { + const site = await create.mutateAsync(parsed.data); + toast.success(`已登记 ${site.name}`); + onOpenChange(false); + navigate(`${consolePath("sites", site.id)}?tab=accounts`); + } catch (err) { + toast.error(errorMessage(err, "登记失败")); + } + }; + + const busy = create.isPending; + return ( + + + + 登记站点 + 登记后在站点的"账号"页签填写凭证。 + +
+ + + + + set("name", e.target.value)} + disabled={busy} + /> + + + set("key", e.target.value)} + disabled={busy || preset !== CUSTOM} + className="font-mono" + /> + + + + + + set("baseUrl", e.target.value)} + placeholder="https://" + disabled={busy} + /> + +
+ set("snapshotEnabled", v)} + disabled={busy} + /> + +
+
+ + + + +
+
+ ); +} diff --git a/apps/web/app/routes/console/sites.$siteId.tsx b/apps/web/app/routes/console/sites.$siteId.tsx index 793284af..6bc194fb 100644 --- a/apps/web/app/routes/console/sites.$siteId.tsx +++ b/apps/web/app/routes/console/sites.$siteId.tsx @@ -1,18 +1,21 @@ import type { ResolutionStatus, WorkbenchRelease } from "@umt/contracts"; -import { ArrowLeft, Bot, Camera, Globe, Radar, SearchX, TriangleAlert } from "lucide-react"; +import { ArrowLeft, Bot, Camera, Globe, Plus, Radar, SearchX, TriangleAlert } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; -import { Link, useParams } from "react-router"; +import { Link, useParams, useSearchParams } from "react-router"; import { toast } from "sonner"; import { useInventory } from "~/api/inventory.ts"; import { type ReleaseFilters, useSiteReleases } from "~/api/releases.ts"; import { snapshotStale, useSite, useSiteAccounts, useSnapshotSite } from "~/api/sites.ts"; import { openAgentDrawer } from "~/components/agent/agent-store.ts"; +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"; import { PageHeader } from "~/components/console/page-header.tsx"; import { StatusBadge } from "~/components/console/status-badge.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 { ReleaseDetailSheet } from "~/components/workbench/release-detail-sheet.tsx"; import { accountSummary, @@ -24,6 +27,8 @@ import { SITE_HEALTH, } from "~/components/workbench/release-fields.ts"; import { ReleaseRow } from "~/components/workbench/release-list.tsx"; +import { SiteAccountsPanel } from "~/components/workbench/site-accounts.tsx"; +import { SnapshotPanel } from "~/components/workbench/snapshot-panel.tsx"; import { errorMessage } from "~/lib/api.ts"; const PROMOS = [ @@ -84,8 +89,16 @@ function useDebounced(value: T, ms = 400) { return v; } +const TABS = ["organized", "origin", "snapshots", "accounts"] as const; + +export const meta = () => [{ title: "站点 · UHD 媒体自动化控制台" }]; + export default function SiteWorkbenchPage() { const { siteId = "" } = useParams(); + const [params, setParams] = useSearchParams(); + const rawTab = params.get("tab") ?? ""; + const tab = (TABS as readonly string[]).includes(rawTab) ? rawTab : "organized"; + const [creatingFeed, setCreatingFeed] = useState(false); const site = useSite(siteId); const accounts = useSiteAccounts(siteId); const snap = useSnapshotSite(); @@ -169,9 +182,9 @@ export default function SiteWorkbenchPage() { actions={ <> + } + > + + + + + + {s ? : } = { - nexusphp: "NexusPHP", - mteam_api: "M-Team API", - api: "站点 API", - generic_rss: "通用 RSS", -}; - function SiteCard({ site, accounts }: { site: Site; accounts: SiteAccount[] }) { const toggle = useToggleSite(); const snap = useSnapshotSite(); @@ -31,7 +27,7 @@ function SiteCard({ site, accounts }: { site: Site; accounts: SiteAccount[] }) {
{site.name} @@ -62,7 +58,9 @@ function SiteCard({ site, accounts }: { site: Site; accounts: SiteAccount[] }) {
{health.label} - 账号:{account.label} + + 账号:{account.label} + {circuit ? {circuit} : null}
@@ -102,8 +100,8 @@ function SiteCard({ site, accounts }: { site: Site; accounts: SiteAccount[] }) { 快照 @@ -112,8 +110,11 @@ function SiteCard({ site, accounts }: { site: Site; accounts: SiteAccount[] }) { ); } +export const meta = () => [{ title: "站点工作台 · UHD 媒体自动化控制台" }]; + export default function SitesPage() { const sites = useSites(); + const [registering, setRegistering] = useState(false); const accounts = useSiteAccounts(); const bySite = new Map(); for (const a of accounts.data?.items ?? []) bySite.set(a.siteId, [...(bySite.get(a.siteId) ?? []), a]); @@ -121,12 +122,18 @@ export default function SitesPage() { return ( <> void sites.refetch()} disabled={sites.isFetching}> - - 刷新 - + <> + + + } /> {sites.isPending ? ( @@ -139,10 +146,11 @@ export default function SitesPage() { - 去来源页 + } /> @@ -153,6 +161,7 @@ export default function SitesPage() { ))}
)} + {registering ? : null} ); } diff --git a/apps/web/app/routes/console/sources.tsx b/apps/web/app/routes/console/sources.tsx index c8cdd906..addf8fcc 100644 --- a/apps/web/app/routes/console/sources.tsx +++ b/apps/web/app/routes/console/sources.tsx @@ -1,188 +1,116 @@ -import type { FeedHealth, FeedSource } from "@umt/contracts"; -import { Plus, RefreshCw, RotateCcw, Rss, Zap } from "lucide-react"; +import type { FeedHealth, Site } from "@umt/contracts"; +import { Globe, Plus, RefreshCw } from "lucide-react"; import { useState } from "react"; -import { toast } from "sonner"; -import { feedGapHint, useFeedAction, useFeeds, useFetchFeedNow } from "~/api/feeds.ts"; -import { useSiteAccounts, useSites } from "~/api/sites.ts"; +import { Link } from "react-router"; +import { useFeeds } from "~/api/feeds.ts"; +import { snapshotStale, 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 { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx"; +import { DetailSection } from "~/components/console/detail-sheet.tsx"; import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx"; -import { JsonView } from "~/components/console/json-view.tsx"; -import { KvList } from "~/components/console/kv-list.tsx"; +import { consolePath } from "~/components/console/nav.ts"; import { PageHeader } from "~/components/console/page-header.tsx"; -import { StatusBadge, type StatusTone } from "~/components/console/status-badge.tsx"; +import { StatusBadge } from "~/components/console/status-badge.tsx"; +import { useQueryState } from "~/components/console/use-query-state.ts"; import { Button } from "~/components/ui/button.tsx"; -import { Switch } from "~/components/ui/switch.tsx"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs.tsx"; -import { FeedFormDialog, PURPOSE_LABEL } from "~/components/workbench/feed-form.tsx"; -import { relTime } from "~/components/workbench/release-fields.ts"; -import { SnapshotPanel } from "~/components/workbench/snapshot-panel.tsx"; -import { errorMessage } from "~/lib/api.ts"; -import { formatDateTime } from "~/lib/utils.ts"; +import { FEED_HEALTH, FeedTable } from "~/components/workbench/feed-table.tsx"; +import { accountSummary, circuitLabel, relTime, SITE_HEALTH } from "~/components/workbench/release-fields.ts"; +import { legacyRedirectLoader } from "./redirects.ts"; -const HEALTH: Record = { - unknown: { label: "未抓取", tone: "neutral" }, - healthy: { label: "健康", tone: "success" }, - degraded: { label: "降级", tone: "warning" }, - failing: { label: "失败", tone: "destructive" }, - circuit_open: { label: "熔断", tone: "destructive" }, - disabled: { label: "已停用", tone: "neutral" }, -}; +// 来源健康:跨站点的 Feed 列表与站点健康总表;单个站点的快照、Feed、账号在站点详情。 -const lastErrorText = (f: FeedSource) => - f.lastError ? String((f.lastError as { message?: unknown }).message ?? JSON.stringify(f.lastError)) : null; +export const meta = () => [{ title: "来源健康 · UHD 媒体自动化控制台" }]; -/** 游标窗口摘要:服务端 cursor.window 的条目数与上次抓取时间 */ -function cursorSummary(f: FeedSource): string { - const w = f.cursor.window as { lastFetchedAt?: string | null; lastGuidSet?: unknown[] } | undefined; - if (!w) return "尚无游标"; - const n = Array.isArray(w.lastGuidSet) ? w.lastGuidSet.length : null; - return `${n !== null ? `窗口 ${n} 条` : "窗口"}${w.lastFetchedAt ? ` · ${relTime(w.lastFetchedAt)}` : ""}`; -} - -function FeedActions({ f }: { f: FeedSource }) { - const fetchNow = useFetchFeedNow(); - const action = useFeedAction(); - return ( -
- e.stopPropagation()} - onKeyDown={(e) => e.stopPropagation()} - onCheckedChange={(v) => - action.mutate( - { id: f.id, action: v ? "enable" : "disable" }, - { onSuccess: () => toast.success(v ? "已启用" : "已停用"), onError: (e) => toast.error(errorMessage(e)) }, - ) - } - /> - -
- ); -} +// 旧的 ?tab=snapshots&siteId= 跳到站点详情的"快照与 Feed" +export const clientLoader = legacyRedirectLoader; export default function SourcesPage() { - const [health, setHealth] = useState(""); - const [siteFilter, setSiteFilter] = useState(""); - const feeds = useFeeds({ health: (health || undefined) as FeedHealth | undefined, siteId: siteFilter || undefined }); + const f = useQueryState(["health", "siteId"] as const); + const [creating, setCreating] = useState(false); const sites = useSites(); const accounts = useSiteAccounts(); - const [editing, setEditing] = useState(null); - const [openId, setOpenId] = useState(null); - 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])); - const open = feeds.data?.items.find((f) => f.id === openId) ?? null; + const feeds = useFeeds({ + health: (f.values.health || undefined) as FeedHealth | undefined, + siteId: f.values.siteId || undefined, + }); - const columns: DataColumn[] = [ + const siteColumns: DataColumn[] = [ { key: "name", - header: "名称", - cell: (f) => ( -
-

{f.name}

-

- {f.urlDisplay} -

-
+ header: "站点", + cell: (s) => ( + e.stopPropagation()} + > + {s.name} + ), }, { key: "health", header: "健康", - cell: (f) => { - const gap = feedGapHint(f); - return ( -
- {HEALTH[f.healthStatus].label} - {gap ? ( - - {f.possibleGap ? "疑似缺口" : "可能漏项"} - - ) : null} -
- ); - }, - }, - { key: "purpose", header: "用途", hideBelow: "md", cell: (f) => PURPOSE_LABEL[f.purpose] }, - { - key: "site", - header: "站点 / 账号", - hideBelow: "lg", - cell: (f) => ( - - {f.siteId ? (siteName.get(f.siteId) ?? "未知站点") : "—"} - {f.siteAccountId ? ` · ${accountName.get(f.siteAccountId) ?? "账号"}` : ""} - - ), - }, - { - key: "poll", - header: "轮询", - hideBelow: "lg", - cell: (f) => {Math.round(f.pollIntervalSec / 60)} 分钟, - }, - { - key: "success", - header: "最后成功", - hideBelow: "sm", - cell: (f) => ( -
-

{relTime(f.lastSuccessAt)}

- {f.consecutiveFailures ? ( -

连续失败 {f.consecutiveFailures}

- ) : null} + cell: (s) => ( +
+ {SITE_HEALTH[s.healthStatus].label} + {circuitLabel(s) ? {circuitLabel(s)} : null} + {!s.enabled && s.healthStatus !== "disabled" ? 已停用 : null}
), }, - { key: "ops", header: "", align: "right", cell: (f) => }, + { + key: "account", + header: "账号", + hideBelow: "md", + cell: (s) => { + const a = accountSummary((accounts.data?.items ?? []).filter((x) => x.siteId === s.id)); + return {a.label}; + }, + }, + { + key: "snapshot", + header: "最近快照", + hideBelow: "sm", + cell: (s) => ( + + {s.snapshotEnabled ? relTime(s.lastSnapshotAt) : "未开启"} + {s.snapshotEnabled && snapshotStale(s) ? 已过期 : null} + + ), + }, ]; return ( <> setEditing("new")}> + } /> - - - Feed - 站点快照 - - +
+ + s.id} + loading={sites.isPending} + error={sites.error} + onRetry={() => void sites.refetch()} + empty={{ + icon: Globe, + title: "还没有登记站点", + description: "在站点工作台登记。", + }} + /> + + { - setHealth(""); - setSiteFilter(""); - } - : undefined - } + onReset={f.active ? f.reset : undefined} actions={ - ) : null} - - - ) : null - } - > - {open ? ( - <> -
- {HEALTH[open.healthStatus].label} - {open.enabled ? "已启用" : "已停用"} - {PURPOSE_LABEL[open.purpose]} -
- {feedGapHint(open) ? ( -

- {feedGapHint(open)} -

- ) : null} - {lastErrorText(open) ? ( -

- {lastErrorText(open)} -

- ) : null} - - - - - - - ) : null} - - - {editing ? ( - !o && setEditing(null)} - feed={editing === "new" ? null : editing} - sites={sites.data?.items ?? []} - accounts={accounts.data?.items ?? []} - /> - ) : null} +
+
); }