feat(web): settings with 9 sidebar sections and Agent upstream management
- /console/settings#<section>[/<anchor>]; legacy ?tab= redirected in clientLoader - SETTINGS_SECTIONS in contracts with field-ownership test - Agent section: upstream list, create wizard (provider -> credentials/model -> login/test/default), edit drawer with OAuth browser/device login, test, set default, delete - sites section with site registration, snapshot/rate-limit drawer and account credentials - path mappings moved into downloader / media-server drawers; metadata and account sections Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
459991421e
commit
c67f6a9682
25 files changed
+3842
-942
No files matched your search
@@ -0,0 +1,625 @@
|
||||
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<string, { label: string; tone: "success" | "warning" | "destructive" | "neutral" }> = {
|
||||
active: { label: "有效", tone: "success" },
|
||||
invalid: { label: "凭证无效", tone: "destructive" },
|
||||
expired: { label: "已过期", tone: "warning" },
|
||||
unknown: { label: "未验证", tone: "neutral" },
|
||||
};
|
||||
|
||||
const ADAPTER: Record<string, string> = {
|
||||
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<Site | null>(null);
|
||||
const [registering, setRegistering] = useState(false);
|
||||
const accountCount = (id: string) => accounts.data?.items.filter((a) => a.siteId === id).length ?? 0;
|
||||
|
||||
return (
|
||||
<SettingsBlock
|
||||
section="sites"
|
||||
anchor="sites"
|
||||
title="站点"
|
||||
description="停用后不再访问该站点,也不产生新候选;已抓到的资源仍可查询。点行改快照周期和限流。"
|
||||
plain
|
||||
actions={
|
||||
<Button size="sm" onClick={() => setRegistering(true)}>
|
||||
<Plus />
|
||||
登记站点
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<DataTable
|
||||
rows={sites.data?.items}
|
||||
rowKey={(r) => r.id}
|
||||
loading={sites.isPending}
|
||||
error={sites.error}
|
||||
onRetry={() => sites.refetch()}
|
||||
onRowClick={(r) => setOpen(r)}
|
||||
empty={{ title: "还没有登记站点", description: "从内置站点定义里登记。" }}
|
||||
columns={[
|
||||
{
|
||||
key: "name",
|
||||
header: "站点",
|
||||
cell: (r) => (
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium">{r.name}</p>
|
||||
<p className="truncate text-meta text-muted-foreground">{ADAPTER[r.adapter] ?? r.adapter}</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "snapshot",
|
||||
header: "快照",
|
||||
hideBelow: "md",
|
||||
cell: (r) =>
|
||||
!r.snapshotEnabled ? (
|
||||
<span className="text-caption text-muted-foreground">关闭</span>
|
||||
) : (
|
||||
<div className="text-caption">
|
||||
<p>每 {Math.round((r.snapshotIntervalSec ?? 3600) / 60)} 分钟</p>
|
||||
<p className={snapshotStale(r) ? "text-warning" : "text-muted-foreground"}>
|
||||
{r.lastSnapshotAt ? formatRelative(r.lastSnapshotAt) : "从未快照"}
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ 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 <StatusBadge tone={h.tone}>{h.label}</StatusBadge>;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "enabled",
|
||||
header: "启用",
|
||||
align: "right",
|
||||
cell: (r) => (
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: 只拦截冒泡,开关不触发整行打开抽屉
|
||||
<span onClick={(e) => e.stopPropagation()} onKeyDown={(e) => e.stopPropagation()}>
|
||||
<Switch
|
||||
checked={r.enabled}
|
||||
aria-label={`${r.enabled ? "停用" : "启用"}${r.name}`}
|
||||
disabled={toggle.isPending}
|
||||
onCheckedChange={async (v) => {
|
||||
try {
|
||||
await toggle.mutateAsync({ id: r.id, enabled: v });
|
||||
toast.success(v ? `${r.name}已启用` : `${r.name}已停用`);
|
||||
} catch (err) {
|
||||
toast.error(errorMessage(err, "切换失败"));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<SiteDrawer key={open?.id ?? "none"} site={open} onClose={() => setOpen(null)} />
|
||||
<RegisterSiteDrawer open={registering} onClose={() => setRegistering(false)} />
|
||||
</SettingsBlock>
|
||||
);
|
||||
}
|
||||
|
||||
function SiteDrawer({ site, onClose }: { site: Site | null; onClose: () => void }) {
|
||||
const update = useUpdateSite();
|
||||
const [v, setV] = useState<FormValues>(() => ({
|
||||
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<Record<string, string>>({});
|
||||
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<string, string> = {
|
||||
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 (
|
||||
<DetailSheet
|
||||
open={!!site}
|
||||
onOpenChange={(o) => !o && !update.isPending && onClose()}
|
||||
title={site?.name ?? ""}
|
||||
description={
|
||||
site ? (
|
||||
<Link to={consolePath("sites", site.id)} className="underline">
|
||||
在站点工作台打开
|
||||
</Link>
|
||||
) : null
|
||||
}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={onClose} disabled={update.isPending}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" form="site-form" disabled={update.isPending}>
|
||||
{update.isPending ? <LoaderCircle className="animate-spin" /> : null}
|
||||
保存
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id="site-form" onSubmit={save} noValidate className="grid gap-x-4 gap-y-1 sm:grid-cols-2">
|
||||
<FieldControl
|
||||
spec={{ name: "baseUrl", label: "地址", kind: "url", wide: true }}
|
||||
value={v.baseUrl}
|
||||
error={errors.baseUrl}
|
||||
onChange={set("baseUrl")}
|
||||
disabled={update.isPending}
|
||||
/>
|
||||
<FieldControl
|
||||
spec={{ name: "snapshotEnabled", label: "定时快照", kind: "switch" }}
|
||||
value={v.snapshotEnabled}
|
||||
onChange={set("snapshotEnabled")}
|
||||
disabled={update.isPending}
|
||||
/>
|
||||
<FieldControl
|
||||
spec={{ name: "snapshotMinutes", label: "快照周期(分钟)", kind: "number", placeholder: "60" }}
|
||||
value={v.snapshotMinutes}
|
||||
error={errors.snapshotMinutes}
|
||||
onChange={set("snapshotMinutes")}
|
||||
disabled={update.isPending || v.snapshotEnabled !== true}
|
||||
/>
|
||||
<FieldControl
|
||||
spec={{ name: "requestsPerMinute", label: "每分钟请求数", kind: "number", hint: "留空用站点定义的默认值" }}
|
||||
value={v.requestsPerMinute}
|
||||
error={errors.requestsPerMinute}
|
||||
onChange={set("requestsPerMinute")}
|
||||
disabled={update.isPending}
|
||||
/>
|
||||
<FieldControl
|
||||
spec={{ name: "minIntervalMs", label: "最小请求间隔(毫秒)", kind: "number" }}
|
||||
value={v.minIntervalMs}
|
||||
error={errors.minIntervalMs}
|
||||
onChange={set("minIntervalMs")}
|
||||
disabled={update.isPending}
|
||||
/>
|
||||
<FieldControl
|
||||
spec={{ name: "concurrency", label: "并发", kind: "number", hint: "快照、搜索、浏览器共用" }}
|
||||
value={v.concurrency}
|
||||
error={errors.concurrency}
|
||||
onChange={set("concurrency")}
|
||||
disabled={update.isPending}
|
||||
/>
|
||||
</form>
|
||||
{site ? <SiteAccountsInline siteId={site.id} /> : null}
|
||||
</DetailSheet>
|
||||
);
|
||||
}
|
||||
|
||||
function RegisterSiteDrawer({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
const defs = useSiteDefinitions();
|
||||
const create = useCreateSite();
|
||||
const [key, setKey] = useState("");
|
||||
const [error, setError] = useState<string | undefined>();
|
||||
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 (
|
||||
<DetailSheet
|
||||
open={open}
|
||||
onOpenChange={(o) => !o && !create.isPending && onClose()}
|
||||
title="登记站点"
|
||||
description="登记后再录入账号凭证。"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={onClose} disabled={create.isPending}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" form="register-site-form" disabled={create.isPending || !def}>
|
||||
{create.isPending ? <LoaderCircle className="animate-spin" /> : null}
|
||||
登记
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id="register-site-form" onSubmit={submit} noValidate>
|
||||
<FieldControl
|
||||
spec={{
|
||||
name: "definition",
|
||||
label: "站点",
|
||||
kind: "select",
|
||||
required: true,
|
||||
placeholder: defs.isPending ? "加载中…" : available.length ? "选择" : "内置站点都已登记",
|
||||
options: available.map((d) => ({
|
||||
value: d.key,
|
||||
label: `${d.name}${d.shippedEnabled ? "" : "(未实测)"}`,
|
||||
})),
|
||||
}}
|
||||
value={key}
|
||||
error={error}
|
||||
onChange={(x) => setKey(String(x))}
|
||||
disabled={create.isPending}
|
||||
/>
|
||||
</form>
|
||||
{def ? (
|
||||
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-caption">
|
||||
<dt className="text-muted-foreground">地址</dt>
|
||||
<dd className="break-all">{def.baseUrl}</dd>
|
||||
<dt className="text-muted-foreground">适配器</dt>
|
||||
<dd>{ADAPTER[def.adapter] ?? def.adapter}</dd>
|
||||
<dt className="text-muted-foreground">凭证类型</dt>
|
||||
<dd>{def.authSupported.map(authLabel).join("、") || "—"}</dd>
|
||||
<dt className="text-muted-foreground">默认限流</dt>
|
||||
<dd>
|
||||
间隔 {def.defaultRateLimit.minIntervalMs} 毫秒,突发 {def.defaultRateLimit.burst}
|
||||
</dd>
|
||||
</dl>
|
||||
) : null}
|
||||
</DetailSheet>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 站点账号 ───
|
||||
|
||||
function CredentialForm({ siteId, onDone }: { siteId?: string; onDone?: () => void }) {
|
||||
const sites = useSiteOptions();
|
||||
const put = usePutSiteCredentials();
|
||||
const [v, setV] = useState<FormValues>({
|
||||
siteId: siteId ?? "",
|
||||
label: "default",
|
||||
authKind: "cookie",
|
||||
username: "",
|
||||
credential: "",
|
||||
});
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
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<string, string> = {};
|
||||
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 (
|
||||
<form onSubmit={submit} noValidate className="grid gap-x-4 gap-y-1 sm:grid-cols-2">
|
||||
{siteId ? null : (
|
||||
<FieldControl
|
||||
spec={{
|
||||
name: "siteId",
|
||||
label: "站点",
|
||||
kind: "select",
|
||||
options: siteOpts,
|
||||
placeholder: sites.isPending ? "加载中…" : siteOpts.length ? "选择" : "还没有登记站点",
|
||||
required: true,
|
||||
}}
|
||||
value={v.siteId}
|
||||
error={errors.siteId}
|
||||
onChange={set("siteId")}
|
||||
disabled={put.isPending}
|
||||
/>
|
||||
)}
|
||||
<FieldControl
|
||||
spec={{ name: "label", label: "标签", kind: "text", hint: "同一站点有多个账号时用来区分;同标签会替换凭证" }}
|
||||
value={v.label}
|
||||
error={errors.label}
|
||||
onChange={set("label")}
|
||||
disabled={put.isPending}
|
||||
/>
|
||||
<FieldControl
|
||||
spec={{ name: "authKind", label: "凭证类型", kind: "select", options: AUTH_KINDS }}
|
||||
value={v.authKind}
|
||||
error={errors.authKind}
|
||||
onChange={set("authKind")}
|
||||
disabled={put.isPending}
|
||||
/>
|
||||
{v.authKind === "password" ? (
|
||||
<FieldControl
|
||||
spec={{ name: "username", label: "登录名", kind: "text" }}
|
||||
value={v.username}
|
||||
error={errors.username}
|
||||
onChange={set("username")}
|
||||
disabled={put.isPending}
|
||||
/>
|
||||
) : null}
|
||||
<FieldControl
|
||||
spec={{
|
||||
name: "credential",
|
||||
label: v.authKind === "password" ? "密码" : "凭证",
|
||||
kind: "password",
|
||||
required: true,
|
||||
wide: true,
|
||||
placeholder: v.authKind === "cookie" ? "整段 Cookie" : undefined,
|
||||
hint: "只写入,保存后不回显",
|
||||
}}
|
||||
value={v.credential}
|
||||
error={errors.credential}
|
||||
onChange={set("credential")}
|
||||
disabled={put.isPending}
|
||||
/>
|
||||
<div className="sm:col-span-2">
|
||||
<Button type="submit" size="sm" disabled={put.isPending}>
|
||||
{put.isPending ? <LoaderCircle className="animate-spin" /> : <KeyRound />}
|
||||
保存凭证
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function AccountStatus({ a }: { a: SiteAccount }) {
|
||||
if (!a.enabled) return <StatusBadge tone="neutral">停用</StatusBadge>;
|
||||
if (!a.secretRef) return <StatusBadge tone="warning">缺凭证</StatusBadge>;
|
||||
const s = ACCOUNT_STATUS[a.status] ?? ACCOUNT_STATUS.unknown;
|
||||
return <StatusBadge tone={s?.tone ?? "neutral"}>{s?.label ?? a.status}</StatusBadge>;
|
||||
}
|
||||
|
||||
/** 站点抽屉里:本站账号 + 录入凭证 */
|
||||
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 (
|
||||
<DetailSection
|
||||
title="站点账号"
|
||||
actions={
|
||||
adding ? null : (
|
||||
<Button variant="outline" size="xs" onClick={() => setAdding(true)}>
|
||||
<Plus />
|
||||
录入凭证
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
>
|
||||
{rows.length ? (
|
||||
<ul className="mb-3 divide-y divide-border/60 rounded-lg border border-border/60">
|
||||
{rows.map((a) => (
|
||||
<li key={a.id} className="flex items-center justify-between gap-2 px-3 py-2 text-caption">
|
||||
<span className="min-w-0 truncate">
|
||||
{a.label} · {authLabel(a.authKind)}
|
||||
</span>
|
||||
<AccountStatus a={a} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
{adding ? <CredentialForm siteId={siteId} onDone={() => setAdding(false)} /> : null}
|
||||
</DetailSection>
|
||||
);
|
||||
}
|
||||
|
||||
function AccountsBlock() {
|
||||
const sites = useSiteOptions();
|
||||
const accounts = useSiteAccounts();
|
||||
const update = useUpdateSiteAccount();
|
||||
const remove = useRemoveSiteAccount();
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [deleting, setDeleting] = useState<SiteAccount | null>(null);
|
||||
const siteName = (id: string) => sites.data?.items.find((s) => s.id === id)?.name ?? id.slice(0, 8);
|
||||
|
||||
return (
|
||||
<SettingsBlock
|
||||
section="sites"
|
||||
anchor="accounts"
|
||||
title="站点账号"
|
||||
description="按「站点 + 标签」新建或替换凭证。凭证只写入,保存后不回显。"
|
||||
plain
|
||||
actions={
|
||||
<Button size="sm" onClick={() => setAdding(true)}>
|
||||
<KeyRound />
|
||||
录入凭证
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<DataTable
|
||||
rows={accounts.data?.items}
|
||||
rowKey={(r) => r.id}
|
||||
loading={accounts.isPending}
|
||||
error={accounts.error}
|
||||
onRetry={() => accounts.refetch()}
|
||||
empty={{ title: "还没有站点账号" }}
|
||||
columns={[
|
||||
{
|
||||
key: "site",
|
||||
header: "站点 / 标签",
|
||||
cell: (r) => (
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium">{siteName(r.siteId)}</p>
|
||||
<p className="text-meta text-muted-foreground">{r.label}</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ 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) => <AccountStatus a={r} /> },
|
||||
{
|
||||
key: "act",
|
||||
header: <span className="sr-only">操作</span>,
|
||||
align: "right",
|
||||
cell: (r) => (
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Switch
|
||||
checked={r.enabled}
|
||||
aria-label={`${r.enabled ? "停用" : "启用"}账号 ${r.label}`}
|
||||
disabled={update.isPending}
|
||||
onCheckedChange={async (v) => {
|
||||
try {
|
||||
await update.mutateAsync({ id: r.id, body: { enabled: v } });
|
||||
toast.success(v ? "账号已启用" : "账号已停用");
|
||||
} catch (err) {
|
||||
toast.error(errorMessage(err, "切换失败"));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`删除账号 ${r.label}`}
|
||||
className="hover:text-destructive"
|
||||
onClick={() => setDeleting(r)}
|
||||
>
|
||||
<Trash2 />
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<DetailSheet
|
||||
open={adding}
|
||||
onOpenChange={(o) => !o && setAdding(false)}
|
||||
title="录入凭证"
|
||||
description="同一站点、同一标签再次保存会替换旧凭证。"
|
||||
>
|
||||
<CredentialForm onDone={() => setAdding(false)} />
|
||||
</DetailSheet>
|
||||
<ConfirmDialog
|
||||
open={!!deleting}
|
||||
onOpenChange={(o) => !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;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</SettingsBlock>
|
||||
);
|
||||
}
|
||||
|
||||
export function SitesSection() {
|
||||
return (
|
||||
<div className="grid gap-8">
|
||||
<SitesBlock />
|
||||
<AccountsBlock />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user