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