Files
UHD_Media_Tool/apps/web/app/components/settings/sites.tsx
T
rootandClaude Opus 5.5 c67f6a9682 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]>
2026-09-28 03:05:51 +08:00

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>
);
}