import { type Site, type SiteAccount, SiteCredentialsPut, SiteUpdate } from "@umt/contracts"; import { KeyRound, LoaderCircle, Plus, Trash2 } from "lucide-react"; import { useState } from "react"; import { Link } from "react-router"; import { toast } from "sonner"; import { useCreateSite, usePutSiteCredentials, useRemoveSiteAccount, useSiteAccounts, useSiteOptions, useUpdateSite, useUpdateSiteAccount, } from "~/api/instances.ts"; import { snapshotStale, useSiteDefinitions, useToggleSite } from "~/api/sites.ts"; import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx"; import { DataTable } from "~/components/console/data-table.tsx"; import { DetailSection, DetailSheet } from "~/components/console/detail-sheet.tsx"; import { zodFieldErrors } from "~/components/console/form-field.tsx"; import { consolePath } from "~/components/console/nav.ts"; import { StatusBadge } from "~/components/console/status-badge.tsx"; import { Button } from "~/components/ui/button.tsx"; import { Switch } from "~/components/ui/switch.tsx"; import { errorMessage } from "~/lib/api.ts"; import { formatRelative, healthLabel } from "~/lib/labels.ts"; import { FieldControl, type FormValues, num, str } from "./resource-section.tsx"; import { SettingsBlock } from "./settings-block.tsx"; // 站点与账号:站点登记 / 启停 / 快照周期 / 限流(抽屉),站点账号凭证录入与启停。 // 站点浏览、快照记录在站点工作台。 const AUTH_KINDS = [ { value: "cookie", label: "Cookie" }, { value: "passkey", label: "Passkey" }, { value: "api_key", label: "API Key" }, { value: "password", label: "账号密码" }, ]; const authLabel = (k: string) => AUTH_KINDS.find((a) => a.value === k)?.label ?? k; const ACCOUNT_STATUS: Record = { active: { label: "有效", tone: "success" }, invalid: { label: "凭证无效", tone: "destructive" }, expired: { label: "已过期", tone: "warning" }, unknown: { label: "未验证", tone: "neutral" }, }; const ADAPTER: Record = { nexusphp: "NexusPHP", mteam_api: "M-Team API", api: "站点 API", generic_rss: "通用 RSS", }; // ─── 站点列表 ─── function SitesBlock() { const sites = useSiteOptions(); const accounts = useSiteAccounts(); const toggle = useToggleSite(); const [open, setOpen] = useState(null); const [registering, setRegistering] = useState(false); const accountCount = (id: string) => accounts.data?.items.filter((a) => a.siteId === id).length ?? 0; return ( setRegistering(true)}> 登记站点 } > r.id} loading={sites.isPending} error={sites.error} onRetry={() => sites.refetch()} onRowClick={(r) => setOpen(r)} empty={{ title: "还没有登记站点", description: "从内置站点定义里登记。" }} columns={[ { key: "name", header: "站点", cell: (r) => (

{r.name}

{ADAPTER[r.adapter] ?? r.adapter}

), }, { key: "snapshot", header: "快照", hideBelow: "md", cell: (r) => !r.snapshotEnabled ? ( 关闭 ) : (

每 {Math.round((r.snapshotIntervalSec ?? 3600) / 60)} 分钟

{r.lastSnapshotAt ? formatRelative(r.lastSnapshotAt) : "从未快照"}

), }, { key: "accounts", header: "账号", hideBelow: "sm", cell: (r) => accountCount(r.id) }, { key: "health", header: "健康", hideBelow: "sm", cell: (r) => { const h = healthLabel(r.circuitState === "open" ? "circuit_open" : r.healthStatus); return {h.label}; }, }, { key: "enabled", header: "启用", align: "right", cell: (r) => ( // biome-ignore lint/a11y/noStaticElementInteractions: 只拦截冒泡,开关不触发整行打开抽屉 e.stopPropagation()} onKeyDown={(e) => e.stopPropagation()}> { try { await toggle.mutateAsync({ id: r.id, enabled: v }); toast.success(v ? `${r.name}已启用` : `${r.name}已停用`); } catch (err) { toast.error(errorMessage(err, "切换失败")); } }} /> ), }, ]} /> setOpen(null)} /> setRegistering(false)} />
); } function SiteDrawer({ site, onClose }: { site: Site | null; onClose: () => void }) { const update = useUpdateSite(); const [v, setV] = useState(() => ({ baseUrl: site?.baseUrl ?? "", snapshotEnabled: site?.snapshotEnabled ?? false, snapshotMinutes: site?.snapshotIntervalSec ? String(Math.round(site.snapshotIntervalSec / 60)) : "", requestsPerMinute: site?.rateLimit.requestsPerMinute ? String(site.rateLimit.requestsPerMinute) : "", minIntervalMs: site?.rateLimit.minIntervalMs !== undefined ? String(site.rateLimit.minIntervalMs) : "", concurrency: site?.rateLimit.concurrency ? String(site.rateLimit.concurrency) : "", })); const [errors, setErrors] = useState>({}); const set = (k: string) => (x: string | boolean) => setV((s) => ({ ...s, [k]: x })); const save = async (e: React.FormEvent) => { e.preventDefault(); if (!site) return; const minutes = num(v.snapshotMinutes); const rateLimit = { ...(num(v.requestsPerMinute) !== undefined ? { requestsPerMinute: num(v.requestsPerMinute) } : {}), ...(num(v.minIntervalMs) !== undefined ? { minIntervalMs: num(v.minIntervalMs) } : {}), ...(num(v.concurrency) !== undefined ? { concurrency: num(v.concurrency) } : {}), }; const parsed = SiteUpdate.safeParse({ baseUrl: str(v.baseUrl), snapshotEnabled: v.snapshotEnabled === true, snapshotIntervalSec: minutes === undefined ? null : Math.round(minutes * 60), rateLimit, }); if (!parsed.success) { const fe = zodFieldErrors(parsed.error.issues); const map: Record = { snapshotIntervalSec: "snapshotMinutes", "rateLimit.requestsPerMinute": "requestsPerMinute", "rateLimit.minIntervalMs": "minIntervalMs", "rateLimit.concurrency": "concurrency", }; for (const [k, to] of Object.entries(map)) if (fe[k]) fe[to] = "填正整数"; setErrors(fe); return; } setErrors({}); try { await update.mutateAsync({ id: site.id, body: parsed.data }); toast.success(`${site.name}已保存`); onClose(); } catch (err) { toast.error(errorMessage(err, "保存失败")); } }; return ( !o && !update.isPending && onClose()} title={site?.name ?? ""} description={ site ? ( 在站点工作台打开 ) : null } footer={ <> } >
{site ? : null}
); } function RegisterSiteDrawer({ open, onClose }: { open: boolean; onClose: () => void }) { const defs = useSiteDefinitions(); const create = useCreateSite(); const [key, setKey] = useState(""); const [error, setError] = useState(); const available = (defs.data?.items ?? []).filter((d) => !d.siteId); const def = available.find((d) => d.key === key); const submit = async (e: React.FormEvent) => { e.preventDefault(); if (!def) { setError("选择站点"); return; } setError(undefined); try { await create.mutateAsync({ key: def.key, name: def.name, adapter: def.adapter, baseUrl: def.baseUrl }); toast.success(`${def.name}已登记`); setKey(""); onClose(); } catch (err) { toast.error(errorMessage(err, "登记失败")); } }; return ( !o && !create.isPending && onClose()} title="登记站点" description="登记后再录入账号凭证。" footer={ <> } >
({ value: d.key, label: `${d.name}${d.shippedEnabled ? "" : "(未实测)"}`, })), }} value={key} error={error} onChange={(x) => setKey(String(x))} disabled={create.isPending} /> {def ? (
地址
{def.baseUrl}
适配器
{ADAPTER[def.adapter] ?? def.adapter}
凭证类型
{def.authSupported.map(authLabel).join("、") || "—"}
默认限流
间隔 {def.defaultRateLimit.minIntervalMs} 毫秒,突发 {def.defaultRateLimit.burst}
) : null}
); } // ─── 站点账号 ─── function CredentialForm({ siteId, onDone }: { siteId?: string; onDone?: () => void }) { const sites = useSiteOptions(); const put = usePutSiteCredentials(); const [v, setV] = useState({ siteId: siteId ?? "", label: "default", authKind: "cookie", username: "", credential: "", }); const [errors, setErrors] = useState>({}); const siteOpts = (sites.data?.items ?? []).map((s) => ({ value: s.id, label: s.name })); const set = (k: string) => (x: string | boolean) => setV((s) => ({ ...s, [k]: x })); const submit = async (e: React.FormEvent) => { e.preventDefault(); const fe: Record = {}; if (!str(v.siteId)) fe.siteId = "选择站点"; const parsed = SiteCredentialsPut.safeParse({ label: str(v.label) || "default", authKind: str(v.authKind), credential: str(v.credential), username: v.authKind === "password" ? str(v.username) || undefined : undefined, }); if (!parsed.success) Object.assign(fe, zodFieldErrors(parsed.error.issues)); if (fe.credential) fe.credential = "填写凭证"; if (Object.keys(fe).length || !parsed.success) { setErrors(fe); return; } setErrors({}); try { const r = await put.mutateAsync({ siteId: str(v.siteId), body: parsed.data }); toast.success(r.created ? "站点账号已添加" : "凭证已替换"); setV((s) => ({ ...s, credential: "" })); onDone?.(); } catch (err) { toast.error(errorMessage(err, "保存失败")); } }; return (
{siteId ? null : ( )} {v.authKind === "password" ? ( ) : null}
); } function AccountStatus({ a }: { a: SiteAccount }) { if (!a.enabled) return 停用; if (!a.secretRef) return 缺凭证; const s = ACCOUNT_STATUS[a.status] ?? ACCOUNT_STATUS.unknown; return {s?.label ?? a.status}; } /** 站点抽屉里:本站账号 + 录入凭证 */ function SiteAccountsInline({ siteId }: { siteId: string }) { const accounts = useSiteAccounts(); const rows = (accounts.data?.items ?? []).filter((a) => a.siteId === siteId); const [adding, setAdding] = useState(rows.length === 0); return ( setAdding(true)}> 录入凭证 ) } > {rows.length ? (
    {rows.map((a) => (
  • {a.label} · {authLabel(a.authKind)}
  • ))}
) : null} {adding ? setAdding(false)} /> : null}
); } function AccountsBlock() { const sites = useSiteOptions(); const accounts = useSiteAccounts(); const update = useUpdateSiteAccount(); const remove = useRemoveSiteAccount(); const [adding, setAdding] = useState(false); const [deleting, setDeleting] = useState(null); const siteName = (id: string) => sites.data?.items.find((s) => s.id === id)?.name ?? id.slice(0, 8); return ( setAdding(true)}> 录入凭证 } > r.id} loading={accounts.isPending} error={accounts.error} onRetry={() => accounts.refetch()} empty={{ title: "还没有站点账号" }} columns={[ { key: "site", header: "站点 / 标签", cell: (r) => (

{siteName(r.siteId)}

{r.label}

), }, { key: "kind", header: "类型", hideBelow: "sm", cell: (r) => authLabel(r.authKind) }, { key: "verified", header: "最近验证", hideBelow: "md", cell: (r) => (r.lastVerifiedAt ? formatRelative(r.lastVerifiedAt) : "—"), }, { key: "status", header: "状态", cell: (r) => }, { key: "act", header: 操作, align: "right", cell: (r) => (
{ try { await update.mutateAsync({ id: r.id, body: { enabled: v } }); toast.success(v ? "账号已启用" : "账号已停用"); } catch (err) { toast.error(errorMessage(err, "切换失败")); } }} />
), }, ]} /> !o && setAdding(false)} title="录入凭证" description="同一站点、同一标签再次保存会替换旧凭证。" > setAdding(false)} /> !o && setDeleting(null)} title={`删除账号「${deleting?.label ?? ""}」?`} description="账号和凭证一并删除,无法恢复。" confirmLabel="删除账号" destructive onConfirm={async () => { if (!deleting) return; try { await remove.mutateAsync(deleting.id); toast.success("站点账号已删除"); } catch (err) { toast.error(errorMessage(err, "删除失败")); throw err; } }} />
); } export function SitesSection() { return (
); }