Files
UHD_Media_Tool/apps/web/app/routes/console/sites.tsx
T
rootandClaude Opus 5.5 7103bc8f7c feat(web): W3-3 site workbench, sources/feeds/snapshots, downloads with business timeline, organization records and agent sessions
- sites + /sites/:siteId: organized view with snapshot-first release list, inventory overlay,
  candidate reasons, volatile-field recheck, stale marking that keeps existing data, live search,
  unified download entry; origin view placeholder (M2)
- sources: feed CRUD/enable/fetch/reset, cursor/health/gap hints; per-site snapshot history
- tasks: download tasks with pause/resume (threshold pause cannot be bypassed), plans,
  cache volume thresholds and cleanup, business timeline (observation → plan → download →
  organization → agent/engine)
- organize: record list/detail with file-level results, manual organize with dryRun preview
- agent: session list/page and layout drawer with context prefix; streaming events,
  tool cards, rejection alerts, compaction, budget meters, model text vs server report;
  mock switch (?mock=1) until W2-D endpoints land

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-27 20:52:32 +08:00

160 lines
6.1 KiB
TypeScript

import type { Site, SiteAccount } from "@umt/contracts";
import { ArrowRight, Camera, Globe, RefreshCw } from "lucide-react";
import { Link } from "react-router";
import { toast } from "sonner";
import { snapshotStale, useSiteAccounts, useSites, useSnapshotSite, useToggleSite } from "~/api/sites.ts";
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { PageHeader } from "~/components/console/page-header.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Switch } from "~/components/ui/switch.tsx";
import { accountSummary, circuitLabel, relTime, SITE_HEALTH } from "~/components/workbench/release-fields.ts";
import { errorMessage } from "~/lib/api.ts";
const ADAPTER_LABEL: Record<Site["adapter"], string> = {
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();
const health = SITE_HEALTH[site.healthStatus];
const account = accountSummary(accounts);
const stale = site.snapshotEnabled && snapshotStale(site);
const circuit = circuitLabel(site);
return (
<div className="pg-slab flex min-w-0 flex-col rounded-xl p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<Link
to={`/console/sites/${site.id}`}
className="block truncate text-lead font-semibold text-foreground hover:underline"
>
{site.name}
</Link>
<p className="truncate text-meta text-muted-foreground">
{site.key} · {ADAPTER_LABEL[site.adapter]}
</p>
</div>
<div className="flex shrink-0 items-center gap-2 text-meta text-muted-foreground">
<label htmlFor={`site-${site.id}`}>{site.enabled ? "已启用" : "已停用"}</label>
<Switch
id={`site-${site.id}`}
checked={site.enabled}
disabled={toggle.isPending}
aria-label={site.enabled ? "停用站点" : "启用站点"}
onCheckedChange={(v) =>
toggle.mutate(
{ id: site.id, enabled: v },
{
onSuccess: () => toast.success(v ? `已启用 ${site.name}` : `已停用 ${site.name},自动访问停止`),
onError: (e) => toast.error(errorMessage(e)),
},
)
}
/>
</div>
</div>
<div className="mt-3 flex flex-wrap gap-1.5">
<StatusBadge tone={health.tone}>{health.label}</StatusBadge>
<StatusBadge tone={account.tone}>账号:{account.label}</StatusBadge>
{circuit ? <StatusBadge tone="warning">{circuit}</StatusBadge> : null}
</div>
<dl className="mt-3 grid grid-cols-2 gap-x-4 gap-y-2 text-caption">
<div className="min-w-0">
<dt className="text-meta text-muted-foreground">最近快照</dt>
<dd className="flex flex-wrap items-center gap-1.5 text-foreground">
{site.snapshotEnabled ? relTime(site.lastSnapshotAt) : "未开启快照"}
{stale ? <StatusBadge tone="warning">已过期</StatusBadge> : null}
</dd>
</div>
<div className="min-w-0">
<dt className="text-meta text-muted-foreground">快照周期</dt>
<dd className="text-foreground">
{site.snapshotIntervalSec ? `${Math.round(site.snapshotIntervalSec / 60)} 分钟` : "默认"}
</dd>
</div>
</dl>
<div className="mt-4 flex flex-wrap items-center justify-end gap-2 border-t border-border/60 pt-3">
<Button
variant="outline"
size="sm"
disabled={!site.enabled || snap.isPending}
title={site.enabled ? "立即快照(受站点限流)" : "站点已停用"}
onClick={() =>
snap.mutate(
{ id: site.id },
{
onSuccess: () => toast.success("已排队快照,完成后自动刷新"),
onError: (e) => toast.error(errorMessage(e)),
},
)
}
>
<Camera />
快照
</Button>
<Button asChild size="sm" variant="outline">
<Link to={`/console/sites/${site.id}`}>
打开工作台
<ArrowRight />
</Link>
</Button>
</div>
</div>
);
}
export default function SitesPage() {
const sites = useSites();
const accounts = useSiteAccounts();
const bySite = new Map<string, SiteAccount[]>();
for (const a of accounts.data?.items ?? []) bySite.set(a.siteId, [...(bySite.get(a.siteId) ?? []), a]);
return (
<>
<PageHeader
title="PT 站点工作台"
description="按统一格式浏览站点资源,并叠加本地库存;下载走与自动收录相同的提交流程。"
actions={
<Button variant="outline" size="sm" onClick={() => void sites.refetch()} disabled={sites.isFetching}>
<RefreshCw className={sites.isFetching ? "animate-spin" : undefined} />
刷新
</Button>
}
/>
{sites.isPending ? (
<div className="pg-slab rounded-xl p-4">
<LoadingState rows={4} />
</div>
) : sites.error && !sites.data ? (
<ErrorState error={sites.error} onRetry={() => void sites.refetch()} />
) : !sites.data?.items.length ? (
<EmptyState
icon={Globe}
title="还没有登记站点"
description="在“来源 / Feed / 快照”或系统设置里登记站点与账号后,这里会列出可浏览的站点。"
action={
<Button asChild variant="outline" size="sm">
<Link to="/console/sources">去来源页</Link>
</Button>
}
/>
) : (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{sites.data.items.map((s) => (
<SiteCard key={s.id} site={s} accounts={bySite.get(s.id) ?? []} />
))}
</div>
)}
</>
);
}