Files
UHD_Media_Tool/apps/web/app/routes/console/sites.tsx
T
rootandClaude Opus 5.5 31a54186c7 copy: second-round copy review across console, settings, agent and server messages
- Trim descriptions, empty states, confirm buttons and toasts to facts
- Unify terms per glossary: 待处理/待识别/整理记录/运行记录/凭证/子会话/Agent 上游
- Subscription-login termsNote states facts only (borrowed client identity, ToS/ban risk)
- Shorter, concrete goal templates for built-in Agent checks
- Human-readable nouns instead of table names in instance CRUD errors
- Audit labels for pi_upstream / schedule / automation_grant actions
- Glossary: add 上游、订阅登录、凭证、定时任务、巡检、运行记录、子会话、停止、持续授权

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-28 13:29:42 +08:00

168 lines
6.4 KiB
TypeScript

import type { Site, SiteAccount } from "@umt/contracts";
import { ArrowRight, Camera, Globe, Plus, RefreshCw } from "lucide-react";
import { useState } from "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 { 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 { Switch } from "~/components/ui/switch.tsx";
import { accountSummary, circuitLabel, relTime, SITE_HEALTH } from "~/components/workbench/release-fields.ts";
import { ADAPTER_LABEL, SiteRegisterDialog } from "~/components/workbench/site-register-dialog.tsx";
import { errorMessage } from "~/lib/api.ts";
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={consolePath("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>
<Link to={`${consolePath("sites", site.id)}?tab=accounts`} className="hover:opacity-80">
<StatusBadge tone={account.tone}>账号:{account.label}</StatusBadge>
</Link>
{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={consolePath("sites", site.id)}>
打开
<ArrowRight />
</Link>
</Button>
</div>
</div>
);
}
export const meta = () => [{ title: "站点工作台 · UHD 媒体自动化控制台" }];
export default function SitesPage() {
const sites = useSites();
const [registering, setRegistering] = useState(false);
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="站点工作台"
actions={
<>
<Button variant="outline" size="sm" onClick={() => void sites.refetch()} disabled={sites.isFetching}>
<RefreshCw className={sites.isFetching ? "animate-spin" : undefined} />
刷新
</Button>
<Button size="sm" onClick={() => setRegistering(true)}>
<Plus />
登记站点
</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="登记站点并填写账号后,在这里浏览和搜索资源。"
action={
<Button size="sm" onClick={() => setRegistering(true)}>
<Plus />
登记站点
</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>
)}
{registering ? <SiteRegisterDialog open onOpenChange={setRegistering} /> : null}
</>
);
}