import type { ResolutionStatus, WorkbenchRelease } from "@umt/contracts"; import { Activity, ArrowLeft, Bot, Camera, Globe, LoaderCircle, Plus, Radar, RefreshCw, SearchX, TriangleAlert, } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import { Link, useNavigate, 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, 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"; import { PageHeader } from "~/components/console/page-header.tsx"; import { StatusBadge } from "~/components/console/status-badge.tsx"; 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 { 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 { ReleaseDetailSheet } from "~/components/workbench/release-detail-sheet.tsx"; import { categoryLabel, circuitLabel, hrOf, 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 PROMOS = [ { value: "free", label: "免费" }, { value: "2xfree", label: "2x 免费" }, { value: "50%", label: "50%" }, { value: "30%", label: "30%" }, { value: "2x", label: "2x 上传" }, ]; const RESOLUTION: { value: ResolutionStatus; label: string }[] = [ { value: "resolved", label: "已识别" }, { value: "unresolved", label: "待识别" }, { value: "ambiguous", label: "有歧义" }, { value: "unidentifiable", label: "无法识别" }, ]; const SIZE = [ { value: "lt5", label: "< 5 GB" }, { value: "5to20", label: "5–20 GB" }, { value: "20to60", label: "20–60 GB" }, { value: "gt60", label: "> 60 GB" }, ]; const AGE = [ { value: "1", label: "24 小时内" }, { value: "7", label: "7 天内" }, { value: "30", label: "30 天内" }, ]; const GB = 1024 ** 3; /** 服务端不支持的维度(H&R/做种/大小/上传时间)在已加载结果里筛选,界面上写明 */ type LocalFilters = { hr: string; seeding: string; size: string; age: string }; function localMatch(r: WorkbenchRelease, f: LocalFilters, now: number) { if (f.hr === "no" && hrOf(r) !== "no") return false; if (f.hr === "yes" && hrOf(r) !== "yes") return false; if (f.seeding === "alive" && !(r.seeders && r.seeders > 0)) return false; if (f.seeding === "hot" && !(r.seeders && r.seeders >= 20)) return false; if (f.size) { const s = r.sizeBytes; if (s === null) return false; if (f.size === "lt5" && s >= 5 * GB) return false; if (f.size === "5to20" && (s < 5 * GB || s >= 20 * GB)) return false; if (f.size === "20to60" && (s < 20 * GB || s >= 60 * GB)) return false; if (f.size === "gt60" && s < 60 * GB) return false; } 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", "accounts"] as const; /** 旧页签(整理视图)落到"资源" */ const LEGACY_TAB: Record = { organized: "resources" }; export const meta = () => [{ title: "站点 · UHD 媒体自动化控制台" }]; 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 [dialog, setDialog] = useState(null); const checks = useSiteChecks(setDialog); const sites = useSites(); const snap = useSnapshotSite(); const [qInput, setQInput] = useState(""); const q = useDebounced(qInput.trim()); const [category, setCategory] = useState(""); const [promotion, setPromotion] = useState(""); const [resolution, setResolution] = useState(""); const [local, setLocal] = useState({ hr: "", seeding: "", size: "", age: "" }); const [live, setLive] = useState(false); const [openId, setOpenId] = useState(null); const filters: ReleaseFilters = { q: q || undefined, category: category || undefined, promotion: promotion || undefined, resolution: (resolution || undefined) as ResolutionStatus | undefined, }; const releases = useSiteReleases(tab === "resources" ? siteId : "", filters, live); // 实时补查只对当前搜索生效一次 // biome-ignore lint/correctness/useExhaustiveDependencies: 条件变化时回到快照优先 useEffect(() => setLive(false), [q, category, promotion, resolution]); const pages = releases.data?.pages ?? []; const all = useMemo(() => pages.flatMap((p) => p.items), [pages]); const coverage = pages[0]?.coverage; const now = Date.now(); const rows = all.filter((r) => localMatch(r, local, now)); 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 = sites.data?.items.find((x) => x.id === siteId); 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 reset = () => { setQInput(""); setCategory(""); setPromotion(""); setResolution(""); setLocal({ hr: "", seeding: "", size: "", age: "" }); }; const anyFilter = !!(qInput || category || promotion || resolution || localActive); if (sites.error && !sites.data) return void sites.refetch()} />; if (sites.data && !s) return ( 返回站点 } /> ); const account = s ? ACCOUNT_OVERALL[overallStatus(s)] : null; const health = s ? healthLine(s.lastHealth, s.lastHealthAt) : null; const circuit = s ? circuitLabel(s) : null; const hasAccount = !!s?.accounts?.some((a) => a.enabled && a.authKind !== "passkey"); return ( <> {hostOf(s.baseUrl)} {account ? 账号{account.label} : null} {health ? 健康 {health.text} : null} {!s.enabled ? 站点已停用,只显示历史数据 : null} {circuit ? {circuit} : null} {s.snapshotEnabled && snapshotStale(s) ? 快照过期 : null} ) : ( "加载中…" ) } actions={ <> {s ? ( <> ) : null} } /> setParams( (p) => { if (v === "overview") p.delete("tab"); else p.set("tab", v); return p; }, { replace: true }, ) } > 概览 资源 原站视图 快照与 Feed 账号 {s ? : } } > setLocal((l) => ({ ...l, hr: v }))} options={[ { value: "no", label: "无 H&R" }, { value: "yes", label: "有 H&R" }, ]} /> setLocal((l) => ({ ...l, seeding: v }))} options={[ { value: "alive", label: "有人做种" }, { value: "hot", label: "≥ 20 做种" }, ]} /> setLocal((l) => ({ ...l, size: v }))} options={SIZE} /> setLocal((l) => ({ ...l, age: v }))} options={AGE} /> {coverage || refreshFailed ? ( ) : null}
{releases.isPending ? ( ) : releases.error && !releases.data ? ( void releases.refetch()} /> ) : rows.length === 0 ? ( ) : ( <>
已加载 {all.length} 条{localActive ? `,本地筛选后 ${rows.length} 条` : ""} {freeCount ? 其中免费 {freeCount} : null} {releases.isFetching && !releases.isFetchingNextPage ? 刷新中… : null}
    {rows.map((r) => ( setOpenId(r.id)} /> ))}
)} {releases.hasNextPage ? (
) : null}
{s ? : }
{s ? : } setCreatingFeed(true)}> 新建 Feed } >
{s ? : }
setDialog(null)} onDeleted={() => navigate(consolePath("sites"))} /> setOpenId(null)} /> ); } 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)})` : "没有快照,列表只来自 Feed 和实时补查", }); 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}

))}
); }