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:
rootandClaude Opus 5.5 committed 2026-09-28 03:05:51 +08:00
1 parent 459991421e
commit c67f6a9682
25 files changed
+3842 -942

No files matched your search

@@ -0,0 +1,331 @@
import { PasswordChangeRequest, TotpCode } from "@umt/contracts";
import { LoaderCircle, LogOut } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { z } from "zod";
import { useChangePassword, useLogout, useSession, useTotpAction, useTotpStatus } from "~/api/auth.ts";
import { AgentShortcutsHelp } from "~/components/console/agent-launcher.tsx";
import { FormField, zodFieldErrors } from "~/components/console/form-field.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Input } from "~/components/ui/input.tsx";
import { clearAuth, errorMessage, getToken, LOGIN_PATH, setToken } from "~/lib/api.ts";
import { formatDateTime } from "~/lib/utils.ts";
import { anchorId, SettingsBlock } from "./settings-block.tsx";
// 账户与安全:改密、二次验证(TOTP)、当前会话与本机 Token、快捷键
const PasswordForm = PasswordChangeRequest.extend({ confirm: z.string() }).refine((v) => v.newPassword === v.confirm, {
message: "两次输入的新密码不一致",
path: ["confirm"],
});
const EMPTY_PWD = { currentPassword: "", newPassword: "", confirm: "" };
function PasswordBlock({ disabled }: { disabled: boolean }) {
const change = useChangePassword();
const [form, setForm] = useState(EMPTY_PWD);
const [errors, setErrors] = useState<Record<string, string>>({});
const dirty = Object.values(form).some(Boolean);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
const parsed = PasswordForm.safeParse(form);
if (!parsed.success) {
setErrors(zodFieldErrors(parsed.error.issues));
return;
}
setErrors({});
try {
const r = await change.mutateAsync({ currentPassword: form.currentPassword, newPassword: form.newPassword });
toast.success(r.revokedSessions ? `密码已修改,注销了其它 ${r.revokedSessions} 个会话` : "密码已修改");
setForm(EMPTY_PWD);
} catch (err) {
const msg = errorMessage(err);
if (/旧密码|当前密码|password/i.test(msg)) setErrors({ currentPassword: "当前密码不正确" });
else toast.error(msg);
}
};
const set = (k: keyof typeof form) => (e: React.ChangeEvent<HTMLInputElement>) =>
setForm((f) => ({ ...f, [k]: e.target.value }));
const off = disabled || change.isPending;
return (
<SettingsBlock
section="account"
anchor="password"
title="修改密码"
description="修改后其它设备上的会话全部注销,当前会话保留。"
dirty={dirty}
>
<form onSubmit={submit} noValidate className="grid gap-x-4 gap-y-1 sm:grid-cols-3">
<FormField label="当前密码" htmlFor="acc-pwd-cur" error={errors.currentPassword}>
<Input
id="acc-pwd-cur"
type="password"
autoComplete="current-password"
value={form.currentPassword}
onChange={set("currentPassword")}
aria-invalid={!!errors.currentPassword}
disabled={off}
/>
</FormField>
<FormField label="新密码" htmlFor="acc-pwd-new" error={errors.newPassword} hint="至少 10 个字符">
<Input
id="acc-pwd-new"
type="password"
autoComplete="new-password"
value={form.newPassword}
onChange={set("newPassword")}
aria-invalid={!!errors.newPassword}
disabled={off}
/>
</FormField>
<FormField label="确认新密码" htmlFor="acc-pwd-confirm" error={errors.confirm}>
<Input
id="acc-pwd-confirm"
type="password"
autoComplete="new-password"
value={form.confirm}
onChange={set("confirm")}
aria-invalid={!!errors.confirm}
disabled={off}
/>
</FormField>
<div className="sm:col-span-3">
<Button type="submit" size="sm" disabled={off || !dirty}>
{change.isPending ? <LoaderCircle className="animate-spin" /> : null}
修改密码
</Button>
</div>
</form>
</SettingsBlock>
);
}
function TotpBlock({ disabled }: { disabled: boolean }) {
const status = useTotpStatus(!disabled);
const action = useTotpAction();
const [setup, setSetup] = useState<{ secret: string; uri: string } | null>(null);
const [code, setCode] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const enabled = status.data?.enabled === true;
const reset = () => {
setSetup(null);
setCode("");
setPassword("");
setError(null);
};
const begin = async () => {
setError(null);
try {
const r = await action.mutateAsync({ action: "begin" });
if (r.secret && r.otpauthUri) setSetup({ secret: r.secret, uri: r.otpauthUri });
} catch (err) {
setError(errorMessage(err));
}
};
const submit = async (e: React.FormEvent) => {
e.preventDefault();
if (!TotpCode.safeParse(code).success) {
setError("验证码为 6 位数字");
return;
}
if (enabled && !password) {
setError("输入当前密码");
return;
}
setError(null);
try {
if (enabled) {
await action.mutateAsync({ action: "disable", password, code });
toast.success("二次验证已关闭");
} else {
await action.mutateAsync({ action: "enable", code });
toast.success("二次验证已开启");
}
reset();
} catch (err) {
setError(errorMessage(err));
}
};
const grouped = setup?.secret.replace(/(.{4})/g, "$1 ").trim();
return (
<SettingsBlock
section="account"
anchor="totp"
title="二次验证(TOTP)"
description={enabled ? "关闭需要当前密码和验证码。" : "开启后登录还要输入验证器里的 6 位验证码。"}
actions={
disabled ? null : status.isPending ? null : (
<StatusBadge tone={enabled ? "success" : "neutral"}>{enabled ? "已开启" : "未开启"}</StatusBadge>
)
}
>
{disabled ? (
<p className="text-caption text-muted-foreground">当前用 API Key 访问,登录会话才能修改。</p>
) : !enabled && !setup ? (
<div className="grid justify-items-start gap-2">
{status.data?.pending ? (
<p className="text-meta text-muted-foreground">有一个未确认的密钥,重新生成会作废它。</p>
) : null}
{error ? <p className="text-meta text-destructive">{error}</p> : null}
<Button size="sm" onClick={begin} disabled={action.isPending || status.isPending}>
{action.isPending ? <LoaderCircle className="animate-spin" /> : null}
生成密钥
</Button>
</div>
) : (
<form onSubmit={submit} noValidate className="grid max-w-md gap-1">
{setup ? (
<div className="mb-2 grid gap-1.5">
<p className="text-meta text-muted-foreground">
在验证器里手动输入密钥,或粘贴 otpauth 链接。密钥只显示这一次。
</p>
<code className="rounded-md bg-muted px-2 py-1.5 font-mono text-sm break-all select-all">{grouped}</code>
<code className="rounded-md bg-muted px-2 py-1.5 font-mono text-meta break-all text-muted-foreground select-all">
{setup.uri}
</code>
</div>
) : null}
{enabled ? (
<FormField label="当前密码" htmlFor="acc-totp-pwd">
<Input
id="acc-totp-pwd"
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</FormField>
) : null}
<FormField label="验证码" htmlFor="acc-totp-code" error={error ?? undefined}>
<Input
id="acc-totp-code"
inputMode="numeric"
autoComplete="one-time-code"
maxLength={6}
value={code}
onChange={(e) => setCode(e.target.value.replace(/\D/g, ""))}
aria-invalid={!!error}
/>
</FormField>
<div className="flex gap-2">
<Button type="submit" size="sm" variant={enabled ? "destructive" : "default"} disabled={action.isPending}>
{action.isPending ? <LoaderCircle className="animate-spin" /> : null}
{enabled ? "关闭二次验证" : "确认开启"}
</Button>
{setup ? (
<Button type="button" variant="ghost" size="sm" onClick={reset} disabled={action.isPending}>
取消
</Button>
) : null}
</div>
</form>
)}
</SettingsBlock>
);
}
function SessionBlock() {
const session = useSession();
const logout = useLogout();
const [hasToken, setHasToken] = useState(() => !!getToken());
const [token, setTokenValue] = useState("");
const s = session.data;
const doLogout = async () => {
try {
await logout.mutateAsync();
} catch {
// 会话已失效也照样回登录页
}
window.location.assign(LOGIN_PATH);
};
return (
<SettingsBlock section="account" anchor="session" title="会话">
<dl className="grid grid-cols-[auto_1fr] gap-x-6 gap-y-1.5 text-caption">
<dt className="text-muted-foreground">身份</dt>
<dd>
{s?.actor?.name ?? "—"} · {s?.actor?.type === "api_key" ? "API Key" : "管理员"}
</dd>
<dt className="text-muted-foreground">当前会话</dt>
<dd>{s?.expiresAt ? `到 ${formatDateTime(s.expiresAt)} 失效` : "未使用登录会话"}</dd>
<dt className="text-muted-foreground">其它会话</dt>
<dd className="text-muted-foreground">修改密码时全部注销</dd>
</dl>
<div className="mt-4 border-t border-border/60 pt-4">
<p className="text-caption font-medium">本机 Token</p>
<p className="mb-2 text-meta text-muted-foreground">
只存在这个浏览器,有值时请求会带上它。{hasToken ? "已设置。" : "未设置。"}
</p>
<div className="flex max-w-md flex-wrap items-center gap-2">
<Input
type="password"
autoComplete="off"
aria-label="本机 Token"
placeholder={hasToken ? "输入新值替换" : "粘贴 API Key"}
value={token}
onChange={(e) => setTokenValue(e.target.value)}
className="min-w-0 flex-1"
/>
<Button
size="sm"
variant="outline"
disabled={!token.trim()}
onClick={() => {
setToken(token.trim());
setTokenValue("");
setHasToken(true);
toast.success("本机 Token 已保存");
}}
>
保存
</Button>
{hasToken ? (
<Button
size="sm"
variant="ghost"
onClick={() => {
clearAuth();
setHasToken(false);
toast.success("本机 Token 已清除");
}}
>
清除
</Button>
) : null}
</div>
</div>
<div className="mt-4 border-t border-border/60 pt-4">
<Button variant="outline" size="sm" onClick={doLogout} disabled={logout.isPending}>
<LogOut />
退出登录
</Button>
</div>
</SettingsBlock>
);
}
export function AccountSection() {
const session = useSession();
const cookie = !!session.data?.expiresAt;
return (
<div className="grid gap-8">
<PasswordBlock disabled={!cookie} />
<TotpBlock disabled={!cookie} />
<SessionBlock />
{/* 快捷键说明自带标题,只包卡片与锚点 */}
<section id={anchorId("account", "shortcuts")} className="pg-slab min-w-0 scroll-mt-24 rounded-xl p-5">
<AgentShortcutsHelp />
</section>
</div>
);
}
+199
View File
@@ -0,0 +1,199 @@
import { PiUpstreamsBlock } from "./pi-upstreams.tsx";
import type { FieldValue } from "./resource-section.tsx";
import { num, str } from "./resource-section.tsx";
import { type CardSpec, n, SettingsCard, SettingsGate } from "./settings-card.tsx";
// Agent 分区:上游(pi_upstream)+ 上下文与子代理(pi)+ 预算(budget.agent* 与单次上限)+ 自动调查 + 高级
/** 可清空的数字:空串 → null */
const nullNum = (v: FieldValue | undefined) => (str(v) === "" ? null : Number(str(v)));
const RUNTIME_CARD: CardSpec = {
anchor: "runtime",
title: "上下文与子代理",
fields: [
{
name: "pi.contextBudget",
label: "上下文预算(token)",
kind: "number",
hint: "4096–2000000,超过模型窗口时按窗口算",
},
{ name: "pi.subagentsEnabled", label: "允许子代理", kind: "switch" },
{ name: "pi.compaction.enabled", label: "上下文压缩", kind: "switch" },
{
name: "pi.compaction.thresholdRatio",
label: "压缩触发比例",
kind: "number",
hint: "0.3–0.95,用到预算的这个比例时压缩",
visible: (v) => v["pi.compaction.enabled"] === true,
},
],
fromSettings: (s) => ({
"pi.contextBudget": n(s.pi.contextBudget),
"pi.subagentsEnabled": s.pi.subagentsEnabled,
"pi.compaction.enabled": s.pi.compaction.enabled,
"pi.compaction.thresholdRatio": n(s.pi.compaction.thresholdRatio),
}),
toPatch: (v) => ({
pi: {
contextBudget: num(v["pi.contextBudget"]),
subagentsEnabled: v["pi.subagentsEnabled"] === true,
compaction: {
enabled: v["pi.compaction.enabled"] === true,
thresholdRatio: num(v["pi.compaction.thresholdRatio"]),
},
},
}),
};
const BUDGET_CARD: CardSpec = {
anchor: "budget",
title: "预算",
description: "超出每日预算的运行停在「超预算」,等你放行。",
fields: [
{ name: "budget.agentMaxRunsPerDay", label: "每日运行次数", kind: "number" },
{ name: "budget.agentMaxCostUsdPerDay", label: "每日费用(USD)", kind: "number" },
{ name: "agent.maxToolCalls", label: "单次工具调用上限", kind: "number", hint: "1–500" },
{ name: "agent.maxDurationSec", label: "单次时长上限(秒)", kind: "number" },
{ name: "agent.maxTokensPerRun", label: "单次 token 上限", kind: "number", hint: "留空不限" },
{ name: "agent.maxCostUsdPerRun", label: "单次费用上限(USD)", kind: "number", hint: "留空不限" },
],
fromSettings: (s) => ({
"budget.agentMaxRunsPerDay": n(s.budget.agentMaxRunsPerDay),
"budget.agentMaxCostUsdPerDay": n(s.budget.agentMaxCostUsdPerDay),
"agent.maxToolCalls": n(s.agent.maxToolCalls),
"agent.maxDurationSec": n(s.agent.maxDurationSec),
"agent.maxTokensPerRun": n(s.agent.maxTokensPerRun),
"agent.maxCostUsdPerRun": n(s.agent.maxCostUsdPerRun),
}),
toPatch: (v) => ({
budget: {
agentMaxRunsPerDay: num(v["budget.agentMaxRunsPerDay"]),
agentMaxCostUsdPerDay: num(v["budget.agentMaxCostUsdPerDay"]),
},
agent: {
maxToolCalls: num(v["agent.maxToolCalls"]),
maxDurationSec: num(v["agent.maxDurationSec"]),
maxTokensPerRun: nullNum(v["agent.maxTokensPerRun"]),
maxCostUsdPerRun: nullNum(v["agent.maxCostUsdPerRun"]),
},
}),
};
const INVESTIGATE_CARD: CardSpec = {
anchor: "investigate",
title: "自动调查",
description: "待处理事项(识别有歧义、下载失败等)创建时,自动发起一次有界调查。",
fields: [
{ name: "agent.autoInvestigate", label: "自动调查", kind: "switch", wide: true },
{
name: "agent.autoInvestigateMode",
label: "模式",
kind: "select",
options: [
{ value: "investigate", label: "只读调查,结论交还给你" },
{ value: "recover", label: "授权内恢复(重试任务、重新校验、确认识别)" },
],
hint: "恢复只在已发布策略或持续授权覆盖时执行,换源、删除、清理始终排除;没有授权时按只读调查",
wide: true,
visible: (v) => v["agent.autoInvestigate"] === true,
},
{
name: "agent.investigateMaxToolCalls",
label: "工具调用上限",
kind: "number",
hint: "1–100",
visible: (v) => v["agent.autoInvestigate"] === true,
},
{
name: "agent.investigateMaxAttempts",
label: "同一根因最多恢复次数",
kind: "number",
hint: "1–10,用完后只交还给你",
visible: (v) => v["agent.autoInvestigate"] === true && v["agent.autoInvestigateMode"] === "recover",
},
{
name: "agent.recoverVerifySec",
label: "恢复后核实时长(秒)",
kind: "number",
hint: "等下载或任务状态变化的最长时间",
visible: (v) => v["agent.autoInvestigate"] === true && v["agent.autoInvestigateMode"] === "recover",
},
],
fromSettings: (s) => ({
"agent.autoInvestigate": s.agent.autoInvestigate,
"agent.autoInvestigateMode": s.agent.autoInvestigateMode,
"agent.investigateMaxToolCalls": n(s.agent.investigateMaxToolCalls),
"agent.investigateMaxAttempts": n(s.agent.investigateMaxAttempts),
"agent.recoverVerifySec": n(s.agent.recoverVerifySec),
}),
toPatch: (v) => ({
agent: {
autoInvestigate: v["agent.autoInvestigate"] === true,
autoInvestigateMode: v["agent.autoInvestigateMode"] === "recover" ? "recover" : "investigate",
investigateMaxToolCalls: num(v["agent.investigateMaxToolCalls"]),
investigateMaxAttempts: num(v["agent.investigateMaxAttempts"]),
recoverVerifySec: num(v["agent.recoverVerifySec"]),
},
}),
};
const ADVANCED_CARD: CardSpec = {
anchor: "advanced",
title: "高级",
fields: [
{ name: "agent.goalPollSec", label: "目标轮询间隔(秒)", kind: "number", hint: "等下载、入库结束时的检查间隔" },
{
name: "agent.goalWaitHours",
label: "目标最长等待(小时)",
kind: "number",
hint: "超时后按「仍在等待」汇报结束",
},
{
name: "agent.sessionDir",
label: "会话目录",
kind: "text",
wide: true,
placeholder: "留空用 DATA_DIR/agent-sessions",
hint: "绝对路径,api 与 worker 都要可读写;有运行中的会话时不要改",
},
{
name: "agent.fakeScript",
label: "fake 脚本(JSON)",
kind: "textarea",
hint: "只对 fake 上游生效,留空用内置演示脚本;格式见 docs/runbook/agent.md",
},
],
fromSettings: (s) => ({
"agent.goalPollSec": n(s.agent.goalPollSec),
"agent.goalWaitHours": n(s.agent.goalWaitHours),
"agent.sessionDir": s.agent.sessionDir ?? "",
"agent.fakeScript": s.agent.fakeScript ?? "",
}),
toPatch: (v) => ({
agent: {
goalPollSec: num(v["agent.goalPollSec"]),
goalWaitHours: num(v["agent.goalWaitHours"]),
sessionDir: str(v["agent.sessionDir"]) || null,
fakeScript: str(v["agent.fakeScript"]) || null,
},
}),
};
export function AgentSection() {
return (
<div className="grid gap-8">
<PiUpstreamsBlock />
<SettingsGate>
{(s) => (
<>
<SettingsCard section="agent" spec={RUNTIME_CARD} settings={s} />
<SettingsCard section="agent" spec={BUDGET_CARD} settings={s} />
<SettingsCard section="agent" spec={INVESTIGATE_CARD} settings={s} />
<SettingsCard section="agent" spec={ADVANCED_CARD} settings={s} />
</>
)}
</SettingsGate>
</div>
);
}
@@ -0,0 +1,260 @@
import { type ApiKey, ApiKeyCreate, type ApiScope } from "@umt/contracts";
import { Check, Copy, LoaderCircle, Plus } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { useApiKeys, useCreateApiKey, useRevokeApiKey } from "~/api/api-keys.ts";
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
import { DataTable } from "~/components/console/data-table.tsx";
import { FormField, zodFieldErrors } from "~/components/console/form-field.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { Button } from "~/components/ui/button.tsx";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog.tsx";
import { Input } from "~/components/ui/input.tsx";
import { errorMessage } from "~/lib/api.ts";
import { formatRelative } from "~/lib/labels.ts";
import { cn, formatDateTime } from "~/lib/utils.ts";
import { SettingsBlock } from "./settings-block.tsx";
// ─── API Key ───
const SCOPES: { value: ApiScope; label: string; hint: string }[] = [
{ value: "read", label: "read", hint: "只读查询全部数据" },
{ value: "organize.read", label: "organize.read", hint: "只读整理记录,供外部增量同步" },
{ value: "organize.execute", label: "organize.execute", hint: "发起整理" },
{ value: "admin", label: "admin", hint: "全部管理权限,含改设置" },
];
function CreatedKeyDialog({ plaintext, onClose }: { plaintext: string | null; onClose: () => void }) {
const [copied, setCopied] = useState(false);
return (
<Dialog open={!!plaintext} onOpenChange={(o) => !o && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>API Key 已创建</DialogTitle>
<DialogDescription>明文只显示这一次,关闭后无法再查看。</DialogDescription>
</DialogHeader>
<div className="flex min-w-0 items-center gap-2 rounded-lg border border-warning/40 bg-warning/10 p-3">
<code className="min-w-0 flex-1 font-mono text-caption break-all text-foreground">{plaintext}</code>
<Button
variant="outline"
size="icon-sm"
aria-label="复制"
onClick={async () => {
try {
await navigator.clipboard.writeText(plaintext ?? "");
setCopied(true);
toast.success("已复制");
} catch {
toast.error("复制失败,手动选中复制");
}
}}
>
{copied ? <Check /> : <Copy />}
</Button>
</div>
<DialogFooter>
<Button onClick={onClose}>我已保存</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export function ApiKeysSection() {
const keys = useApiKeys();
const create = useCreateApiKey();
const revoke = useRevokeApiKey();
const [open, setOpen] = useState(false);
const [name, setName] = useState("");
const [scopes, setScopes] = useState<ApiScope[]>(["read"]);
const [errors, setErrors] = useState<Record<string, string>>({});
const [plaintext, setPlaintext] = useState<string | null>(null);
const [revoking, setRevoking] = useState<ApiKey | null>(null);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
const parsed = ApiKeyCreate.safeParse({ name: name.trim(), scopes });
if (!parsed.success) {
const fe = zodFieldErrors(parsed.error.issues);
if (fe.name) fe.name = "名称必填,80 字以内";
if (fe.scopes) fe.scopes = "至少选一个权限范围";
setErrors(fe);
return;
}
setErrors({});
try {
const r = await create.mutateAsync(parsed.data);
setOpen(false);
setName("");
setScopes(["read"]);
setPlaintext(r.plaintextKey);
} catch (err) {
toast.error(errorMessage(err, "创建失败"));
}
};
return (
<SettingsBlock
section="api-keys"
anchor="keys"
title="API Key"
description="签发给外部程序(媒体服务器插件、脚本)的 Key,按权限范围授权。控制台走登录会话,不用 Key;浏览器本机 Token 在「账户与安全」。"
plain
actions={
<Button size="sm" onClick={() => setOpen(true)}>
<Plus />
创建 API Key
</Button>
}
>
<ScopeLegend />
<DataTable
rows={keys.data?.items}
rowKey={(r) => r.id}
loading={keys.isPending}
error={keys.error}
onRetry={() => keys.refetch()}
empty={{ title: "还没有 API Key" }}
columns={[
{
key: "name",
header: "名称",
cell: (r) => (
<div className="min-w-0">
<p className={cn("font-medium", r.revokedAt && "text-muted-foreground line-through")}>{r.name}</p>
<p className="text-meta text-muted-foreground">创建于 {formatDateTime(r.createdAt)}</p>
</div>
),
},
{
key: "scopes",
header: "权限",
hideBelow: "sm",
cell: (r) => (
<div className="flex flex-wrap gap-1">
{r.scopes.map((s) => (
<code key={s} className="rounded-tight bg-foreground/[0.06] px-1.5 py-0.5 text-meta">
{s}
</code>
))}
</div>
),
},
{ key: "used", header: "最近使用", hideBelow: "md", cell: (r) => formatRelative(r.lastUsedAt) },
{
key: "status",
header: "状态",
cell: (r) => (
<StatusBadge tone={r.revokedAt ? "neutral" : "success"}>{r.revokedAt ? "已撤销" : "有效"}</StatusBadge>
),
},
{
key: "act",
header: <span className="sr-only">操作</span>,
align: "right",
cell: (r) =>
r.revokedAt ? null : (
<Button variant="ghost" size="sm" className="hover:text-destructive" onClick={() => setRevoking(r)}>
撤销
</Button>
),
},
]}
/>
<Dialog open={open} onOpenChange={(o) => !create.isPending && setOpen(o)}>
<DialogContent>
<DialogHeader>
<DialogTitle>创建 API Key</DialogTitle>
<DialogDescription>创建后明文只显示一次。</DialogDescription>
</DialogHeader>
<form id="apikey-form" onSubmit={submit} noValidate className="grid gap-1">
<FormField label="名称" htmlFor="ak-name" error={errors.name} required>
<Input
id="ak-name"
value={name}
onChange={(e) => setName(e.target.value)}
aria-invalid={!!errors.name}
placeholder="emby-plugin"
/>
</FormField>
<FormField label="权限范围" error={errors.scopes}>
<div className="grid gap-1.5">
{SCOPES.map((s) => {
const on = scopes.includes(s.value);
return (
<label
key={s.value}
className="flex cursor-pointer items-center gap-2.5 rounded-lg px-1 py-1 text-sm hover:bg-foreground/[0.04]"
>
<input
type="checkbox"
className="h-4 w-4 accent-current"
checked={on}
onChange={() => setScopes((xs) => (on ? xs.filter((x) => x !== s.value) : [...xs, s.value]))}
/>
<code className="text-caption">{s.label}</code>
<span className="text-meta text-muted-foreground">{s.hint}</span>
</label>
);
})}
</div>
</FormField>
</form>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)} disabled={create.isPending}>
取消
</Button>
<Button type="submit" form="apikey-form" disabled={create.isPending}>
{create.isPending ? <LoaderCircle className="animate-spin" /> : null}
创建
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<CreatedKeyDialog plaintext={plaintext} onClose={() => setPlaintext(null)} />
<ConfirmDialog
open={!!revoking}
onOpenChange={(o) => !o && setRevoking(null)}
title={`撤销「${revoking?.name ?? ""}」?`}
description="使用这个 Key 的程序立即失去访问权限,无法恢复。"
confirmLabel="撤销"
destructive
onConfirm={async () => {
if (!revoking) return;
try {
await revoke.mutateAsync(revoking.id);
toast.success("已撤销");
} catch (err) {
toast.error(errorMessage(err, "撤销失败"));
throw err;
}
}}
/>
</SettingsBlock>
);
}
function ScopeLegend() {
return (
<dl className="mb-3 grid gap-x-4 gap-y-1 text-meta sm:grid-cols-[auto_1fr]">
{SCOPES.map((s) => (
<div key={s.value} className="contents">
<dt>
<code className="rounded-tight bg-foreground/[0.06] px-1.5 py-0.5">{s.label}</code>
</dt>
<dd className="text-muted-foreground">{s.hint}</dd>
</div>
))}
</dl>
);
}
@@ -0,0 +1,189 @@
import { Pause, Play, ShieldAlert } from "lucide-react";
import type { ReactNode } from "react";
import { toast } from "sonner";
import { useUpdateSettings } from "~/api/settings.ts";
import { Switch } from "~/components/ui/switch.tsx";
import { errorMessage } from "~/lib/api.ts";
import { cn } from "~/lib/utils.ts";
import { num } from "./resource-section.tsx";
import { SettingsBlock } from "./settings-block.tsx";
import { type CardSpec, n, SettingsCard, SettingsGate } from "./settings-card.tsx";
// 自动化分区:总开关、只读对账、缓存水位、每日下载预算、保留天数
const GiB = 1024 ** 3;
const CACHE_CARD: CardSpec = {
anchor: "cache",
title: "缓存水位",
description: "缓存卷使用率到硬阈值时停止新增下载并暂停纳管任务;降到恢复水位以下只恢复因阈值暂停的任务。",
fields: [
{ name: "cache.hardThresholdPercent", label: "硬阈值(%)", kind: "number" },
{ name: "cache.resumePercent", label: "恢复水位(%)", kind: "number", hint: "低于硬阈值" },
{ name: "cache.cleanupTargetPercent", label: "清理目标(%)", kind: "number", hint: "不高于恢复水位" },
{ name: "cache.checkIntervalSec", label: "检查间隔(秒)", kind: "number", hint: "10–3600" },
],
fromSettings: (s) => ({
"cache.hardThresholdPercent": n(s.cache.hardThresholdPercent),
"cache.resumePercent": n(s.cache.resumePercent),
"cache.cleanupTargetPercent": n(s.cache.cleanupTargetPercent),
"cache.checkIntervalSec": n(s.cache.checkIntervalSec),
}),
toPatch: (v) => ({
cache: {
hardThresholdPercent: num(v["cache.hardThresholdPercent"]),
resumePercent: num(v["cache.resumePercent"]),
cleanupTargetPercent: num(v["cache.cleanupTargetPercent"]),
checkIntervalSec: num(v["cache.checkIntervalSec"]),
},
}),
};
const BUDGET_CARD: CardSpec = {
anchor: "budget",
title: "每日预算",
description: "任一项到上限后不再建新的下载计划;前两项按自然日计。",
fields: [
{ name: "budget.maxNewTitlesPerDay", label: "每日新作品数", kind: "number" },
{ name: "budget.maxDownloadGiBPerDay", label: "每日下载量(GiB)", kind: "number" },
{ name: "budget.maxConcurrentDownloads", label: "最大并发下载", kind: "number", hint: "1–100" },
],
fromSettings: (s) => ({
"budget.maxNewTitlesPerDay": n(s.budget.maxNewTitlesPerDay),
"budget.maxDownloadGiBPerDay": n(Math.round((s.budget.maxDownloadBytesPerDay / GiB) * 100) / 100),
"budget.maxConcurrentDownloads": n(s.budget.maxConcurrentDownloads),
}),
toPatch: (v) => {
const gib = num(v["budget.maxDownloadGiBPerDay"]);
return {
budget: {
maxNewTitlesPerDay: num(v["budget.maxNewTitlesPerDay"]),
maxDownloadBytesPerDay: gib === undefined ? undefined : Math.round(gib * GiB),
maxConcurrentDownloads: num(v["budget.maxConcurrentDownloads"]),
},
};
},
};
const RETENTION_CARD: CardSpec = {
anchor: "retention",
title: "保留",
fields: [
{
name: "retention.seedRetentionDays",
label: "源文件保留天数",
kind: "number",
hint: "复制入库后下载源在缓存里至少留这么久;0–365",
},
{ name: "retention.logRetentionDays", label: "日志保留天数", kind: "number", hint: "观察与日志类数据" },
],
fromSettings: (s) => ({
"retention.seedRetentionDays": n(s.retention.seedRetentionDays),
"retention.logRetentionDays": n(s.retention.logRetentionDays),
}),
toPatch: (v) => ({
retention: {
seedRetentionDays: num(v["retention.seedRetentionDays"]),
logRetentionDays: num(v["retention.logRetentionDays"]),
},
}),
};
function ToggleRow({
id,
icon,
title,
description,
checked,
onChange,
disabled,
tone,
}: {
id: string;
icon: ReactNode;
title: string;
description: string;
checked: boolean;
onChange: (v: boolean) => void;
disabled: boolean;
tone: "success" | "warning";
}) {
return (
<div className="flex items-start gap-4 py-4 first:pt-0 last:pb-0">
<span
className={cn(
"grid h-9 w-9 shrink-0 place-items-center rounded-full",
tone === "success" ? "bg-success/10 text-success" : "bg-warning/10 text-warning",
)}
>
{icon}
</span>
<span className="min-w-0 flex-1">
<label htmlFor={id} className="block cursor-pointer text-sm font-medium text-foreground">
{title}
</label>
<span className="block text-caption text-muted-foreground">{description}</span>
</span>
<Switch id={id} checked={checked} onCheckedChange={onChange} disabled={disabled} aria-label={title} />
</div>
);
}
function Switches() {
const update = useUpdateSettings();
const set = async (patch: { enabled?: boolean; readOnlyReconcile?: boolean }, msg: string) => {
try {
await update.mutateAsync({ automation: patch });
toast.success(msg);
} catch (err) {
toast.error(errorMessage(err, "切换失败"));
}
};
return (
<SettingsGate>
{(s) => (
<SettingsBlock section="automation" anchor="switches" title="开关" description="切换后立即生效。">
<div className="divide-y divide-border/60">
<ToggleRow
id="tg-automation"
icon={s.automation.enabled ? <Play className="h-4 w-4" /> : <Pause className="h-4 w-4" />}
tone={s.automation.enabled ? "success" : "warning"}
title={`自动化总开关 · ${s.automation.enabled ? "运行中" : "已暂停"}`}
description="关闭后自动收录、下载、整理全部停止;查询与手动操作不受影响。"
checked={s.automation.enabled}
disabled={update.isPending}
onChange={(v) => set({ enabled: v }, v ? "自动化已恢复" : "自动化已暂停")}
/>
<ToggleRow
id="tg-readonly"
icon={<ShieldAlert className="h-4 w-4" />}
tone={s.automation.readOnlyReconcile ? "warning" : "success"}
title={`只读对账 · ${s.automation.readOnlyReconcile ? "开启" : "关闭"}`}
description="从备份恢复后先开启:只核对库存与路径,不做自动写入。核对完再关闭。"
checked={s.automation.readOnlyReconcile}
disabled={update.isPending}
onChange={(v) => set({ readOnlyReconcile: v }, v ? "已进入只读对账" : "已退出只读对账")}
/>
</div>
</SettingsBlock>
)}
</SettingsGate>
);
}
export function AutomationSection() {
return (
<div className="grid gap-8">
<Switches />
<SettingsGate>
{(s) => (
<>
<SettingsCard section="automation" spec={CACHE_CARD} settings={s} />
<SettingsCard section="automation" spec={BUDGET_CARD} settings={s} />
<SettingsCard section="automation" spec={RETENTION_CARD} settings={s} />
</>
)}
</SettingsGate>
</div>
);
}
@@ -0,0 +1,342 @@
import {
type DownloaderInstance,
DownloaderInstanceCreate,
DownloaderInstanceUpdate,
type LibraryRoot,
LibraryRootCreate,
LibraryRootUpdate,
type MediaServerInstance,
MediaServerInstanceCreate,
MediaServerInstanceUpdate,
} from "@umt/contracts";
import { PlugZap } from "lucide-react";
import { toast } from "sonner";
import { downloaders, libraryRoots, mediaServers } from "~/api/instances.ts";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { Button } from "~/components/ui/button.tsx";
import { errorMessage } from "~/lib/api.ts";
import { formatRelative, healthLabel } from "~/lib/labels.ts";
import { PathMappingsPanel } from "./path-mappings.tsx";
import { type FormValues, nullStr, optStr, ResourceSection, str } from "./resource-section.tsx";
import { SettingsBlock } from "./settings-block.tsx";
export function Health({ status, at }: { status: string; at?: string | null }) {
const h = healthLabel(status);
return (
<div className="flex flex-col items-start gap-0.5">
<StatusBadge tone={h.tone}>{h.label}</StatusBadge>
{at ? <span className="text-meta text-muted-foreground">{formatRelative(at)}</span> : null}
</div>
);
}
function Enabled({ on }: { on: boolean }) {
return <StatusBadge tone={on ? "success" : "neutral"}>{on ? "启用" : "停用"}</StatusBadge>;
}
/** 行内"测试连接"按钮:结果 toast,health 写回后列表自动刷新 */
export function TestButton({
run,
pending,
label = "测试连接",
}: {
run: () => Promise<{ ok: boolean; message: string | null; version?: string | null }>;
pending: boolean;
label?: string;
}) {
return (
<Button
variant="ghost"
size="sm"
disabled={pending}
onClick={async () => {
try {
const r = await run();
if (r.ok) toast.success(`${label}成功${r.version ? ` · ${r.version}` : ""}`);
else toast.error(r.message || `${label}失败`);
} catch (err) {
toast.error(errorMessage(err, `${label}失败`));
}
}}
>
<PlugZap />
<span className="hidden md:inline">{label}</span>
</Button>
);
}
const credentialHint = { editHint: "留空不修改" };
export function DownloadersSection() {
return (
<SettingsBlock section="downloaders" anchor="instances" title="qBittorrent 实例" plain>
<DownloadersTable />
</SettingsBlock>
);
}
function DownloadersTable() {
const list = downloaders.useList();
const create = downloaders.useCreate();
const update = downloaders.useUpdate();
const remove = downloaders.useRemove();
const test = downloaders.useTest();
return (
<ResourceSection<DownloaderInstance>
hooks={{ list, create, update, remove } as never}
config={{
noun: "下载器实例",
description:
"只接管带管理标签的任务。多个实例共用一块缓存盘时填相同的缓存卷标识,水位合并计算。点行编辑实例和它的路径映射。",
emptyHint: "添加 qBittorrent 后才能提交下载。",
drawerExtra: (r) => <PathMappingsPanel viewKind="downloader" instanceId={r.id} />,
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">{r.baseUrl}</p>
</div>
),
},
{
key: "tag",
header: "管理标签",
hideBelow: "md",
cell: (r) => <code className="text-meta">{r.managedTag}</code>,
},
{ key: "vol", header: "缓存卷", hideBelow: "lg", cell: (r) => r.cacheVolumeKey ?? "—" },
{ key: "enabled", header: "状态", hideBelow: "sm", cell: (r) => <Enabled on={r.enabled} /> },
{ key: "health", header: "健康", cell: (r) => <Health status={r.healthStatus} at={r.lastSeenAt} /> },
],
rowActions: (r) => <TestButton pending={test.isPending} run={() => test.mutateAsync(r.id)} />,
fields: [
{ name: "name", label: "名称", kind: "text", required: true, placeholder: "例如:家里的 qB" },
{
name: "baseUrl",
label: "WebUI 地址",
kind: "url",
required: true,
placeholder: "http://192.168.1.10:8080",
},
{ name: "username", label: "用户名", kind: "text", hint: "留空时凭证按 WebUI API Key 使用" },
{ name: "credential", label: "密码 / API Key", kind: "password", ...credentialHint },
{ name: "managedTag", label: "管理标签", kind: "text", placeholder: "umt", hint: "只接管带此标签的任务" },
{ name: "cacheVolumeKey", label: "缓存卷标识", kind: "text", placeholder: "cache-1" },
{ name: "enabled", label: "启用", kind: "switch" },
],
initial: {
name: "",
baseUrl: "",
username: "",
credential: "",
managedTag: "umt",
cacheVolumeKey: "",
enabled: true,
},
fromRow: (r) => ({
name: r.name,
baseUrl: r.baseUrl,
username: r.username ?? "",
credential: "",
managedTag: r.managedTag,
cacheVolumeKey: r.cacheVolumeKey ?? "",
enabled: r.enabled,
}),
toBody: (v: FormValues, mode) => ({
name: str(v.name),
kind: mode === "create" ? "qbittorrent" : undefined,
baseUrl: str(v.baseUrl),
username: nullStr(v.username),
credential: optStr(v.credential),
managedTag: optStr(v.managedTag),
cacheVolumeKey: nullStr(v.cacheVolumeKey),
enabled: v.enabled === true,
}),
createSchema: DownloaderInstanceCreate,
updateSchema: DownloaderInstanceUpdate,
}}
/>
);
}
export function MediaServersSection() {
return (
<SettingsBlock section="media-servers" anchor="instances" title="Emby / Jellyfin 实例" plain>
<MediaServersTable />
</SettingsBlock>
);
}
function MediaServersTable() {
const list = mediaServers.useList();
const create = mediaServers.useCreate();
const update = mediaServers.useUpdate();
const remove = mediaServers.useRemove();
const test = mediaServers.useTest();
return (
<ResourceSection<MediaServerInstance>
hooks={{ list, create, update, remove } as never}
config={{
noun: "媒体服务器",
description: "只用来查询作品有无、季集和版本;查不到不等于缺失。点行编辑实例和它的路径映射。",
drawerExtra: (r) => <PathMappingsPanel viewKind="media_server" instanceId={r.id} />,
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">{r.baseUrl}</p>
</div>
),
},
{ key: "kind", header: "类型", hideBelow: "md", cell: (r) => (r.kind === "emby" ? "Emby" : "Jellyfin") },
{ key: "enabled", header: "状态", hideBelow: "sm", cell: (r) => <Enabled on={r.enabled} /> },
{ key: "health", header: "健康", cell: (r) => <Health status={r.healthStatus} at={r.lastSeenAt} /> },
],
rowActions: (r) => <TestButton pending={test.isPending} run={() => test.mutateAsync(r.id)} />,
fields: [
{ name: "name", label: "名称", kind: "text", required: true },
{
name: "kind",
label: "类型",
kind: "select",
options: [
{ value: "emby", label: "Emby" },
{ value: "jellyfin", label: "Jellyfin" },
],
},
{ name: "baseUrl", label: "地址", kind: "url", required: true, placeholder: "http://192.168.1.10:8096" },
{ name: "userId", label: "用户 ID", kind: "text", hint: "可选,按该用户的可见范围查询" },
{ name: "credential", label: "API Key", kind: "password", ...credentialHint },
{ name: "enabled", label: "启用", kind: "switch" },
],
initial: { name: "", kind: "emby", baseUrl: "", userId: "", credential: "", enabled: true },
fromRow: (r) => ({
name: r.name,
kind: r.kind,
baseUrl: r.baseUrl,
userId: r.userId ?? "",
credential: "",
enabled: r.enabled,
}),
toBody: (v) => ({
name: str(v.name),
kind: str(v.kind),
baseUrl: str(v.baseUrl),
userId: nullStr(v.userId),
credential: optStr(v.credential),
enabled: v.enabled === true,
}),
createSchema: MediaServerInstanceCreate,
updateSchema: MediaServerInstanceUpdate,
}}
/>
);
}
const MEDIA_KIND = [
{ value: "movie", label: "电影" },
{ value: "series", label: "剧集" },
{ value: "variety", label: "综艺" },
{ value: "mixed", label: "混合" },
{ value: "other", label: "其它" },
];
export function LibraryRootsTable() {
const list = libraryRoots.useList();
const create = libraryRoots.useCreate();
const update = libraryRoots.useUpdate();
const remove = libraryRoots.useRemove();
const test = libraryRoots.useTest();
return (
<ResourceSection<LibraryRoot>
hooks={{ list, create, update, remove } as never}
config={{
noun: "库根",
description: "登记或改路径时会在目录里写哨兵文件 .uhd-root,据此判断库根是否在线;目录不存在或不可写时无法保存。",
deleteHint: "只删除登记,磁盘上的文件不动。",
columns: [
{
key: "name",
header: "名称",
cell: (r) => (
<div className="min-w-0">
<p className="font-medium">{r.name}</p>
<p className="truncate font-mono text-meta text-muted-foreground">{r.localPath}</p>
</div>
),
},
{
key: "kind",
header: "类型",
hideBelow: "md",
cell: (r) => MEDIA_KIND.find((k) => k.value === r.mediaKind)?.label ?? r.mediaKind,
},
{
key: "mode",
header: "管理方式",
hideBelow: "lg",
cell: (r) => (r.managementMode === "managed" ? "自动维护" : "只登记"),
},
{ key: "health", header: "在线", cell: (r) => <Health status={r.healthStatus} at={r.lastHealthAt} /> },
],
rowActions: (r) => <TestButton label="检查哨兵" pending={test.isPending} run={() => test.mutateAsync(r.id)} />,
fields: [
{ name: "name", label: "名称", kind: "text", required: true, placeholder: "例如:电影库" },
{ name: "mediaKind", label: "内容类型", kind: "select", options: MEDIA_KIND },
{
name: "localPath",
label: "本地路径",
kind: "text",
required: true,
placeholder: "/media/movies",
hint: "服务进程能访问的绝对路径",
wide: true,
},
{
name: "managementMode",
label: "管理方式",
kind: "select",
options: [
{ value: "register_only", label: "只登记" },
{ value: "managed", label: "自动维护(补齐、升级)" },
],
},
{ name: "enabled", label: "启用", kind: "switch" },
{ name: "notes", label: "备注", kind: "textarea" },
],
initial: {
name: "",
mediaKind: "movie",
localPath: "",
managementMode: "register_only",
enabled: true,
notes: "",
},
fromRow: (r) => ({
name: r.name,
mediaKind: r.mediaKind,
localPath: r.localPath,
managementMode: r.managementMode,
enabled: r.enabled,
notes: r.notes ?? "",
}),
toBody: (v) => ({
name: str(v.name),
mediaKind: str(v.mediaKind),
localPath: str(v.localPath),
managementMode: str(v.managementMode),
enabled: v.enabled === true,
notes: nullStr(v.notes),
}),
createSchema: LibraryRootCreate,
updateSchema: LibraryRootUpdate,
}}
/>
);
}
@@ -0,0 +1,50 @@
import { LibraryRootsTable } from "./instances.tsx";
import { str } from "./resource-section.tsx";
import { SettingsBlock } from "./settings-block.tsx";
import { type CardSpec, SettingsCard, SettingsGate } from "./settings-card.tsx";
// 媒体库与目录:库根(含哨兵状态)+ 默认整理方式
const ORGANIZE_CARD: CardSpec = {
anchor: "organize",
title: "整理方式",
description: "复制入库时源文件留在缓存继续做种,保留天数在自动化分区设置。",
fields: [
{
name: "organize.defaultMode",
label: "默认整理方式",
kind: "select",
options: [
{ value: "copy", label: "复制(源文件继续做种)" },
{ value: "move", label: "移动(移走源文件)" },
],
},
{
name: "organize.allowCrossVolumeMove",
label: "允许跨卷移动",
kind: "switch",
hint: "跨卷时按复制 → 核验 → 删源执行",
},
],
fromSettings: (s) => ({
"organize.defaultMode": s.organize.defaultMode,
"organize.allowCrossVolumeMove": s.organize.allowCrossVolumeMove,
}),
toPatch: (v) => ({
organize: {
defaultMode: str(v["organize.defaultMode"]) as "copy" | "move",
allowCrossVolumeMove: v["organize.allowCrossVolumeMove"] === true,
},
}),
};
export function LibrarySection() {
return (
<div className="grid gap-8">
<SettingsBlock section="library" anchor="roots" title="库根" plain>
<LibraryRootsTable />
</SettingsBlock>
<SettingsGate>{(s) => <SettingsCard section="library" spec={ORGANIZE_CARD} settings={s} />}</SettingsGate>
</div>
);
}
@@ -0,0 +1,72 @@
import { toast } from "sonner";
import { useUpdateSettings } from "~/api/settings.ts";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { Button } from "~/components/ui/button.tsx";
import { errorMessage } from "~/lib/api.ts";
import { str } from "./resource-section.tsx";
import { type CardSpec, SettingsCard, SettingsGate } from "./settings-card.tsx";
// 元数据:TMDB API Key(只写,存 secret)与 Base URL
const TMDB_CARD: CardSpec = {
anchor: "tmdb",
title: "TMDB",
description: "这里没填时使用环境变量 TMDB_API_KEY / TMDB_ACCESS_TOKEN / TMDB_BASE_URL。",
fields: [
{
name: "metadata.tmdbApiKey",
label: "API Key",
kind: "password",
hint: "v3 API Key 或 v4 Read Access Token;留空不修改",
wide: true,
},
{
name: "metadata.tmdbBaseUrl",
label: "Base URL",
kind: "url",
placeholder: "https://api.themoviedb.org",
hint: "自建反代时填写;留空用官方地址",
wide: true,
},
],
fromSettings: (s) => ({ "metadata.tmdbApiKey": "", "metadata.tmdbBaseUrl": s.metadata.tmdbBaseUrl ?? "" }),
toPatch: (v) => ({
metadata: {
tmdbBaseUrl: str(v["metadata.tmdbBaseUrl"]) || null,
...(str(v["metadata.tmdbApiKey"]) ? { tmdbApiKey: str(v["metadata.tmdbApiKey"]) } : {}),
},
}),
extra: (s) => <TmdbKeyStatus saved={!!s.metadata.tmdbApiKeySecretRef} />,
};
function TmdbKeyStatus({ saved }: { saved: boolean }) {
const update = useUpdateSettings();
return (
<div className="mb-2 flex flex-wrap items-center gap-2">
<StatusBadge tone={saved ? "success" : "neutral"}>{saved ? "API Key 已保存" : "未保存 API Key"}</StatusBadge>
{saved ? (
<Button
type="button"
variant="ghost"
size="xs"
className="hover:text-destructive"
disabled={update.isPending}
onClick={async () => {
try {
await update.mutateAsync({ metadata: { tmdbApiKey: null } });
toast.success("TMDB API Key 已清除");
} catch (err) {
toast.error(errorMessage(err, "清除失败"));
}
}}
>
清除
</Button>
) : null}
</div>
);
}
export function MetadataSection() {
return <SettingsGate>{(s) => <SettingsCard section="metadata" spec={TMDB_CARD} settings={s} />}</SettingsGate>;
}
@@ -0,0 +1,229 @@
import { type PathMapping, PathMappingCreate } from "@umt/contracts";
import { LoaderCircle, Plus, ShieldCheck, Trash2 } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { libraryRoots, pathMappings } from "~/api/instances.ts";
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
import { DetailSection } from "~/components/console/detail-sheet.tsx";
import { zodFieldErrors } from "~/components/console/form-field.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { Button } from "~/components/ui/button.tsx";
import { errorMessage } from "~/lib/api.ts";
import { healthLabel } from "~/lib/labels.ts";
import { FieldControl, type FormValues, num, str } from "./resource-section.tsx";
// 实例抽屉里的路径映射:只列本实例的映射,可新增、校验、删除。
// 对方看到的路径前缀 → 本进程可访问的前缀;前缀越长越优先,同长按优先级。
const EMPTY: FormValues = { remotePrefix: "", localPrefix: "", libraryRootId: "none", priority: "0" };
export function PathMappingsPanel({
viewKind,
instanceId,
}: {
viewKind: "downloader" | "media_server";
instanceId: string;
}) {
const list = pathMappings.useList();
const create = pathMappings.useCreate();
const remove = pathMappings.useRemove();
const verify = pathMappings.useVerify();
const roots = libraryRoots.useList();
const [adding, setAdding] = useState(false);
const [v, setV] = useState<FormValues>(EMPTY);
const [errors, setErrors] = useState<Record<string, string>>({});
const [deleting, setDeleting] = useState<PathMapping | null>(null);
const rows = (list.data?.items ?? []).filter((m) =>
viewKind === "downloader" ? m.downloaderInstanceId === instanceId : m.mediaServerInstanceId === instanceId,
);
const rootOpts = [
{ value: "none", label: "不关联" },
...(roots.data?.items ?? []).map((d) => ({ value: d.id, label: d.name })),
];
const rootName = (id: string | null) => roots.data?.items.find((r) => r.id === id)?.name;
const submit = async (e: React.FormEvent) => {
e.preventDefault();
const root = str(v.libraryRootId);
const parsed = PathMappingCreate.safeParse({
viewKind,
downloaderInstanceId: viewKind === "downloader" ? instanceId : null,
mediaServerInstanceId: viewKind === "media_server" ? instanceId : null,
remotePrefix: str(v.remotePrefix),
localPrefix: str(v.localPrefix),
libraryRootId: root && root !== "none" ? root : null,
priority: num(v.priority) ?? 0,
enabled: true,
});
if (!parsed.success) {
const fe = zodFieldErrors(parsed.error.issues);
if (fe.localPrefix) fe.localPrefix = "填以 / 开头的绝对路径";
if (fe.remotePrefix) fe.remotePrefix = "必填";
setErrors(fe);
return;
}
setErrors({});
try {
await create.mutateAsync(parsed.data as never);
toast.success("路径映射已添加");
setV(EMPTY);
setAdding(false);
} catch (err) {
toast.error(errorMessage(err, "添加失败"));
}
};
return (
<DetailSection
title="路径映射"
actions={
adding ? null : (
<Button variant="outline" size="xs" onClick={() => setAdding(true)}>
<Plus />
添加映射
</Button>
)
}
>
<p className="mb-2 text-meta text-muted-foreground">
把{viewKind === "downloader" ? "下载器" : "媒体服务器"}
看到的路径换成本服务能访问的路径。前缀越长越优先,同长比优先级。
</p>
{list.isPending ? (
<p className="text-caption text-muted-foreground">加载中…</p>
) : rows.length === 0 ? (
<p className="text-caption text-muted-foreground">还没有映射,路径两边一致时不用加。</p>
) : (
<ul className="divide-y divide-border/60 rounded-lg border border-border/60">
{rows.map((m) => {
const h = healthLabel(m.verifyStatus);
return (
<li key={m.id} className="flex items-center gap-2 px-3 py-2">
<div className="min-w-0 flex-1 font-mono text-meta">
<p className="truncate">{m.remotePrefix}</p>
<p className="truncate text-muted-foreground">→ {m.localPrefix}</p>
{rootName(m.libraryRootId) ? (
<p className="font-sans text-muted-foreground">库根:{rootName(m.libraryRootId)}</p>
) : null}
</div>
<StatusBadge tone={h.tone}>{h.label}</StatusBadge>
<Button
variant="ghost"
size="icon-sm"
aria-label="校验映射"
title="校验"
disabled={verify.isPending}
onClick={async () => {
try {
const r = await verify.mutateAsync(m.id);
if (r.verifyStatus === "ok") toast.success("映射可用");
else toast.error(String(r.verifyDetail?.message ?? "本地前缀不可访问"));
} catch (err) {
toast.error(errorMessage(err, "校验失败"));
}
}}
>
<ShieldCheck />
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label="删除映射"
className="hover:text-destructive"
onClick={() => setDeleting(m)}
>
<Trash2 />
</Button>
</li>
);
})}
</ul>
)}
{adding ? (
<form
onSubmit={submit}
noValidate
className="mt-3 grid gap-x-3 gap-y-1 rounded-lg border border-border/60 p-3 sm:grid-cols-2"
>
<FieldControl
spec={{
name: "remotePrefix",
label: "对方路径前缀",
kind: "text",
required: true,
placeholder: "/downloads",
wide: true,
}}
value={v.remotePrefix}
error={errors.remotePrefix}
onChange={(x) => setV((s) => ({ ...s, remotePrefix: x }))}
disabled={create.isPending}
/>
<FieldControl
spec={{
name: "localPrefix",
label: "本地路径前缀",
kind: "text",
required: true,
placeholder: "/mnt/cache/downloads",
wide: true,
}}
value={v.localPrefix}
error={errors.localPrefix}
onChange={(x) => setV((s) => ({ ...s, localPrefix: x }))}
disabled={create.isPending}
/>
<FieldControl
spec={{ name: "libraryRootId", label: "关联库根", kind: "select", options: rootOpts }}
value={v.libraryRootId}
onChange={(x) => setV((s) => ({ ...s, libraryRootId: x }))}
disabled={create.isPending}
/>
<FieldControl
spec={{ name: "priority", label: "优先级", kind: "number", hint: "数字大的优先" }}
value={v.priority}
error={errors.priority}
onChange={(x) => setV((s) => ({ ...s, priority: x }))}
disabled={create.isPending}
/>
<div className="flex gap-2 sm:col-span-2">
<Button type="submit" size="sm" disabled={create.isPending}>
{create.isPending ? <LoaderCircle className="animate-spin" /> : null}
添加
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
setAdding(false);
setErrors({});
}}
>
取消
</Button>
</div>
</form>
) : null}
<ConfirmDialog
open={!!deleting}
onOpenChange={(o) => !o && setDeleting(null)}
title="删除路径映射?"
description="依赖这条映射的路径换算会失效,磁盘文件不动。"
confirmLabel="删除"
destructive
onConfirm={async () => {
if (!deleting) return;
try {
await remove.mutateAsync(deleting.id);
toast.success("路径映射已删除");
} catch (err) {
toast.error(errorMessage(err, "删除失败"));
throw err;
}
}}
/>
</DetailSection>
);
}
@@ -0,0 +1,307 @@
import type { PiOAuthMode, PiOAuthStartResult, PiOAuthState, PiProviderInfo, PiUpstream } from "@umt/contracts";
import { Check, Copy, ExternalLink, LoaderCircle } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { completePiOAuth, useInvalidatePi, useStartPiOAuth } from "~/api/pi.ts";
import { FormField } from "~/components/console/form-field.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Input } from "~/components/ui/input.tsx";
import { errorMessage } from "~/lib/api.ts";
import { cn } from "~/lib/utils.ts";
// 订阅登录:start → browser 粘回地址栏 URL / device 前端轮询 complete → 终态。
// 登录会话只在服务端内存里,15 分钟过期;失败、过期、被取消都可以重新开始。
const MODE_LABEL: Record<PiOAuthMode, string> = { device: "验证码", browser: "浏览器授权" };
const END_LABEL: Record<
Exclude<PiOAuthState, "pending">,
{ label: string; tone: "success" | "destructive" | "warning" }
> = {
succeeded: { label: "已登录", tone: "success" },
failed: { label: "登录失败", tone: "destructive" },
expired: { label: "登录会话已过期", tone: "warning" },
cancelled: { label: "登录已取消", tone: "warning" },
};
function CopyButton({ text, label }: { text: string; label: string }) {
const [done, setDone] = useState(false);
return (
<Button
type="button"
variant="outline"
size="icon-sm"
aria-label={`复制${label}`}
onClick={async () => {
try {
await navigator.clipboard.writeText(text);
setDone(true);
toast.success("已复制");
} catch {
toast.error("复制失败,手动选中复制");
}
}}
>
{done ? <Check /> : <Copy />}
</Button>
);
}
function useCountdown(expiresAt: string | null) {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
if (!expiresAt) return;
const t = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(t);
}, [expiresAt]);
if (!expiresAt) return null;
const left = Math.max(0, Math.floor((new Date(expiresAt).getTime() - now) / 1000));
return left;
}
export function PiOAuthLogin({
upstream,
provider,
onSucceeded,
}: {
upstream: PiUpstream;
provider: PiProviderInfo | undefined;
onSucceeded?: () => void;
}) {
const start = useStartPiOAuth();
const invalidate = useInvalidatePi();
const modes = provider?.oauth?.modes ?? ["browser"];
const [mode, setMode] = useState<PiOAuthMode>(modes[0] ?? "browser");
const [session, setSession] = useState<PiOAuthStartResult | null>(null);
const [state, setState] = useState<PiOAuthState | null>(null);
const [message, setMessage] = useState<string | null>(null);
const [progress, setProgress] = useState<string[]>([]);
const [pasted, setPasted] = useState("");
const [submitting, setSubmitting] = useState(false);
const abortRef = useRef<AbortController | null>(null);
const left = useCountdown(state === "pending" ? (session?.expiresAt ?? null) : null);
const termsNote = provider?.oauth?.termsNote ?? provider?.termsNote ?? null;
useEffect(() => () => abortRef.current?.abort(), []);
const finish = (s: PiOAuthState, msg: string | null) => {
setState(s);
setMessage(msg);
invalidate();
if (s === "succeeded") {
toast.success(`${upstream.name}已登录`);
onSucceeded?.();
}
};
// device:反复调 complete(每次服务端最多等 25 秒)直到终态或本地判定过期
const poll = async (s: PiOAuthStartResult) => {
abortRef.current?.abort();
const ac = new AbortController();
abortRef.current = ac;
while (!ac.signal.aborted) {
if (Date.now() > new Date(s.expiresAt).getTime()) {
finish("expired", "超过有效期仍未完成授权。");
return;
}
try {
const r = await completePiOAuth(upstream.id, { sessionId: s.sessionId, waitMs: 25_000 }, ac.signal);
setProgress(r.progress);
if (r.state !== "pending") {
finish(r.state, r.message);
return;
}
} catch (err) {
if (ac.signal.aborted) return;
finish("failed", errorMessage(err, "轮询登录状态失败"));
return;
}
}
};
const begin = async () => {
abortRef.current?.abort();
setMessage(null);
setProgress([]);
setPasted("");
try {
const r = await start.mutateAsync({ id: upstream.id, body: { mode } });
setSession(r);
setState(r.state);
setMessage(r.message);
if (r.state === "pending" && r.mode === "device") void poll(r);
} catch (err) {
setSession(null);
setState(null);
toast.error(errorMessage(err, "无法开始登录"));
}
};
const submitPasted = async (e: React.FormEvent) => {
e.preventDefault();
if (!session || !pasted.trim()) return;
setSubmitting(true);
try {
const r = await completePiOAuth(upstream.id, {
sessionId: session.sessionId,
pastedUrlOrCode: pasted.trim(),
waitMs: 25_000,
});
setProgress(r.progress);
if (r.state === "pending") setMessage("还在处理,稍后再提交一次。");
else finish(r.state, r.message);
} catch (err) {
toast.error(errorMessage(err, "提交失败"));
} finally {
setSubmitting(false);
}
};
const pending = state === "pending" && !!session;
const ended = state && state !== "pending" ? END_LABEL[state] : null;
const mmss = left === null ? "" : `${Math.floor(left / 60)}:${String(left % 60).padStart(2, "0")}`;
return (
<div className="grid gap-3">
{termsNote ? (
<p className="rounded-lg border border-warning/40 bg-warning/10 px-3 py-2 text-caption text-foreground">
{termsNote}
</p>
) : null}
{!pending ? (
<div className="grid gap-3">
{modes.length > 1 ? (
<fieldset className="grid gap-1.5">
<legend className="mb-1 text-caption font-medium">登录方式</legend>
<div className="flex flex-wrap gap-2">
{modes.map((m) => (
<button
key={m}
type="button"
aria-pressed={mode === m}
onClick={() => setMode(m)}
className={cn(
"rounded-full border px-3 py-1 text-caption transition-colors duration-micro",
mode === m
? "border-foreground/30 bg-foreground/[0.08] text-foreground"
: "border-border text-muted-foreground hover:bg-foreground/[0.04]",
)}
>
{MODE_LABEL[m]}
</button>
))}
</div>
</fieldset>
) : null}
{ended ? (
<div className="flex flex-wrap items-center gap-2 text-caption">
<StatusBadge tone={ended.tone}>{ended.label}</StatusBadge>
{message ? <span className="text-muted-foreground">{message}</span> : null}
</div>
) : null}
{state !== "succeeded" ? (
<div>
<Button size="sm" onClick={begin} disabled={start.isPending}>
{start.isPending ? <LoaderCircle className="animate-spin" /> : null}
{ended ? "重新登录" : upstream.status === "expired" ? "重新登录" : "开始登录"}
</Button>
</div>
) : null}
</div>
) : session.mode === "device" ? (
<div className="grid gap-2 rounded-lg border border-border/60 p-3">
<p className="text-caption text-muted-foreground">
在任意设备打开验证地址,输入下面的验证码。授权后这里自动完成。
</p>
<div className="flex items-center gap-2">
<code className="rounded-md bg-muted px-3 py-1.5 font-mono text-lead tracking-widest select-all">
{session.userCode}
</code>
{session.userCode ? <CopyButton text={session.userCode} label="验证码" /> : null}
</div>
{session.verificationUrl ? (
<a
href={session.verificationUrl}
target="_blank"
rel="noreferrer noopener"
className="inline-flex items-center gap-1 text-caption break-all underline"
>
{session.verificationUrl}
<ExternalLink className="size-3.5 shrink-0" />
</a>
) : null}
{session.instructions ? <p className="text-meta text-muted-foreground">{session.instructions}</p> : null}
<p className="flex items-center gap-2 text-meta text-muted-foreground">
<LoaderCircle className="size-3.5 animate-spin" />
等待授权 · 剩余 {mmss}
{progress.length ? ` · ${progress[progress.length - 1]}` : ""}
</p>
<div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
abortRef.current?.abort();
setState(null);
setSession(null);
}}
>
取消
</Button>
</div>
</div>
) : (
<form onSubmit={submitPasted} noValidate className="grid gap-2 rounded-lg border border-border/60 p-3">
<p className="text-caption text-muted-foreground">
在自己的浏览器打开授权链接。授权后会跳到 localhost 地址,页面打不开是正常的,把地址栏里的完整 URL 粘到下面。
</p>
{session.authorizeUrl ? (
<div className="flex min-w-0 items-center gap-2">
<a
href={session.authorizeUrl}
target="_blank"
rel="noreferrer noopener"
className="inline-flex min-w-0 items-center gap-1 text-caption underline"
>
<span className="truncate">打开授权链接</span>
<ExternalLink className="size-3.5 shrink-0" />
</a>
<CopyButton text={session.authorizeUrl} label="授权链接" />
</div>
) : null}
<FormField label="地址栏 URL" htmlFor={`oauth-paste-${upstream.id}`} hint={`剩余 ${mmss}`}>
<Input
id={`oauth-paste-${upstream.id}`}
value={pasted}
onChange={(e) => setPasted(e.target.value)}
placeholder="http://localhost:…/callback?code=…&state=…"
autoComplete="off"
disabled={submitting}
/>
</FormField>
{message ? <p className="text-meta text-muted-foreground">{message}</p> : null}
<div className="flex gap-2">
<Button type="submit" size="sm" disabled={submitting || !pasted.trim()}>
{submitting ? <LoaderCircle className="animate-spin" /> : null}
完成登录
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
setState(null);
setSession(null);
}}
>
取消
</Button>
</div>
</form>
)}
</div>
);
}
@@ -0,0 +1,108 @@
import type { PiAuthKind, PiProviderGroup, PiProviderInfo, PiThinkingLevel, PiUpstream } from "@umt/contracts";
import { StatusBadge, type StatusTone } from "~/components/console/status-badge.tsx";
import { Input } from "~/components/ui/input.tsx";
// Agent 上游的界面标签(术语见 docs/design/glossary.md:上游配置叫"Agent 上游")
export const GROUP_LABEL: Record<PiProviderGroup, string> = {
api_key: "API Key",
subscription: "订阅登录",
cloud: "云凭据",
custom: "兼容端点",
test: "测试",
};
export const GROUP_ORDER: PiProviderGroup[] = ["api_key", "subscription", "cloud", "custom", "test"];
export const AUTH_LABEL: Record<PiAuthKind, string> = {
api_key: "API Key",
oauth: "订阅登录",
none: "不需要凭据",
};
export const THINKING_LABEL: Record<PiThinkingLevel, string> = {
off: "关闭",
minimal: "最低",
low: "低",
medium: "中",
high: "高",
xhigh: "很高",
max: "最高",
};
export const THINKING_OPTIONS = (Object.keys(THINKING_LABEL) as PiThinkingLevel[]).map((v) => ({
value: v,
label: `${THINKING_LABEL[v]}(${v})`,
}));
export const API_OPTIONS = [
{ value: "openai-completions", label: "OpenAI Chat Completions" },
{ value: "openai-responses", label: "OpenAI Responses" },
{ value: "anthropic-messages", label: "Anthropic Messages" },
{ value: "google-generative-ai", label: "Google Generative AI" },
];
export function upstreamStatus(u: Pick<PiUpstream, "status" | "authKind" | "enabled">): {
label: string;
tone: StatusTone;
} {
if (!u.enabled) return { label: "已停用", tone: "neutral" };
switch (u.status) {
case "ok":
return { label: "可用", tone: "success" };
case "auth_required":
return { label: u.authKind === "oauth" ? "待登录" : "缺 API Key", tone: "warning" };
case "expired":
return { label: "登录已失效", tone: "destructive" };
case "error":
return { label: "出错", tone: "destructive" };
}
}
export function UpstreamStatusBadge({ u }: { u: Pick<PiUpstream, "status" | "authKind" | "enabled"> }) {
const s = upstreamStatus(u);
return <StatusBadge tone={s.tone}>{s.label}</StatusBadge>;
}
export const providerName = (providers: PiProviderInfo[] | undefined, id: string) =>
providers?.find((p) => p.id === id)?.name ?? id;
/** 模型 id:可从列表挑,也可手填(目录滞后于上游时) */
export function ModelInput({
id,
value,
onChange,
models,
disabled,
invalid,
placeholder,
}: {
id: string;
value: string;
onChange: (v: string) => void;
models: { id: string; name: string }[];
disabled?: boolean;
invalid?: boolean;
placeholder?: string;
}) {
const listId = `${id}-models`;
return (
<>
<Input
id={id}
list={listId}
value={value}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
aria-invalid={invalid}
autoComplete="off"
placeholder={placeholder ?? (models.length ? `从 ${models.length} 个模型中选,或手填 id` : "模型 id")}
/>
<datalist id={listId}>
{models.map((m) => (
<option key={m.id} value={m.id}>
{m.name}
</option>
))}
</datalist>
</>
);
}
@@ -0,0 +1,72 @@
import type { PiUpstream, PiUpstreamTestResult } from "@umt/contracts";
import { LoaderCircle, PlugZap, Send } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { useTestPiUpstream } from "~/api/pi.ts";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { Button } from "~/components/ui/button.tsx";
import { errorMessage } from "~/lib/api.ts";
// 上游测试:默认只验凭据与模型(不产生费用);"发一次最小补全"真实调用一次模型
const ERROR_KIND: Record<string, string> = {
auth: "凭据被拒",
oauth: "登录失效",
model: "模型不可用",
network: "网络错误",
config: "配置错误",
other: "失败",
};
export function PiTestPanel({ upstream, model }: { upstream: PiUpstream; model?: string | null }) {
const test = useTestPiUpstream();
const [result, setResult] = useState<PiUpstreamTestResult | null>(null);
const [running, setRunning] = useState<"auth" | "completion" | null>(null);
const run = async (mode: "auth" | "completion") => {
setRunning(mode);
try {
const r = await test.mutateAsync({ id: upstream.id, body: { mode, ...(model ? { model } : {}) } });
setResult(r);
if (r.ok) toast.success(mode === "auth" ? "凭据可用" : "补全成功");
else toast.error(r.message ?? "测试失败");
} catch (err) {
setResult(null);
toast.error(errorMessage(err, "测试失败"));
} finally {
setRunning(null);
}
};
return (
<div className="grid gap-2">
<div className="flex flex-wrap gap-2">
<Button type="button" size="sm" variant="outline" disabled={!!running} onClick={() => run("auth")}>
{running === "auth" ? <LoaderCircle className="animate-spin" /> : <PlugZap />}
测试
</Button>
<Button type="button" size="sm" variant="ghost" disabled={!!running} onClick={() => run("completion")}>
{running === "completion" ? <LoaderCircle className="animate-spin" /> : <Send />}
发一次最小补全
</Button>
</div>
<p className="text-meta text-muted-foreground">
「测试」只检查凭据和模型,不产生费用;最小补全会真实调用一次模型(最多 16 个 token)。
</p>
{result ? (
<div className="flex flex-wrap items-center gap-2 text-caption">
<StatusBadge tone={result.ok ? "success" : "destructive"}>
{result.ok ? "通过" : ERROR_KIND[result.errorKind ?? "other"]}
</StatusBadge>
<span className="text-muted-foreground">
{result.latencyMs} ms{result.model ? ` · ${result.model}` : ""}
{result.source ? ` · ${result.source}` : ""}
</span>
{!result.ok && result.message ? (
<span className="w-full break-words text-destructive">{result.message}</span>
) : null}
</div>
) : null}
</div>
);
}
@@ -0,0 +1,397 @@
import { type PiProviderInfo, type PiThinkingLevel, type PiUpstream, PiUpstreamUpdate } from "@umt/contracts";
import { LoaderCircle, LogOut, Plus, Star, Trash2 } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import {
useLogoutPiOAuth,
usePiProviders,
usePiUpstreamModels,
usePiUpstreams,
useRemovePiUpstream,
useUpdatePiUpstream,
} from "~/api/pi.ts";
import { useSettings } from "~/api/settings.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 { FormField, zodFieldErrors } from "~/components/console/form-field.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { Button } from "~/components/ui/button.tsx";
import { errorMessage } from "~/lib/api.ts";
import { formatRelative } from "~/lib/labels.ts";
import { formatDateTime } from "~/lib/utils.ts";
import { PiOAuthLogin } from "./pi-oauth.tsx";
import {
AUTH_LABEL,
ModelInput,
providerName,
THINKING_LABEL,
THINKING_OPTIONS,
UpstreamStatusBadge,
} from "./pi-shared.tsx";
import { PiTestPanel } from "./pi-test.tsx";
import { PiUpstreamWizard } from "./pi-wizard.tsx";
import { FieldControl } from "./resource-section.tsx";
import { SettingsBlock } from "./settings-block.tsx";
// Agent 上游列表 + 编辑抽屉(登录、测试、设默认、删除)
export function PiUpstreamsBlock() {
const list = usePiUpstreams();
const providers = usePiProviders(false);
const settings = useSettings();
const [wizard, setWizard] = useState(false);
const [openId, setOpenId] = useState<string | null>(null);
const open = list.data?.items.find((u) => u.id === openId) ?? null;
const pv = providers.data?.providers;
const legacy = settings.data?.pi.provider;
return (
<SettingsBlock
section="agent"
anchor="upstream"
title="上游"
description="Agent 调用的模型上游。没指定上游的运行用默认上游;密钥和登录凭据只写入,不回显。"
plain
actions={
<Button size="sm" onClick={() => setWizard(true)}>
<Plus />
新建上游
</Button>
}
>
<DataTable
rows={list.data?.items}
rowKey={(r) => r.id}
loading={list.isPending}
error={list.error}
onRetry={() => list.refetch()}
onRowClick={(r) => setOpenId(r.id)}
empty={{
title: "还没有上游",
description: legacy ? `没有上游时按旧设置运行(provider:${legacy})。` : undefined,
}}
columns={[
{
key: "name",
header: "名称",
cell: (r) => (
<div className="min-w-0">
<p className="flex items-center gap-1.5 font-medium">
<span className="truncate">{r.name}</span>
{r.isDefault ? <StatusBadge tone="success">默认</StatusBadge> : null}
</p>
<p className="truncate text-meta text-muted-foreground">{providerName(pv, r.provider)}</p>
</div>
),
},
{ key: "auth", header: "认证", hideBelow: "md", cell: (r) => AUTH_LABEL[r.authKind] },
{
key: "model",
header: "模型",
hideBelow: "sm",
cell: (r) => <code className="text-meta break-all">{r.defaultModel ?? "provider 默认"}</code>,
},
{ key: "status", header: "状态", cell: (r) => <UpstreamStatusBadge u={r} /> },
]}
/>
<UpstreamDrawer
key={open?.id ?? "none"}
upstream={open}
provider={pv?.find((p) => p.id === open?.provider)}
onClose={() => setOpenId(null)}
/>
<PiUpstreamWizard open={wizard} onClose={() => setWizard(false)} />
</SettingsBlock>
);
}
function UpstreamDrawer({
upstream,
provider,
onClose,
}: {
upstream: PiUpstream | null;
provider: PiProviderInfo | undefined;
onClose: () => void;
}) {
const update = useUpdatePiUpstream();
const remove = useRemovePiUpstream();
const logout = useLogoutPiOAuth();
const models = usePiUpstreamModels(upstream?.id ?? null);
const [deleting, setDeleting] = useState(false);
const [v, setV] = useState(() => ({
name: upstream?.name ?? "",
model: upstream?.defaultModel ?? "",
thinking: (upstream?.thinkingLevel ?? "medium") as PiThinkingLevel,
baseUrl: upstream?.baseUrl ?? "",
apiKey: "",
enabled: upstream?.enabled ?? true,
env: { ...(upstream?.extra.env ?? {}) } as Record<string, string>,
}));
const [errors, setErrors] = useState<Record<string, string>>({});
if (!upstream) return null;
const custom = upstream.provider === "openai-compatible";
const showBaseUrl = (provider?.baseUrlOverridable ?? custom) && upstream.authKind !== "oauth";
const showThinking = provider?.supportsThinking || custom;
const envFields = provider?.envFields ?? [];
const dirty =
v.name !== upstream.name ||
v.model !== (upstream.defaultModel ?? "") ||
v.thinking !== upstream.thinkingLevel ||
v.baseUrl !== (upstream.baseUrl ?? "") ||
!!v.apiKey ||
v.enabled !== upstream.enabled ||
envFields.some((f) => (v.env[f.name] ?? "") !== (upstream.extra.env?.[f.name] ?? ""));
const save = async (e: React.FormEvent) => {
e.preventDefault();
const env = Object.fromEntries(Object.entries(v.env).filter(([, x]) => x.trim()));
const parsed = PiUpstreamUpdate.safeParse({
name: v.name.trim(),
defaultModel: v.model.trim() || null,
...(showThinking ? { thinkingLevel: v.thinking } : {}),
...(showBaseUrl ? { baseUrl: v.baseUrl.trim() || null } : {}),
...(v.apiKey.trim() ? { apiKey: v.apiKey.trim() } : {}),
enabled: v.enabled,
...(envFields.length ? { extra: { ...upstream.extra, env } } : {}),
});
if (!parsed.success) {
setErrors(zodFieldErrors(parsed.error.issues));
return;
}
setErrors({});
try {
await update.mutateAsync({ id: upstream.id, body: parsed.data });
setV((s) => ({ ...s, apiKey: "" }));
toast.success(`${v.name.trim()}已保存`);
} catch (err) {
toast.error(errorMessage(err, "保存失败"));
}
};
const makeDefault = async () => {
try {
await update.mutateAsync({ id: upstream.id, body: { makeDefault: true } });
toast.success(`${upstream.name}已设为默认上游`);
} catch (err) {
toast.error(errorMessage(err, "设置失败"));
}
};
const pending = update.isPending;
return (
<DetailSheet
open
onOpenChange={(o) => !o && onClose()}
title={upstream.name}
description={
<span className="flex flex-wrap items-center gap-2">
<UpstreamStatusBadge u={upstream} />
{upstream.isDefault ? <StatusBadge tone="success">默认上游</StatusBadge> : null}
<span>
{provider?.name ?? upstream.provider} · {AUTH_LABEL[upstream.authKind]}
</span>
</span>
}
width="lg"
footer={
<>
<Button
variant="ghost"
className="mr-auto hover:text-destructive"
onClick={() => setDeleting(true)}
disabled={pending}
>
<Trash2 />
删除
</Button>
{upstream.isDefault ? null : (
<Button variant="outline" onClick={makeDefault} disabled={pending}>
<Star />
设为默认
</Button>
)}
<Button type="submit" form="pi-upstream-form" disabled={pending || !dirty}>
{pending ? <LoaderCircle className="animate-spin" /> : null}
保存
</Button>
</>
}
>
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-caption">
{upstream.credentialMask ? (
<>
<dt className="text-muted-foreground">API Key</dt>
<dd className="font-mono">{upstream.credentialMask}</dd>
</>
) : null}
{upstream.account ? (
<>
<dt className="text-muted-foreground">账号</dt>
<dd className="break-all">{upstream.account}</dd>
</>
) : null}
{upstream.oauthExpiresAt ? (
<>
<dt className="text-muted-foreground">令牌到期</dt>
<dd>{formatDateTime(upstream.oauthExpiresAt)}(到期前自动刷新)</dd>
</>
) : null}
<dt className="text-muted-foreground">最近验证</dt>
<dd>{upstream.lastVerifiedAt ? formatRelative(upstream.lastVerifiedAt) : "未验证"}</dd>
{upstream.lastError ? (
<>
<dt className="text-muted-foreground">最近错误</dt>
<dd className="break-words text-destructive">{upstream.lastError}</dd>
</>
) : null}
</dl>
{upstream.authKind === "oauth" ? (
<DetailSection
title="登录"
actions={
upstream.hasCredential ? (
<Button
variant="ghost"
size="xs"
disabled={logout.isPending}
onClick={async () => {
try {
await logout.mutateAsync(upstream.id);
toast.success("已退出登录");
} catch (err) {
toast.error(errorMessage(err, "退出失败"));
}
}}
>
<LogOut />
退出登录
</Button>
) : null
}
>
{upstream.hasCredential && upstream.status === "ok" ? (
<p className="text-caption text-muted-foreground">已登录。退出只删除本地令牌,不会撤销提供方侧的授权。</p>
) : (
<PiOAuthLogin upstream={upstream} provider={provider} />
)}
</DetailSection>
) : null}
<DetailSection title="配置">
<form id="pi-upstream-form" onSubmit={save} noValidate className="grid gap-x-4 gap-y-1 sm:grid-cols-2">
<FieldControl
spec={{ name: "name", label: "名称", kind: "text", required: true }}
value={v.name}
error={errors.name}
onChange={(x) => setV((s) => ({ ...s, name: String(x) }))}
disabled={pending}
/>
<FieldControl
spec={{ name: "enabled", label: "启用", kind: "switch", hint: "停用后用它的运行直接失败" }}
value={v.enabled}
onChange={(x) => setV((s) => ({ ...s, enabled: x === true }))}
disabled={pending}
/>
<FormField
label="默认模型"
htmlFor="pu-model"
error={errors.defaultModel}
hint={models.data?.refreshError ? `模型列表刷新失败:${models.data.refreshError}` : "可从列表选,也可手填"}
className="sm:col-span-2"
>
<ModelInput
id="pu-model"
value={v.model}
onChange={(x) => setV((s) => ({ ...s, model: x }))}
models={models.data?.items ?? []}
disabled={pending}
/>
</FormField>
{showThinking ? (
<FieldControl
spec={{
name: "thinking",
label: "思考档位",
kind: "select",
options: THINKING_OPTIONS,
hint: `当前 ${THINKING_LABEL[upstream.thinkingLevel]};运行时按模型支持的档位取近`,
}}
value={v.thinking}
onChange={(x) => setV((s) => ({ ...s, thinking: x as PiThinkingLevel }))}
disabled={pending}
/>
) : null}
{showBaseUrl ? (
<FieldControl
spec={{
name: "baseUrl",
label: "Base URL",
kind: "url",
wide: true,
placeholder: provider?.defaultBaseUrl ?? undefined,
hint: custom ? undefined : "留空用官方地址",
}}
value={v.baseUrl}
error={errors.baseUrl}
onChange={(x) => setV((s) => ({ ...s, baseUrl: String(x) }))}
disabled={pending}
/>
) : null}
{upstream.authKind === "api_key" ? (
<FieldControl
spec={{
name: "apiKey",
label: upstream.hasCredential ? "替换 API Key" : "API Key",
kind: "password",
wide: true,
hint: upstream.hasCredential ? "留空不修改" : undefined,
}}
value={v.apiKey}
error={errors.apiKey}
onChange={(x) => setV((s) => ({ ...s, apiKey: String(x) }))}
disabled={pending}
/>
) : null}
{envFields.map((f) => (
<FieldControl
key={f.name}
spec={{ name: `env-${f.name}`, label: f.label, kind: "text", hint: f.hint ?? f.name }}
value={v.env[f.name] ?? ""}
onChange={(x) => setV((s) => ({ ...s, env: { ...s.env, [f.name]: String(x) } }))}
disabled={pending}
/>
))}
</form>
</DetailSection>
<DetailSection title="测试">
<PiTestPanel upstream={upstream} model={v.model.trim() || null} />
</DetailSection>
<ConfirmDialog
open={deleting}
onOpenChange={setDeleting}
title={`删除上游「${upstream.name}」?`}
description={
upstream.isDefault ? "它是默认上游,删除后没有默认上游;凭据一并删除,无法恢复。" : "凭据一并删除,无法恢复。"
}
confirmLabel="删除上游"
destructive
onConfirm={async () => {
try {
await remove.mutateAsync(upstream.id);
toast.success(`${upstream.name}已删除`);
onClose();
} catch (err) {
toast.error(errorMessage(err, "删除失败"));
throw err;
}
}}
/>
</DetailSheet>
);
}
@@ -0,0 +1,410 @@
import {
type PiAuthKind,
type PiProviderInfo,
type PiThinkingLevel,
type PiUpstream,
PiUpstreamCreate,
} from "@umt/contracts";
import { ChevronLeft, LoaderCircle, Search } from "lucide-react";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { useCreatePiUpstream, usePiProviders, usePiUpstreamModels, useUpdatePiUpstream } from "~/api/pi.ts";
import { DetailSection, DetailSheet } from "~/components/console/detail-sheet.tsx";
import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { FormField, zodFieldErrors } from "~/components/console/form-field.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Input } from "~/components/ui/input.tsx";
import { Switch } from "~/components/ui/switch.tsx";
import { errorMessage } from "~/lib/api.ts";
import { cn } from "~/lib/utils.ts";
import { PiOAuthLogin } from "./pi-oauth.tsx";
import { API_OPTIONS, AUTH_LABEL, GROUP_LABEL, GROUP_ORDER, ModelInput, THINKING_OPTIONS } from "./pi-shared.tsx";
import { PiTestPanel } from "./pi-test.tsx";
import { FieldControl } from "./resource-section.tsx";
// 新建 Agent 上游:① 选 provider → ② 填凭据与模型并创建 → ③ 登录 / 测试,可设为默认
type Step = "provider" | "config" | "verify";
export function PiUpstreamWizard({ open, onClose }: { open: boolean; onClose: () => void }) {
const [step, setStep] = useState<Step>("provider");
const [provider, setProvider] = useState<PiProviderInfo | null>(null);
const [created, setCreated] = useState<PiUpstream | null>(null);
const close = () => {
onClose();
// 关闭动画后再复位,避免闪回第一步
setTimeout(() => {
setStep("provider");
setProvider(null);
setCreated(null);
}, 200);
};
const title =
step === "provider"
? "新建 Agent 上游"
: step === "config"
? `新建上游 · ${provider?.name ?? ""}`
: (created?.name ?? "");
const description =
step === "provider"
? "第 1 步,共 3 步:选 provider"
: step === "config"
? "第 2 步,共 3 步:凭据与模型"
: created?.authKind === "oauth"
? "第 3 步,共 3 步:登录并测试。上游已创建。"
: "第 3 步,共 3 步:测试。上游已创建。";
return (
<DetailSheet open={open} onOpenChange={(o) => !o && close()} title={title} description={description} width="lg">
{step === "provider" ? (
<ProviderPicker
onPick={(p) => {
setProvider(p);
setStep("config");
}}
/>
) : step === "config" && provider ? (
<ConfigStep
provider={provider}
onBack={() => setStep("provider")}
onCreated={(u) => {
setCreated(u);
setStep("verify");
}}
/>
) : created ? (
<VerifyStep upstream={created} provider={provider} onDone={close} />
) : null}
</DetailSheet>
);
}
function ProviderPicker({ onPick }: { onPick: (p: PiProviderInfo) => void }) {
const q = usePiProviders(false);
const [filter, setFilter] = useState("");
if (q.isPending) return <LoadingState rows={6} />;
if (q.isError) return <ErrorState error={q.error} onRetry={() => q.refetch()} />;
const f = filter.trim().toLowerCase();
const list = q.data.providers.filter((p) => !f || p.name.toLowerCase().includes(f) || p.id.includes(f));
return (
<div className="grid gap-5">
<div className="relative">
<Search className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
aria-label="筛选 provider"
placeholder="筛选,例如 openai"
value={filter}
onChange={(e) => setFilter(e.target.value)}
className="pl-9"
/>
</div>
{GROUP_ORDER.map((g) => {
const items = list.filter((p) => p.group === g);
if (!items.length) return null;
return (
<DetailSection key={g} title={GROUP_LABEL[g]}>
<ul className="grid gap-1.5 sm:grid-cols-2">
{items.map((p) => (
<li key={p.id}>
<button
type="button"
onClick={() => onPick(p)}
className="flex w-full cursor-pointer flex-col items-start rounded-lg border border-border/60 px-3 py-2 text-left transition-colors duration-micro hover:bg-foreground/[0.04] focus-visible:bg-foreground/[0.06] focus-visible:outline-none"
>
<span className="text-caption font-medium text-foreground">{p.name}</span>
<span className="text-meta text-muted-foreground">
{p.authKinds.map((k) => AUTH_LABEL[k]).join(" / ")}
{p.modelCount ? ` · ${p.modelCount} 个模型` : ""}
</span>
</button>
</li>
))}
</ul>
</DetailSection>
);
})}
{list.length === 0 ? <p className="text-caption text-muted-foreground">没有匹配的 provider。</p> : null}
</div>
);
}
function ConfigStep({
provider,
onBack,
onCreated,
}: {
provider: PiProviderInfo;
onBack: () => void;
onCreated: (u: PiUpstream) => void;
}) {
const full = usePiProviders(true);
const create = useCreatePiUpstream();
const models = useMemo(
() => full.data?.providers.find((p) => p.id === provider.id)?.models ?? [],
[full.data, provider.id],
);
const custom = provider.id === "openai-compatible";
const [authKind, setAuthKind] = useState<PiAuthKind>(provider.authKinds[0] ?? "none");
const [name, setName] = useState(provider.name);
const [apiKey, setApiKey] = useState("");
const [baseUrl, setBaseUrl] = useState("");
const [model, setModel] = useState("");
const [thinking, setThinking] = useState<PiThinkingLevel>("medium");
const [api, setApi] = useState("openai-completions");
const [contextWindow, setContextWindow] = useState("");
const [env, setEnv] = useState<Record<string, string>>({});
const [errors, setErrors] = useState<Record<string, string>>({});
const showBaseUrl = provider.baseUrlOverridable && authKind !== "oauth";
const submit = async (e: React.FormEvent) => {
e.preventDefault();
const envVals = Object.fromEntries(Object.entries(env).filter(([, v]) => v.trim()));
const extra = {
...(Object.keys(envVals).length ? { env: envVals } : {}),
...(custom ? { api: api as never } : {}),
...(custom && contextWindow.trim() ? { contextWindow: Number(contextWindow) } : {}),
};
const parsed = PiUpstreamCreate.safeParse({
name: name.trim(),
provider: provider.id,
authKind,
...(authKind === "api_key" && apiKey.trim() ? { apiKey: apiKey.trim() } : {}),
baseUrl: showBaseUrl && baseUrl.trim() ? baseUrl.trim() : null,
defaultModel: model.trim() || null,
...(provider.supportsThinking || custom ? { thinkingLevel: thinking } : {}),
extra,
});
const fe: Record<string, string> = parsed.success ? {} : zodFieldErrors(parsed.error.issues);
for (const f of provider.envFields) if (f.required && !env[f.name]?.trim()) fe[`env.${f.name}`] = `${f.label}必填`;
if (authKind === "api_key" && !apiKey.trim() && !custom) fe.apiKey = "填写 API Key";
if (fe.name) fe.name = "名称必填,64 字以内";
if (fe["extra.contextWindow"]) fe.contextWindow = "填 1024 以上的整数";
if (!parsed.success || Object.keys(fe).length) {
setErrors(fe);
return;
}
setErrors({});
try {
const u = await create.mutateAsync(parsed.data);
toast.success(`${u.name}已创建`);
onCreated(u);
} catch (err) {
toast.error(errorMessage(err, "创建失败"));
}
};
const pending = create.isPending;
return (
<form onSubmit={submit} noValidate className="grid gap-x-4 gap-y-1 sm:grid-cols-2">
<div className="sm:col-span-2">
<Button type="button" variant="ghost" size="xs" onClick={onBack} className="-ml-2 mb-2">
<ChevronLeft />
换一个 provider
</Button>
{provider.note && !custom ? <p className="mb-3 text-caption text-muted-foreground">{provider.note}</p> : null}
</div>
<FormField label="名称" htmlFor="pw-name" error={errors.name} required className="sm:col-span-2">
<Input id="pw-name" value={name} onChange={(e) => setName(e.target.value)} disabled={pending} />
</FormField>
{provider.authKinds.length > 1 ? (
<FieldControl
spec={{
name: "authKind",
label: "认证方式",
kind: "select",
options: provider.authKinds.map((k) => ({ value: k, label: AUTH_LABEL[k] })),
wide: true,
}}
value={authKind}
onChange={(v) => setAuthKind(v as PiAuthKind)}
disabled={pending}
/>
) : null}
{authKind === "oauth" && provider.termsNote ? (
<p className="mb-3 rounded-lg border border-warning/40 bg-warning/10 px-3 py-2 text-caption sm:col-span-2">
{provider.termsNote}
</p>
) : null}
{authKind === "api_key" ? (
<FieldControl
spec={{
name: "apiKey",
label: "API Key",
kind: "password",
required: !custom,
wide: true,
hint: custom ? "端点不要求鉴权时可留空" : "只写入,保存后只显示前后 4 位",
}}
value={apiKey}
error={errors.apiKey}
onChange={(v) => setApiKey(String(v))}
disabled={pending}
/>
) : null}
{authKind === "none" && provider.group === "cloud" ? (
<p className="mb-3 text-caption text-muted-foreground sm:col-span-2">
不存密钥,运行时读服务端进程的环境凭据(AWS 凭据链 / Google ADC)。
</p>
) : null}
{showBaseUrl ? (
<FieldControl
spec={{
name: "baseUrl",
label: "Base URL",
kind: "url",
required: custom,
wide: true,
placeholder: provider.defaultBaseUrl ?? "https://example.com/v1",
hint: custom ? "OpenAI 兼容地址一般以 /v1 结尾" : "留空用官方地址",
}}
value={baseUrl}
error={errors.baseUrl}
onChange={(v) => setBaseUrl(String(v))}
disabled={pending}
/>
) : null}
{custom ? (
<>
<FieldControl
spec={{ name: "api", label: "接口形状", kind: "select", options: API_OPTIONS }}
value={api}
onChange={(v) => setApi(String(v))}
disabled={pending}
/>
<FieldControl
spec={{ name: "contextWindow", label: "上下文窗口(token)", kind: "number", placeholder: "128000" }}
value={contextWindow}
error={errors.contextWindow}
onChange={(v) => setContextWindow(String(v))}
disabled={pending}
/>
</>
) : null}
{provider.envFields.map((f) => (
<FieldControl
key={f.name}
spec={{ name: `env-${f.name}`, label: f.label, kind: "text", required: f.required, hint: f.hint ?? f.name }}
value={env[f.name] ?? ""}
error={errors[`env.${f.name}`]}
onChange={(v) => setEnv((s) => ({ ...s, [f.name]: String(v) }))}
disabled={pending}
/>
))}
<FormField
label="默认模型"
htmlFor="pw-model"
error={errors.defaultModel}
required={custom}
hint={custom ? "端点上的模型 id" : "留空用 provider 的默认模型"}
className="sm:col-span-2"
>
<ModelInput
id="pw-model"
value={model}
onChange={setModel}
models={models}
disabled={pending}
invalid={!!errors.defaultModel}
placeholder={full.isPending && !custom ? "模型列表加载中…" : undefined}
/>
</FormField>
{provider.supportsThinking || custom ? (
<FieldControl
spec={{
name: "thinking",
label: "思考档位",
kind: "select",
options: THINKING_OPTIONS,
hint: "运行时按模型支持的档位取近",
}}
value={thinking}
onChange={(v) => setThinking(v as PiThinkingLevel)}
disabled={pending}
/>
) : null}
<div className="mt-2 sm:col-span-2">
<Button type="submit" disabled={pending}>
{pending ? <LoaderCircle className="animate-spin" /> : null}
{authKind === "oauth" ? "创建并去登录" : "创建并测试"}
</Button>
</div>
</form>
);
}
function VerifyStep({
upstream,
provider,
onDone,
}: {
upstream: PiUpstream;
provider: PiProviderInfo | null;
onDone: () => void;
}) {
const update = useUpdatePiUpstream();
const models = usePiUpstreamModels(upstream.id);
const [model, setModel] = useState(upstream.defaultModel ?? "");
const [makeDefault, setMakeDefault] = useState(upstream.isDefault);
const [loggedIn, setLoggedIn] = useState(upstream.authKind !== "oauth");
const finish = async () => {
const body = {
...(model.trim() !== (upstream.defaultModel ?? "") ? { defaultModel: model.trim() || null } : {}),
...(makeDefault && !upstream.isDefault ? { makeDefault: true } : {}),
};
try {
if (Object.keys(body).length) await update.mutateAsync({ id: upstream.id, body });
if (makeDefault && !upstream.isDefault) toast.success(`${upstream.name}已设为默认上游`);
onDone();
} catch (err) {
toast.error(errorMessage(err, "保存失败"));
}
};
return (
<div className="grid gap-6">
{upstream.authKind === "oauth" ? (
<DetailSection title="登录">
<PiOAuthLogin upstream={upstream} provider={provider ?? undefined} onSucceeded={() => setLoggedIn(true)} />
</DetailSection>
) : null}
<DetailSection title="模型">
<FormField
label="默认模型"
htmlFor="pv-model"
hint={models.data?.refreshError ? `模型列表刷新失败:${models.data.refreshError}` : "可从列表选,也可手填"}
>
<ModelInput id="pv-model" value={model} onChange={setModel} models={models.data?.items ?? []} />
</FormField>
</DetailSection>
<DetailSection title="测试">
{loggedIn ? (
<PiTestPanel upstream={upstream} model={model.trim() || null} />
) : (
<p className="text-caption text-muted-foreground">登录后再测试。</p>
)}
</DetailSection>
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-border/60 pt-4">
<label
htmlFor="pv-default"
className={cn("flex items-center gap-2 text-caption", upstream.isDefault && "opacity-60")}
>
<Switch
id="pv-default"
checked={makeDefault}
onCheckedChange={setMakeDefault}
disabled={upstream.isDefault}
/>
{upstream.isDefault ? "已是默认上游" : "设为默认上游"}
</label>
<Button onClick={finish} disabled={update.isPending}>
{update.isPending ? <LoaderCircle className="animate-spin" /> : null}
完成
</Button>
</div>
</div>
);
}
@@ -0,0 +1,344 @@
import type { UseMutationResult, UseQueryResult } from "@tanstack/react-query";
import type { CursorPage } from "@umt/contracts";
import { LoaderCircle, Pencil, Plus, Trash2 } from "lucide-react";
import { type ReactNode, useState } from "react";
import { toast } from "sonner";
import type { z } from "zod";
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
import { type DataColumn, DataTable } from "~/components/console/data-table.tsx";
import { DetailSheet } from "~/components/console/detail-sheet.tsx";
import { FormField, zodFieldErrors } from "~/components/console/form-field.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Input } from "~/components/ui/input.tsx";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
import { Switch } from "~/components/ui/switch.tsx";
import { Textarea } from "~/components/ui/textarea.tsx";
import { errorMessage } from "~/lib/api.ts";
// 设置页里"实例类"资源的通用段落:列表 + 新建/编辑抽屉 + 删除 + 行内动作。
// 表单用 contracts 的 Create/Update schema 做 zod 校验;凭证字段只写,编辑时留空表示不变。
export type FieldValue = string | boolean;
export type FormValues = Record<string, FieldValue>;
export type FieldSpec = {
name: string;
label: string;
kind: "text" | "url" | "password" | "number" | "select" | "switch" | "textarea";
options?: { value: string; label: string }[];
placeholder?: string;
hint?: string;
/** 编辑时的说明(如"留空表示不修改") */
editHint?: string;
required?: boolean;
/** 编辑时不可改 */
createOnly?: boolean;
/** 条件显示 */
visible?: (v: FormValues) => boolean;
wide?: boolean;
};
export type ResourceConfig<Row extends { id: string }> = {
/** 单数名,用于标题与提示:"下载器实例" */
noun: string;
description?: ReactNode;
columns: DataColumn<Row>[];
fields: FieldSpec[];
/** 新建时的初始值 */
initial: FormValues;
/** 编辑时从行取表单值(凭证字段给空串) */
fromRow: (row: Row) => FormValues;
/** 表单值 → 请求体;mode=edit 时凭证空串应省略 */
toBody: (v: FormValues, mode: "create" | "edit") => unknown;
createSchema: z.ZodType;
updateSchema: z.ZodType;
/** 行内额外动作(测试连接等) */
rowActions?: (row: Row) => ReactNode;
emptyHint?: string;
/** 删除确认的额外说明 */
deleteHint?: string;
/** 编辑抽屉里表单下方的附加内容(如该实例的路径映射) */
drawerExtra?: (row: Row) => ReactNode;
};
type Hooks<Row> = {
list: UseQueryResult<CursorPage<Row>>;
create: UseMutationResult<Row, Error, never>;
update: UseMutationResult<Row, Error, { id: string; body: never }>;
remove: UseMutationResult<unknown, Error, string>;
};
export function ResourceSection<Row extends { id: string }>({
config,
hooks,
}: {
config: ResourceConfig<Row>;
hooks: Hooks<Row>;
}) {
const [editing, setEditing] = useState<Row | "new" | null>(null);
const [deleting, setDeleting] = useState<Row | null>(null);
const { list, remove } = hooks;
const columns: DataColumn<Row>[] = [
...config.columns,
{
key: "__actions",
header: <span className="sr-only">操作</span>,
align: "right",
cell: (row) => (
// biome-ignore lint/a11y/noStaticElementInteractions: 只拦截冒泡,避免行内按钮触发整行打开抽屉
<div
className="flex items-center justify-end gap-1"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
{config.rowActions?.(row)}
<Button variant="ghost" size="icon-sm" aria-label={`编辑${config.noun}`} onClick={() => setEditing(row)}>
<Pencil />
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label={`删除${config.noun}`}
className="hover:text-destructive"
onClick={() => setDeleting(row)}
>
<Trash2 />
</Button>
</div>
),
},
];
return (
<div>
<div className="mb-3 flex flex-wrap items-start justify-between gap-3">
{config.description ? (
<p className="max-w-2xl text-caption text-muted-foreground">{config.description}</p>
) : (
<span />
)}
<Button size="sm" onClick={() => setEditing("new")}>
<Plus />
添加{config.noun}
</Button>
</div>
<DataTable
columns={columns}
rows={list.data?.items}
rowKey={(r) => r.id}
loading={list.isPending}
error={list.error}
onRetry={() => list.refetch()}
onRowClick={(r) => setEditing(r)}
empty={{ title: `还没有${config.noun}`, description: config.emptyHint }}
/>
<ResourceFormDrawer
key={editing === "new" ? "new" : (editing?.id ?? "none")}
config={config}
hooks={hooks}
row={editing}
onClose={() => setEditing(null)}
/>
<ConfirmDialog
open={!!deleting}
onOpenChange={(o) => !o && setDeleting(null)}
title={`删除${config.noun}?`}
description={config.deleteHint ?? "删除后无法恢复。"}
confirmLabel="删除"
destructive
onConfirm={async () => {
if (!deleting) return;
try {
await remove.mutateAsync(deleting.id);
toast.success(`已删除${config.noun}`);
} catch (err) {
toast.error(errorMessage(err, "删除失败"));
throw err;
}
}}
/>
</div>
);
}
function ResourceFormDrawer<Row extends { id: string }>({
config,
hooks,
row,
onClose,
}: {
config: ResourceConfig<Row>;
hooks: Hooks<Row>;
row: Row | "new" | null;
onClose: () => void;
}) {
const mode = row === "new" ? "create" : "edit";
const [values, setValues] = useState<FormValues>(() =>
row && row !== "new" ? config.fromRow(row) : { ...config.initial },
);
const [errors, setErrors] = useState<Record<string, string>>({});
const pending = hooks.create.isPending || hooks.update.isPending;
const set = (name: string, v: FieldValue) => setValues((s) => ({ ...s, [name]: v }));
const submit = async (e: React.FormEvent) => {
e.preventDefault();
const body = config.toBody(values, mode);
const parsed = (mode === "create" ? config.createSchema : config.updateSchema).safeParse(body);
if (!parsed.success) {
const fe = zodFieldErrors(parsed.error.issues);
// 顶层 refine 错误(path 为空)挂到第一个字段下
if (fe[""]) {
fe[config.fields[0]?.name ?? ""] ??= fe[""];
}
setErrors(fe);
return;
}
setErrors({});
try {
if (mode === "create") await hooks.create.mutateAsync(parsed.data as never);
else if (row && row !== "new") await hooks.update.mutateAsync({ id: row.id, body: parsed.data as never });
toast.success(mode === "create" ? `已添加${config.noun}` : `已保存${config.noun}`);
onClose();
} catch (err) {
toast.error(errorMessage(err, "保存失败"));
}
};
const formId = `resource-form-${config.noun}`;
return (
<DetailSheet
open={!!row}
onOpenChange={(o) => !o && !pending && onClose()}
title={`${mode === "create" ? "添加" : "编辑"}${config.noun}`}
description="凭证只写入,保存后不回显。"
footer={
<>
<Button variant="outline" onClick={onClose} disabled={pending}>
取消
</Button>
<Button type="submit" form={formId} disabled={pending}>
{pending ? <LoaderCircle className="animate-spin" /> : null}
保存
</Button>
</>
}
>
<form id={formId} onSubmit={submit} noValidate className="grid gap-x-4 gap-y-1 sm:grid-cols-2">
{config.fields
.filter((f) => !f.visible || f.visible(values))
.map((f) => (
<FieldControl
key={f.name}
spec={f}
value={values[f.name]}
error={errors[f.name]}
onChange={(v) => set(f.name, v)}
disabled={pending || (mode === "edit" && !!f.createOnly)}
hint={mode === "edit" && f.editHint ? f.editHint : f.hint}
/>
))}
</form>
{row && row !== "new" && config.drawerExtra ? config.drawerExtra(row) : null}
</DetailSheet>
);
}
export function FieldControl({
spec,
value,
error,
onChange,
disabled,
hint,
}: {
spec: FieldSpec;
value: FieldValue | undefined;
error?: string;
onChange: (v: FieldValue) => void;
disabled?: boolean;
hint?: string;
}) {
const id = `f-${spec.name}`;
const wide = spec.wide || spec.kind === "textarea";
if (spec.kind === "switch") {
return (
<div className={wide ? "sm:col-span-2" : undefined}>
<label htmlFor={id} className="flex min-h-9 items-center justify-between gap-3 text-caption font-medium">
{spec.label}
<Switch id={id} checked={value === true} onCheckedChange={onChange} disabled={disabled} />
</label>
<p className="min-h-4 text-meta text-muted-foreground">{hint ?? ""}</p>
</div>
);
}
let control: ReactNode;
if (spec.kind === "select") {
control = (
<Select value={String(value ?? "")} onValueChange={onChange} disabled={disabled}>
<SelectTrigger id={id} className="w-full" aria-invalid={!!error}>
<SelectValue placeholder={spec.placeholder ?? "选择"} />
</SelectTrigger>
<SelectContent position="popper">
{(spec.options ?? []).map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
} else if (spec.kind === "textarea") {
control = (
<Textarea
id={id}
value={String(value ?? "")}
onChange={(e) => onChange(e.target.value)}
placeholder={spec.placeholder}
disabled={disabled}
aria-invalid={!!error}
rows={3}
/>
);
} else {
control = (
<Input
id={id}
type={
spec.kind === "password"
? "password"
: spec.kind === "number"
? "number"
: spec.kind === "url"
? "url"
: "text"
}
autoComplete={spec.kind === "password" ? "new-password" : "off"}
value={String(value ?? "")}
onChange={(e) => onChange(e.target.value)}
placeholder={spec.placeholder}
disabled={disabled}
aria-invalid={!!error}
/>
);
}
return (
<FormField
label={spec.label}
htmlFor={id}
error={error}
hint={hint}
required={spec.required}
className={wide ? "sm:col-span-2" : undefined}
>
{control}
</FormField>
);
}
/** 表单值工具 */
export const str = (v: FieldValue | undefined) => (typeof v === "string" ? v.trim() : "");
export const optStr = (v: FieldValue | undefined) => str(v) || undefined;
export const nullStr = (v: FieldValue | undefined) => str(v) || null;
export const num = (v: FieldValue | undefined) => (str(v) === "" ? undefined : Number(str(v)));
@@ -0,0 +1,37 @@
import { SETTINGS_SECTIONS, type SettingsSectionDef, type SettingsSectionId } from "@umt/contracts";
import { Bot, FolderTree, KeyRound, type LucideIcon, Server, Shield, Tags, Tv, UserCog, Zap } from "lucide-react";
import type { ReactNode } from "react";
import { AccountSection } from "./account.tsx";
import { AgentSection } from "./agent.tsx";
import { ApiKeysSection } from "./api-keys.tsx";
import { AutomationSection } from "./automation.tsx";
import { DownloadersSection, MediaServersSection } from "./instances.tsx";
import { LibrarySection } from "./library.tsx";
import { MetadataSection } from "./metadata.tsx";
import { SitesSection } from "./sites.tsx";
// 设置分区:文案与字段归属在 contracts 的 SETTINGS_SECTIONS,这里只挂图标与渲染
const UI: Record<SettingsSectionId, { icon: LucideIcon; render: () => ReactNode }> = {
automation: { icon: Zap, render: () => <AutomationSection /> },
sites: { icon: UserCog, render: () => <SitesSection /> },
downloaders: { icon: Server, render: () => <DownloadersSection /> },
library: { icon: FolderTree, render: () => <LibrarySection /> },
"media-servers": { icon: Tv, render: () => <MediaServersSection /> },
metadata: { icon: Tags, render: () => <MetadataSection /> },
agent: { icon: Bot, render: () => <AgentSection /> },
"api-keys": { icon: KeyRound, render: () => <ApiKeysSection /> },
account: { icon: Shield, render: () => <AccountSection /> },
};
export type SectionView = SettingsSectionDef & { icon: LucideIcon; render: () => ReactNode };
export const SECTIONS: SectionView[] = SETTINGS_SECTIONS.map((s) => ({ ...s, ...UI[s.id] }));
/** URL hash:`#<分区>` 或 `#<分区>/<节>`;不认识的落到默认分区 */
export function parseSettingsHash(hash: string): { section: SectionView; anchor: string | null } {
const [id, anchor] = decodeURIComponent(hash.replace(/^#/, "")).split("/");
const section = SECTIONS.find((s) => s.id === id) ?? (SECTIONS[0] as SectionView);
return { section, anchor: section.id === id && anchor ? anchor : null };
}
export const settingsHash = (section: string, anchor?: string | null) => `#${section}${anchor ? `/${anchor}` : ""}`;
@@ -0,0 +1,45 @@
import type { ReactNode } from "react";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { cn } from "~/lib/utils.ts";
/** 分区内锚点 id:`<分区>-<节>`,URL hash 写作 `#<分区>/<节>` */
export const anchorId = (section: string, anchor: string) => `settings-${section}-${anchor}`;
/** 设置分区里的一节:标题 + 一句说明 + 未保存提示 + 内容;带锚点 */
export function SettingsBlock({
section,
anchor,
title,
description,
dirty,
actions,
plain,
children,
}: {
section: string;
anchor: string;
title: string;
description?: ReactNode;
dirty?: boolean;
actions?: ReactNode;
/** 不包卡片(内容自带 DataTable 卡片时用) */
plain?: boolean;
children: ReactNode;
}) {
const id = anchorId(section, anchor);
return (
<section id={id} aria-labelledby={`${id}-title`} className="scroll-mt-24">
<div className="mb-3 flex flex-wrap items-end justify-between gap-3">
<div className="min-w-0">
<h2 id={`${id}-title`} className="flex items-center gap-2 text-body font-semibold text-foreground">
{title}
{dirty ? <StatusBadge tone="warning">未保存</StatusBadge> : null}
</h2>
{description ? <p className="mt-0.5 max-w-2xl text-caption text-muted-foreground">{description}</p> : null}
</div>
{actions ? <div className="flex shrink-0 items-center gap-2">{actions}</div> : null}
</div>
<div className={cn(!plain && "pg-slab min-w-0 rounded-xl p-5")}>{children}</div>
</section>
);
}
@@ -0,0 +1,129 @@
import {
type GlobalSettings,
GlobalSettings as GlobalSettingsSchema,
type GlobalSettingsUpdate,
mergeSettings,
} from "@umt/contracts";
import { LoaderCircle } from "lucide-react";
import { type ReactNode, useState } from "react";
import { toast } from "sonner";
import { useSettings, useUpdateSettings } from "~/api/settings.ts";
import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { zodFieldErrors } from "~/components/console/form-field.tsx";
import { Button } from "~/components/ui/button.tsx";
import { errorMessage } from "~/lib/api.ts";
import { FieldControl, type FieldSpec, type FormValues } from "./resource-section.tsx";
import { SettingsBlock } from "./settings-block.tsx";
// 全局设置的一节:一组字段,独立保存(PATCH 对应 key);
// 校验 = 合并现值后用 contracts GlobalSettings 整体校验(含"恢复水位 < 硬阈值"等交叉规则)。
export type CardSpec = {
/** 锚点 id(分区内唯一) */
anchor: string;
title: string;
description?: ReactNode;
fields: FieldSpec[];
fromSettings: (s: GlobalSettings) => FormValues;
toPatch: (v: FormValues, s: GlobalSettings) => GlobalSettingsUpdate;
extra?: (s: GlobalSettings) => ReactNode;
};
export function SettingsCard({
spec,
settings,
section,
}: {
spec: CardSpec;
settings: GlobalSettings;
section: string;
}) {
const update = useUpdateSettings();
const [values, setValues] = useState<FormValues>(() => spec.fromSettings(settings));
const [errors, setErrors] = useState<Record<string, string>>({});
const initial = spec.fromSettings(settings);
const dirty = Object.keys(values).some((k) => values[k] !== initial[k]);
const save = async (e: React.FormEvent) => {
e.preventDefault();
const patch = spec.toPatch(values, settings);
const parsed = GlobalSettingsSchema.safeParse(mergeSettings(settings, patch));
if (!parsed.success) {
const fe = zodFieldErrors(parsed.error.issues);
// 交叉校验(refine)落在分组上:挂到本节同组的第一个字段
for (const [k, m] of Object.entries(fe)) {
if (!spec.fields.some((f) => f.name === k))
fe[spec.fields.find((f) => k.startsWith(f.name.split(".")[0] ?? ""))?.name ?? k] ??= m;
}
setErrors(fe);
return;
}
setErrors({});
try {
const next = await update.mutateAsync(patch);
setValues(spec.fromSettings(next));
toast.success(`${spec.title}已保存`);
} catch (err) {
toast.error(errorMessage(err, "保存失败"));
}
};
return (
<SettingsBlock
section={section}
anchor={spec.anchor}
title={spec.title}
description={spec.description}
dirty={dirty}
>
<form onSubmit={save} noValidate>
<div className="grid gap-x-4 gap-y-1 sm:grid-cols-2">
{spec.fields
.filter((f) => !f.visible || f.visible(values))
.map((f) => (
<FieldControl
key={f.name}
spec={f}
value={values[f.name]}
error={errors[f.name]}
hint={f.hint}
disabled={update.isPending}
onChange={(v) => setValues((s) => ({ ...s, [f.name]: v }))}
/>
))}
</div>
{spec.extra?.(settings)}
<div className="mt-2 flex items-center gap-2">
<Button type="submit" size="sm" disabled={update.isPending || !dirty}>
{update.isPending ? <LoaderCircle className="animate-spin" /> : null}
保存
</Button>
{dirty ? (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
setValues(spec.fromSettings(settings));
setErrors({});
}}
>
还原
</Button>
) : null}
</div>
</form>
</SettingsBlock>
);
}
/** 读取全局设置后再渲染(加载 / 出错占位) */
export function SettingsGate({ children }: { children: (s: GlobalSettings) => ReactNode }) {
const q = useSettings();
if (q.isPending) return <LoadingState rows={6} />;
if (q.isError) return <ErrorState error={q.error} onRetry={() => q.refetch()} />;
return <>{children(q.data)}</>;
}
/** 数字 → 表单字符串(null 为空) */
export const n = (v: number | null | undefined) => (v === null || v === undefined ? "" : String(v));
+625
View File
@@ -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>
);
}