import type { FeedHealth, ResolutionStatus, SiteListItem, WorkbenchRelease } from "@umt/contracts"; import { Activity, Bot, Camera, Globe, LoaderCircle, Plus, Radar, RefreshCw, SearchX, TriangleAlert, } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; import { useInventory } from "~/api/inventory.ts"; 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 { DetailSheet } 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 { useStickyId } from "~/components/console/record-sheet.tsx"; import { StatusBadge } from "~/components/console/status-badge.tsx"; import { useListPagination } from "~/components/console/use-list-pagination.ts"; import { QuickFilterBar } from "~/components/search/quick-filter-bar.tsx"; import { formatQuickFilter, selectedCount, useQuickFilters } from "~/components/search/quick-filter-state.ts"; 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, 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 { Pagination } from "~/components/ui/pagination.tsx"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs.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"; import { SnapshotPanel } from "~/components/workbench/snapshot-panel.tsx"; import { errorMessage } from "~/lib/api.ts"; const RESOLUTION: { value: ResolutionStatus; label: string }[] = [ { value: "resolved", label: "已识别" }, { value: "unresolved", label: "未识别" }, { value: "ambiguous", label: "匹配到多部作品" }, { value: "unidentifiable", label: "无法识别" }, ]; const AGE = [ { value: "1", label: "24 小时内" }, { value: "7", label: "7 天内" }, { value: "30", label: "30 天内" }, ]; /** * 服务端不支持的维度(上传时间)在已加载结果里筛选,界面上写明。H&R(POLISH-1)、做种与大小(SEARCH-4) * 在快速筛选里,按全部本地资源计数和筛选。 */ type LocalFilters = { age: string }; function localMatch(r: WorkbenchRelease, f: LocalFilters, now: number) { if (f.age) { const t = r.sourcePublishedAt ? new Date(r.sourcePublishedAt).getTime() : null; if (t === null || now - t > Number(f.age) * 86_400_000) return false; } return true; } function useDebounced(value: T, ms = 400) { const [v, setV] = useState(value); useEffect(() => { const t = setTimeout(() => setV(value), ms); return () => clearTimeout(t); }, [value, ms]); return v; } 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; }); } const TAB_LABEL: Record = { overview: "概览", resources: "资源", origin: "原站视图", snapshots: "抓取", rss: "RSS", accounts: "账号", }; /** * 站点抽屉(console-ia.md §9):站点页上 ?open=<站点 id> 打开的宽抽屉,里面一层页签(?tab=); * 资源、RSS 的详情在同一个抽屉里换成子视图,不叠第二层。 */ export function SiteSheet({ id, params, onPatch, onClose, }: { id: string | null; params: URLSearchParams; onPatch: (extra: Record) => void; onClose: () => void; }) { const shown = useStickyId(id); const sites = useSites(); const site = sites.data?.items.find((x) => x.id === shown); return ( !o && onClose()} width="xl" title={site?.name ?? "站点"} description={site ? : undefined} actions={site ? : undefined} > {shown ? : null} ); } function SiteWorkbench({ siteId, params, onPatch, onClose, }: { siteId: string; params: URLSearchParams; onPatch: (extra: Record) => void; onClose: () => void; }) { 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()); const [category, setCategory] = useState(""); const [resolution, setResolution] = useState(""); const [local, setLocal] = useState({ age: "" }); // 快速筛选(SEARCH-3):服务端按本地资源表数据统计与筛选,不触发站点请求 const [quick, setQuick] = useQuickFilters("site"); const qf = formatQuickFilter(quick); // 实时补查只对开启时的快速筛选生效;改快速筛选不会再次请求站点 const [liveAt, setLiveAt] = useState(null); const live = liveAt !== null && liveAt === (qf ?? ""); const setLive = (v: boolean) => setLiveAt(v ? (qf ?? "") : null); const [openId, setOpenId] = useState(null); const filters: ReleaseFilters = { q: q || undefined, category: category || undefined, resolution: (resolution || undefined) as ResolutionStatus | undefined, qf, }; const releases = useSiteReleases(tab === "resources" ? siteId : "", filters, live); // 实时补查只对当前搜索生效一次 // biome-ignore lint/correctness/useExhaustiveDependencies: 条件变化时回到快照优先 useEffect(() => setLiveAt(null), [q, category, resolution]); const pages = releases.data?.pages ?? []; const all = useMemo(() => pages.flatMap((p) => p.items), [pages]); const coverage = pages[0]?.coverage; const quickTotal = pages[0]?.total; /** 本地有资源,但快速筛选后一条都没有 */ const quickHidden = selectedCount(quick) > 0 && (pages[0]?.unfilteredTotal ?? 0) > 0 && quickTotal === 0; const now = Date.now(); const rows = all.filter((r) => localMatch(r, local, now)); const paged = useListPagination(rows, { hasMore: releases.hasNextPage, onLoadMore: () => releases.fetchNextPage(), loadingMore: releases.isFetchingNextPage, error: releases.error, resetKey: JSON.stringify({ siteId, filters, local, live }), }); const localActive = Object.values(local).some(Boolean); const inventory = useInventory(all.flatMap((r) => (r.entity ? [r.entity.id] : []))); const categories = useMemo(() => { const m = new Map(); for (const r of all) if (r.categoryRaw) m.set(r.categoryRaw, categoryLabel(r) ?? r.categoryRaw); if (category && !m.has(category)) m.set(category, category); return [...m].map(([value, label]) => ({ value, label })); }, [all, category]); const s = site; const refreshFailed = !!releases.error && !!releases.data; // 刷新失败时整页数据都标过期;否则早于 2 个快照周期确认的条目标过期 const staleBefore = refreshFailed ? now + 1 : s ? now - (s.snapshotIntervalSec ?? 3600) * 2000 : null; const selected = all.find((r) => r.id === openId) ?? null; const freeCount = all.filter(isFree).length; const reqStats = useSiteRequestStats(tab === "resources" ? siteId : undefined); const reset = () => { setQInput(""); setCategory(""); setQuick({}); setResolution(""); setLocal({ age: "" }); }; const anyFilter = !!(qInput || category || resolution || localActive || selectedCount(quick)); if (sites.error && !sites.data) return void sites.refetch()} />; if (sites.data && !s) return ; return ( <> onPatch({ tab: v === "overview" ? null : v, feed: null })}> {tabs.map((t) => ( {TAB_LABEL[t]} ))} {s ? : } {s && !siteCan(s, "list") ? null : ( )} {s && !siteCan(s, "search") ? null : ( )} } > setLocal((l) => ({ ...l, age: v }))} options={AGE} /> {/* 「清除筛选」在上方筛选行,一并清掉快速筛选 */} {/* 分类已有上方的站点分类下拉(与 MP 站点资源一致),不再给分面 */} {coverage || refreshFailed ? ( ) : null}
{releases.isPending ? ( ) : releases.error && !releases.data ? ( void releases.refetch()} /> ) : rows.length === 0 ? ( ) : ( <>
{quickTotal !== undefined && selectedCount(quick) ? `共 ${quickTotal} 条,已加载 ${all.length} 条` : `已加载 ${all.length} 条`} {localActive ? `,本地筛选后 ${rows.length} 条` : ""} {freeCount ? 其中免费 {freeCount} : null} {reqStats.data ? ( 近 24 小时请求站点 {reqStats.data.last24h} 次 ) : null} {releases.isFetching && !releases.isFetchingNextPage ? 刷新中… : null}
    {paged.items?.map((r) => ( setOpenId(r.id)} /> ))}
)} {all.length > 0 || releases.hasNextPage ? : null}
{s ? : } {s ? : } {/* 原独立 RSS 页的全部能力(IA-2):新建 / 编辑 / 启停 / 立即抓取 / 抓取状态 / 最近条目 */} setFeedHealth("") : undefined} actions={ } > ({ value, label: h.label }))} /> {s ? : }
setDialog(null)} onDeleted={onClose} /> setOpenId(null)} /> ); } /** 抽屉标题下的一行状态:域名、类型、账号、健康、停用、熔断、抓取过期 */ function SiteBadges({ s }: { s: SiteListItem }) { const login = hasLogin(s); const checkable = s.capabilities.some((c) => c !== "rss"); const account = login ? ACCOUNT_OVERALL[overallStatus(s)] : null; const health = checkable ? healthLine(s.lastHealth, s.lastHealthAt) : null; const circuit = circuitLabel(s); return ( {hostOf(s.baseUrl)} {s.kind !== "pt" ? {kindLabel(s.kind)} : null} {account ? 账号{account.label} : null} {health ? 健康 {health.text} : null} {!s.enabled ? 站点已停用,只显示历史数据 : null} {circuit ? {circuit} : null} {s.snapshotEnabled && snapshotStale(s) ? 抓取已过期 : null} ); } /** 抽屉标题下的操作:检测健康、刷新登录、更多、问智能体 */ function SiteActions({ s, onDeleted }: { s: SiteListItem; onDeleted: () => void }) { const [dialog, setDialog] = useState(null); const checks = useSiteChecks(setDialog); const login = hasLogin(s); const checkable = s.capabilities.some((c) => c !== "rss"); const hasAccount = !!s.accounts?.some((a) => a.enabled && a.authKind !== "passkey"); return ( <> {checkable ? ( <> {login ? ( ) : null} ) : null} setDialog(null)} onDeleted={onDeleted} /> ); } function CoverageBanner({ coverage, refreshError, }: { coverage: import("@umt/contracts").WorkbenchCoverage | undefined; refreshError: unknown; }) { const notes: { tone: "warning" | "neutral" | "success"; text: string }[] = []; if (refreshError) notes.push({ tone: "warning", text: `刷新失败,显示上次数据:${errorMessage(refreshError)}` }); if (coverage) { if (!coverage.siteEnabled) notes.push({ tone: "warning", text: "站点已停用,只显示历史数据" }); if (coverage.stale) notes.push({ tone: "warning", text: coverage.lastSnapshotAt ? `抓取已过期(上次 ${relTime(coverage.lastSnapshotAt)})` : "还没抓取过,列表只来自 RSS 和站点搜索", }); if (coverage.liveError) notes.push({ tone: "warning", text: `去站点搜失败:${coverage.liveError}` }); else if (coverage.live) notes.push({ tone: "success", text: coverage.coalesced ? "已合并到进行中的同一搜索" : "站点搜索完成" }); } if (!notes.length) return null; return (
{notes.map((n) => (

{n.tone === "warning" ? : null} {n.text}

))}
); }