- 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]>
168 lines
6.4 KiB
TypeScript
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}
|
|
</>
|
|
);
|
|
}
|