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

+49
View File
@@ -16,8 +16,11 @@ import type {
PathMappingUpdate,
Site,
SiteAccount,
SiteAccountUpdate,
SiteCreate,
SiteCredentialsPut,
SiteCredentialsResult,
SiteUpdate,
} from "@umt/contracts";
import { apiData } from "~/lib/api.ts";
import { type QueryArea, qk } from "~/lib/query-keys.ts";
@@ -115,6 +118,52 @@ export function usePutSiteCredentials() {
apiData<SiteCredentialsResult>("/sites/{id}/credentials", { method: "PUT", pathParams: { id: siteId }, body }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: qk.all("site-accounts") });
qc.invalidateQueries({ queryKey: qk.all("sites") });
},
});
}
// ─── 站点登记与参数(设置"站点与账号";浏览在站点工作台) ───
function useInvalidateSites() {
const qc = useQueryClient();
return () => {
// sites.ts 用 ["sites", …],这里的列表用 qk.list("sites"),两者同前缀
qc.invalidateQueries({ queryKey: qk.all("sites") });
qc.invalidateQueries({ queryKey: qk.all("site-accounts") });
};
}
export function useCreateSite() {
const inv = useInvalidateSites();
return useMutation({
mutationFn: (body: SiteCreate) => apiData<Site>("/sites", { method: "POST", body }),
onSuccess: inv,
});
}
export function useUpdateSite() {
const inv = useInvalidateSites();
return useMutation({
mutationFn: ({ id, body }: { id: string; body: SiteUpdate }) =>
apiData<Site>("/sites/{id}", { method: "PATCH", pathParams: { id }, body }),
onSuccess: inv,
});
}
export function useUpdateSiteAccount() {
const inv = useInvalidateSites();
return useMutation({
mutationFn: ({ id, body }: { id: string; body: SiteAccountUpdate }) =>
apiData<SiteAccount>("/site-accounts/{id}", { method: "PATCH", pathParams: { id }, body }),
onSuccess: inv,
});
}
export function useRemoveSiteAccount() {
const inv = useInvalidateSites();
return useMutation({
mutationFn: (id: string) => apiData<unknown>("/site-accounts/{id}", { method: "DELETE", pathParams: { id } }),
onSuccess: inv,
});
}
+125
View File
@@ -0,0 +1,125 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type {
PiOAuthCompleteRequest,
PiOAuthCompleteResult,
PiOAuthStartRequest,
PiOAuthStartResult,
PiProvidersResponse,
PiUpstream,
PiUpstreamCreate,
PiUpstreamList,
PiUpstreamModels,
PiUpstreamTestRequest,
PiUpstreamTestResult,
PiUpstreamUpdate,
} from "@umt/contracts";
import { apiData, TTL_MINUTE } from "~/lib/api.ts";
import { qk } from "~/lib/query-keys.ts";
// Agent 上游(W7-A 的 /pi/*):能力表、上游 CRUD、测试、模型表、订阅登录。
// 写操作后刷新上游列表与全局设置(默认上游存在 settings.pi.defaultUpstreamId)。
export const piKeys = {
all: ["pi"] as const,
providers: (withModels: boolean) => ["pi", "providers", withModels] as const,
upstreams: ["pi", "upstreams"] as const,
models: (id: string) => ["pi", "models", id] as const,
};
/** 能力表;withModels = 带静态模型表(约 370 KB,只在新建向导里要) */
export function usePiProviders(withModels = false, enabled = true) {
return useQuery({
queryKey: piKeys.providers(withModels),
enabled,
staleTime: 30 * TTL_MINUTE,
queryFn: ({ signal }) =>
apiData<PiProvidersResponse>("/pi/providers", { params: withModels ? {} : { models: "false" }, signal }),
});
}
export function usePiUpstreams() {
return useQuery({
queryKey: piKeys.upstreams,
queryFn: ({ signal }) => apiData<PiUpstreamList>("/pi/upstreams", { signal }),
});
}
export function usePiUpstreamModels(id: string | null) {
return useQuery({
queryKey: piKeys.models(id ?? ""),
enabled: !!id,
staleTime: 5 * TTL_MINUTE,
queryFn: ({ signal }) =>
apiData<PiUpstreamModels>("/pi/upstreams/{id}/models", { pathParams: { id: id ?? "" }, signal }),
});
}
function useInvalidatePi() {
const qc = useQueryClient();
return () => {
qc.invalidateQueries({ queryKey: piKeys.upstreams });
qc.invalidateQueries({ queryKey: qk.settings });
};
}
export function useCreatePiUpstream() {
const inv = useInvalidatePi();
return useMutation({
mutationFn: (body: PiUpstreamCreate) => apiData<PiUpstream>("/pi/upstreams", { method: "POST", body }),
onSuccess: inv,
});
}
export function useUpdatePiUpstream() {
const inv = useInvalidatePi();
return useMutation({
mutationFn: ({ id, body }: { id: string; body: PiUpstreamUpdate }) =>
apiData<PiUpstream>("/pi/upstreams/{id}", { method: "PATCH", pathParams: { id }, body }),
onSuccess: inv,
});
}
export function useRemovePiUpstream() {
const inv = useInvalidatePi();
return useMutation({
mutationFn: (id: string) => apiData<unknown>("/pi/upstreams/{id}", { method: "DELETE", pathParams: { id } }),
onSuccess: inv,
});
}
export function useTestPiUpstream() {
const inv = useInvalidatePi();
return useMutation({
mutationFn: ({ id, body }: { id: string; body: PiUpstreamTestRequest }) =>
apiData<PiUpstreamTestResult>("/pi/upstreams/{id}/test", { method: "POST", pathParams: { id }, body }),
onSettled: inv,
});
}
export function useStartPiOAuth() {
return useMutation({
mutationFn: ({ id, body }: { id: string; body: PiOAuthStartRequest }) =>
apiData<PiOAuthStartResult>("/pi/upstreams/{id}/oauth/start", { method: "POST", pathParams: { id }, body }),
});
}
/** 完成 / 轮询一次登录(device 模式前端反复调用直到终态) */
export function completePiOAuth(id: string, body: PiOAuthCompleteRequest, signal?: AbortSignal) {
return apiData<PiOAuthCompleteResult>("/pi/upstreams/{id}/oauth/complete", {
method: "POST",
pathParams: { id },
body,
signal,
});
}
export function useLogoutPiOAuth() {
const inv = useInvalidatePi();
return useMutation({
mutationFn: (id: string) =>
apiData<PiUpstream>("/pi/upstreams/{id}/oauth/logout", { method: "POST", pathParams: { id }, body: {} }),
onSuccess: inv,
});
}
export { useInvalidatePi };
@@ -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>
);
}
@@ -1,15 +1,11 @@
import { type ApiKey, ApiKeyCreate, type ApiScope, SiteCredentialsPut } from "@umt/contracts";
import { Check, Copy, KeyRound, LoaderCircle, Plus } from "lucide-react";
import { type ApiKey, ApiKeyCreate, type ApiScope } from "@umt/contracts";
import { Check, Copy, LoaderCircle, Plus } from "lucide-react";
import { useState } from "react";
import { Link } from "react-router";
import { toast } from "sonner";
import { useApiKeys, useCreateApiKey, useRevokeApiKey } from "~/api/api-keys.ts";
import { usePutSiteCredentials, useSiteAccounts, useSiteOptions } from "~/api/instances.ts";
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
import { DataTable } from "~/components/console/data-table.tsx";
import { DetailSection } from "~/components/console/detail-sheet.tsx";
import { FormField, 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 {
@@ -24,177 +20,15 @@ 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 { FieldControl, type FormValues, str } from "./resource-section.tsx";
// ─── 站点账号凭证 ───
const AUTH_KINDS = [
{ value: "cookie", label: "Cookie" },
{ value: "passkey", label: "Passkey" },
{ value: "api_key", label: "API Key" },
{ value: "password", label: "账号密码" },
];
export function SiteCredentialsSection() {
const sites = useSiteOptions();
const accounts = useSiteAccounts();
const put = usePutSiteCredentials();
const [v, setV] = useState<FormValues>({
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 siteName = (id: string) => sites.data?.items.find((s) => s.id === id)?.name ?? id.slice(0, 8);
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: "" }));
} catch (err) {
toast.error(errorMessage(err, "保存失败"));
}
};
return (
<div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
<form
onSubmit={submit}
noValidate
className="pg-slab grid content-start gap-x-4 gap-y-1 rounded-xl p-5 sm:grid-cols-2"
>
<p className="mb-3 text-caption text-muted-foreground sm:col-span-2">
按「站点 + 标签」新建或替换账号凭证。凭证加密保存,提交后不会回显;站点本身在
<Link to={consolePath("sites")} className="mx-1 underline">
PT 站点工作台
</Link>
登记。
</p>
<FieldControl
spec={{
name: "siteId",
label: "站点",
kind: "select",
options: siteOpts,
placeholder: sites.isPending ? "加载中…" : siteOpts.length ? "请选择" : "还没有登记站点",
required: true,
}}
value={v.siteId}
error={errors.siteId}
onChange={(x) => setV((s) => ({ ...s, siteId: x }))}
disabled={put.isPending}
/>
<FieldControl
spec={{ name: "label", label: "标签", kind: "text", hint: "同一站点多个账号时区分" }}
value={v.label}
error={errors.label}
onChange={(x) => setV((s) => ({ ...s, label: x }))}
disabled={put.isPending}
/>
<FieldControl
spec={{ name: "authKind", label: "凭证类型", kind: "select", options: AUTH_KINDS }}
value={v.authKind}
error={errors.authKind}
onChange={(x) => setV((s) => ({ ...s, authKind: x }))}
disabled={put.isPending}
/>
{v.authKind === "password" ? (
<FieldControl
spec={{ name: "username", label: "登录名", kind: "text" }}
value={v.username}
error={errors.username}
onChange={(x) => setV((s) => ({ ...s, username: x }))}
disabled={put.isPending}
/>
) : null}
<FieldControl
spec={{
name: "credential",
label: v.authKind === "password" ? "密码" : "凭证",
kind: "password",
required: true,
wide: true,
placeholder: v.authKind === "cookie" ? "粘贴整段 Cookie" : undefined,
}}
value={v.credential}
error={errors.credential}
onChange={(x) => setV((s) => ({ ...s, credential: x }))}
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>
<DetailSection title="已录入的账号">
<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: "类型",
cell: (r) => AUTH_KINDS.find((k) => k.value === r.authKind)?.label ?? r.authKind,
},
{
key: "status",
header: "状态",
cell: (r) => (
<StatusBadge tone={!r.enabled ? "neutral" : r.secretRef ? "success" : "warning"}>
{!r.enabled ? "停用" : r.secretRef ? "已录入" : "缺凭证"}
</StatusBadge>
),
},
]}
/>
</DetailSection>
</div>
);
}
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: "全部管理权限" },
{ 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 }) {
@@ -204,7 +38,7 @@ function CreatedKeyDialog({ plaintext, onClose }: { plaintext: string | null; on
<DialogContent>
<DialogHeader>
<DialogTitle>API Key 已创建</DialogTitle>
<DialogDescription>明文只显示这一次,关闭后无法再查看。请现在复制保存。</DialogDescription>
<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>
@@ -218,7 +52,7 @@ function CreatedKeyDialog({ plaintext, onClose }: { plaintext: string | null; on
setCopied(true);
toast.success("已复制");
} catch {
toast.error("复制失败,请手动选中复制");
toast.error("复制失败,手动选中复制");
}
}}
>
@@ -249,8 +83,8 @@ export function ApiKeysSection() {
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 = "至少选择一个权限范围";
if (fe.name) fe.name = "名称必填,80 字以内";
if (fe.scopes) fe.scopes = "至少选一个权限范围";
setErrors(fe);
return;
}
@@ -267,16 +101,20 @@ export function ApiKeysSection() {
};
return (
<div>
<div className="mb-3 flex flex-wrap items-start justify-between gap-3">
<p className="max-w-2xl text-caption text-muted-foreground">
给外部程序(媒体服务器插件、脚本)用的密钥,按权限范围授权。控制台本身走登录会话,不需要 Key。
</p>
<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>
</div>
}
>
<ScopeLegend />
<DataTable
rows={keys.data?.items}
rowKey={(r) => r.id}
@@ -388,7 +226,7 @@ export function ApiKeysSection() {
open={!!revoking}
onOpenChange={(o) => !o && setRevoking(null)}
title={`撤销「${revoking?.name ?? ""}」?`}
description="撤销后使用这个 Key 的程序会立即失去访问权限,无法恢复。"
description="使用这个 Key 的程序立即失去访问权限,无法恢复。"
confirmLabel="撤销"
destructive
onConfirm={async () => {
@@ -402,6 +240,21 @@ export function ApiKeysSection() {
}
}}
/>
</div>
</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>
);
}
@@ -8,20 +8,19 @@ import {
type MediaServerInstance,
MediaServerInstanceCreate,
MediaServerInstanceUpdate,
type PathMapping,
PathMappingCreate,
PathMappingUpdate,
} from "@umt/contracts";
import { PlugZap } from "lucide-react";
import { toast } from "sonner";
import { downloaders, libraryRoots, mediaServers, pathMappings } from "~/api/instances.ts";
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 { type FormValues, nullStr, num, optStr, ResourceSection, str } from "./resource-section.tsx";
import { PathMappingsPanel } from "./path-mappings.tsx";
import { type FormValues, nullStr, optStr, ResourceSection, str } from "./resource-section.tsx";
import { SettingsBlock } from "./settings-block.tsx";
function Health({ status, at }: { status: string; at?: string | null }) {
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">
@@ -36,7 +35,7 @@ function Enabled({ on }: { on: boolean }) {
}
/** 行内"测试连接"按钮:结果 toast,health 写回后列表自动刷新 */
function TestButton({
export function TestButton({
run,
pending,
label = "测试连接",
@@ -66,9 +65,17 @@ function TestButton({
);
}
const credentialHint = { editHint: "留空表示不修改已保存的凭证" };
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();
@@ -80,8 +87,9 @@ export function DownloadersSection() {
config={{
noun: "下载器实例",
description:
"qBittorrent WebUI。系统只管理带管理标签的任务;共享同一缓存卷的多个实例填相同的缓存卷标识以合并水位。",
"只接管带管理标签的任务。多个实例共用一块缓存盘时填相同的缓存卷标识,水位合并计算。点行编辑实例和它的路径映射。",
emptyHint: "添加 qBittorrent 后才能提交下载。",
drawerExtra: (r) => <PathMappingsPanel viewKind="downloader" instanceId={r.id} />,
columns: [
{
key: "name",
@@ -105,7 +113,7 @@ export function DownloadersSection() {
],
rowActions: (r) => <TestButton pending={test.isPending} run={() => test.mutateAsync(r.id)} />,
fields: [
{ name: "name", label: "名称", kind: "text", required: true, placeholder: "家里的 qB" },
{ name: "name", label: "名称", kind: "text", required: true, placeholder: "例如:家里的 qB" },
{
name: "baseUrl",
label: "WebUI 地址",
@@ -113,7 +121,7 @@ export function DownloadersSection() {
required: true,
placeholder: "http://192.168.1.10:8080",
},
{ name: "username", label: "用户名", kind: "text", hint: "留空则把凭证当 WebUI API Key 使用" },
{ 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" },
@@ -155,6 +163,14 @@ export function DownloadersSection() {
}
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();
@@ -165,7 +181,8 @@ export function MediaServersSection() {
hooks={{ list, create, update, remove } as never}
config={{
noun: "媒体服务器",
description: "Emby / Jellyfin 只用于查询有无、季集与版本,查不到不等于缺失。",
description: "只用来查询作品有无、季集和版本;查不到不等于缺失。点行编辑实例和它的路径映射。",
drawerExtra: (r) => <PathMappingsPanel viewKind="media_server" instanceId={r.id} />,
columns: [
{
key: "name",
@@ -194,7 +211,7 @@ export function MediaServersSection() {
],
},
{ name: "baseUrl", label: "地址", kind: "url", required: true, placeholder: "http://192.168.1.10:8096" },
{ name: "userId", label: "用户 ID", kind: "text", hint: "可选;按该用户视角查询" },
{ name: "userId", label: "用户 ID", kind: "text", hint: "可选,按该用户的可见范围查询" },
{ name: "credential", label: "API Key", kind: "password", ...credentialHint },
{ name: "enabled", label: "启用", kind: "switch" },
],
@@ -230,7 +247,7 @@ const MEDIA_KIND = [
{ value: "other", label: "其它" },
];
export function LibraryRootsSection() {
export function LibraryRootsTable() {
const list = libraryRoots.useList();
const create = libraryRoots.useCreate();
const update = libraryRoots.useUpdate();
@@ -241,9 +258,8 @@ export function LibraryRootsSection() {
hooks={{ list, create, update, remove } as never}
config={{
noun: "库根",
description:
"登记或修改路径时会在目录里写入哨兵文件 .uhd-root,用来判断库根是否在线;目录不存在或不可写会被拒绝。",
deleteHint: "只删除登记,不会删除磁盘上的任何文件。",
description: "登记或改路径时会在目录里写哨兵文件 .uhd-root,据此判断库根是否在线;目录不存在或不可写时无法保存。",
deleteHint: "只删除登记,磁盘上的文件不动。",
columns: [
{
key: "name",
@@ -271,7 +287,7 @@ export function LibraryRootsSection() {
],
rowActions: (r) => <TestButton label="检查哨兵" pending={test.isPending} run={() => test.mutateAsync(r.id)} />,
fields: [
{ name: "name", label: "名称", kind: "text", required: true, placeholder: "电影库" },
{ name: "name", label: "名称", kind: "text", required: true, placeholder: "例如:电影库" },
{ name: "mediaKind", label: "内容类型", kind: "select", options: MEDIA_KIND },
{
name: "localPath",
@@ -279,7 +295,7 @@ export function LibraryRootsSection() {
kind: "text",
required: true,
placeholder: "/media/movies",
hint: "执行进程可访问的绝对路径",
hint: "服务进程能访问的绝对路径",
wide: true,
},
{
@@ -287,8 +303,8 @@ export function LibraryRootsSection() {
label: "管理方式",
kind: "select",
options: [
{ value: "register_only", label: "只登记(不自动维护)" },
{ value: "managed", label: "自动维护(补齐/升级)" },
{ value: "register_only", label: "只登记" },
{ value: "managed", label: "自动维护(补齐、升级)" },
],
},
{ name: "enabled", label: "启用", kind: "switch" },
@@ -324,150 +340,3 @@ export function LibraryRootsSection() {
/>
);
}
export function PathMappingsSection() {
const list = pathMappings.useList();
const create = pathMappings.useCreate();
const update = pathMappings.useUpdate();
const remove = pathMappings.useRemove();
const verify = pathMappings.useVerify();
const dls = downloaders.useList();
const mss = mediaServers.useList();
const roots = libraryRoots.useList();
const dlOpts = (dls.data?.items ?? []).map((d) => ({ value: d.id, label: d.name }));
const msOpts = (mss.data?.items ?? []).map((d) => ({ value: d.id, label: d.name }));
const rootOpts = [
{ value: "none", label: "不关联" },
...(roots.data?.items ?? []).map((d) => ({ value: d.id, label: d.name })),
];
const nameOf = (r: PathMapping) =>
r.viewKind === "downloader"
? (dls.data?.items.find((d) => d.id === r.downloaderInstanceId)?.name ?? "下载器")
: (mss.data?.items.find((d) => d.id === r.mediaServerInstanceId)?.name ?? "媒体服务器");
return (
<ResourceSection<PathMapping>
hooks={{ list, create, update, remove } as never}
config={{
noun: "路径映射",
description: "把下载器或媒体服务器看到的路径前缀换成本进程可访问的路径。前缀越长越优先,同长按优先级。",
columns: [
{
key: "map",
header: "映射",
cell: (r) => (
<div className="min-w-0 font-mono text-meta">
<p className="truncate">{r.remotePrefix}</p>
<p className="truncate text-muted-foreground">→ {r.localPrefix}</p>
</div>
),
},
{
key: "view",
header: "视图",
hideBelow: "md",
cell: (r) => (
<span className="text-caption">
{r.viewKind === "downloader" ? "下载器" : "媒体服务器"} · {nameOf(r)}
</span>
),
},
{ key: "prio", header: "优先级", hideBelow: "lg", cell: (r) => r.priority },
{ key: "verify", header: "校验", cell: (r) => <Health status={r.verifyStatus} at={r.verifiedAt} /> },
],
rowActions: (r) => (
<TestButton
label="校验"
pending={verify.isPending}
run={async () => {
const m = await verify.mutateAsync(r.id);
return { ok: m.verifyStatus === "ok", message: String(m.verifyDetail?.message ?? "本地前缀不可达") };
}}
/>
),
fields: [
{
name: "viewKind",
label: "路径视图",
kind: "select",
options: [
{ value: "downloader", label: "下载器看到的路径" },
{ value: "media_server", label: "媒体服务器看到的路径" },
],
},
{
name: "downloaderInstanceId",
label: "下载器实例",
kind: "select",
options: dlOpts,
placeholder: dlOpts.length ? "请选择" : "先添加下载器实例",
visible: (v) => v.viewKind === "downloader",
},
{
name: "mediaServerInstanceId",
label: "媒体服务器",
kind: "select",
options: msOpts,
placeholder: msOpts.length ? "请选择" : "先添加媒体服务器",
visible: (v) => v.viewKind === "media_server",
},
{
name: "remotePrefix",
label: "对方路径前缀",
kind: "text",
required: true,
placeholder: "/downloads",
wide: true,
},
{
name: "localPrefix",
label: "本地路径前缀",
kind: "text",
required: true,
placeholder: "/mnt/cache/downloads",
wide: true,
},
{ name: "libraryRootId", label: "关联库根", kind: "select", options: rootOpts },
{ name: "priority", label: "优先级", kind: "number", hint: "数字越大越优先" },
{ name: "enabled", label: "启用", kind: "switch" },
],
initial: {
viewKind: "downloader",
downloaderInstanceId: "",
mediaServerInstanceId: "",
remotePrefix: "",
localPrefix: "",
libraryRootId: "none",
priority: "0",
enabled: true,
},
fromRow: (r) => ({
viewKind: r.viewKind,
downloaderInstanceId: r.downloaderInstanceId ?? "",
mediaServerInstanceId: r.mediaServerInstanceId ?? "",
remotePrefix: r.remotePrefix,
localPrefix: r.localPrefix,
libraryRootId: r.libraryRootId ?? "none",
priority: String(r.priority),
enabled: r.enabled,
}),
toBody: (v) => {
const dl = v.viewKind === "downloader";
const root = str(v.libraryRootId);
return {
viewKind: str(v.viewKind),
downloaderInstanceId: dl ? nullStr(v.downloaderInstanceId) : null,
mediaServerInstanceId: dl ? null : nullStr(v.mediaServerInstanceId),
remotePrefix: str(v.remotePrefix),
localPrefix: str(v.localPrefix),
libraryRootId: root && root !== "none" ? root : null,
priority: num(v.priority) ?? 0,
enabled: v.enabled === true,
};
},
createSchema: PathMappingCreate,
updateSchema: PathMappingUpdate,
}}
/>
);
}
@@ -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>
);
}
@@ -6,23 +6,16 @@ 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 {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog.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;
@@ -64,6 +57,8 @@ export type ResourceConfig<Row extends { id: string }> = {
emptyHint?: string;
/** 删除确认的额外说明 */
deleteHint?: string;
/** 编辑抽屉里表单下方的附加内容(如该实例的路径映射) */
drawerExtra?: (row: Row) => ReactNode;
};
type Hooks<Row> = {
@@ -91,7 +86,12 @@ export function ResourceSection<Row extends { id: string }>({
header: <span className="sr-only">操作</span>,
align: "right",
cell: (row) => (
<div className="flex items-center justify-end gap-1">
// 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 />
@@ -130,9 +130,10 @@ export function ResourceSection<Row extends { id: string }>({
loading={list.isPending}
error={list.error}
onRetry={() => list.refetch()}
onRowClick={(r) => setEditing(r)}
empty={{ title: `还没有${config.noun}`, description: config.emptyHint }}
/>
<ResourceFormDialog
<ResourceFormDrawer
key={editing === "new" ? "new" : (editing?.id ?? "none")}
config={config}
hooks={hooks}
@@ -161,7 +162,7 @@ export function ResourceSection<Row extends { id: string }>({
);
}
function ResourceFormDialog<Row extends { id: string }>({
function ResourceFormDrawer<Row extends { id: string }>({
config,
hooks,
row,
@@ -205,42 +206,42 @@ function ResourceFormDialog<Row extends { id: string }>({
}
};
const formId = `resource-form-${config.noun}`;
return (
<Dialog open={!!row} onOpenChange={(o) => !o && !pending && onClose()}>
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-xl">
<DialogHeader>
<DialogTitle>
{mode === "create" ? "添加" : "编辑"}
{config.noun}
</DialogTitle>
<DialogDescription>凭证只写入、加密保存,任何页面都不会回显。</DialogDescription>
</DialogHeader>
<form id="resource-form" 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>
<DialogFooter>
<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="resource-form" disabled={pending}>
<Button type="submit" form={formId} disabled={pending}>
{pending ? <LoaderCircle className="animate-spin" /> : null}
保存
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
}
>
<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>
);
}
@@ -277,7 +278,7 @@ export function FieldControl({
control = (
<Select value={String(value ?? "")} onValueChange={onChange} disabled={disabled}>
<SelectTrigger id={id} className="w-full" aria-invalid={!!error}>
<SelectValue placeholder={spec.placeholder ?? "请选择"} />
<SelectValue placeholder={spec.placeholder ?? "选择"} />
</SelectTrigger>
<SelectContent position="popper">
{(spec.options ?? []).map((o) => (
@@ -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>
);
}
+108 -53
View File
@@ -1,64 +1,119 @@
import { Bot, FolderTree, HardDrive, KeyRound, Link2, Server, Tv, UserCog, Zap } from "lucide-react";
import { useSearchParams } from "react-router";
import { settingsSectionForLegacyTab } from "@umt/contracts";
import { useEffect } from "react";
import { Link, redirect, useLocation, useNavigate } from "react-router";
import { PageHeader } from "~/components/console/page-header.tsx";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs.tsx";
import {
DownloadersSection,
LibraryRootsSection,
MediaServersSection,
PathMappingsSection,
} from "./settings/instances-tab.tsx";
import { ApiKeysSection, SiteCredentialsSection } from "./settings/keys-tab.tsx";
import { AutomationTab, PiTab, StorageTab } from "./settings/prefs-tab.tsx";
import { parseSettingsHash, SECTIONS, settingsHash } from "~/components/settings/sections.tsx";
import { anchorId } from "~/components/settings/settings-block.tsx";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
import { cn } from "~/lib/utils.ts";
export const meta = () => [{ title: "系统设置 · UHD 媒体自动化控制台" }];
export const meta = () => [{ title: "设置 · UHD 媒体自动化控制台" }];
const TABS = [
{ value: "automation", label: "自动化", icon: Zap, render: () => <AutomationTab /> },
{ value: "downloaders", label: "下载器", icon: Server, render: () => <DownloadersSection /> },
{ value: "media-servers", label: "媒体服务器", icon: Tv, render: () => <MediaServersSection /> },
{ value: "library-roots", label: "库根", icon: FolderTree, render: () => <LibraryRootsSection /> },
{ value: "path-mappings", label: "路径映射", icon: Link2, render: () => <PathMappingsSection /> },
{ value: "site-credentials", label: "站点凭证", icon: UserCog, render: () => <SiteCredentialsSection /> },
{ value: "api-keys", label: "API Key", icon: KeyRound, render: () => <ApiKeysSection /> },
{ value: "pi", label: "Pi 上游", icon: Bot, render: () => <PiTab /> },
{ value: "storage", label: "缓存与入库", icon: HardDrive, render: () => <StorageTab /> },
] as const;
/** 旧链接 `?tab=xxx` → `#分区[/节]`(console-ia.md §3.3) */
export function clientLoader({ request }: { request: Request }) {
const url = new URL(request.url);
const tab = url.searchParams.get("tab");
if (!tab) return null;
const hit = settingsSectionForLegacyTab(tab);
url.searchParams.delete("tab");
const rest = url.searchParams.toString();
return redirect(`${url.pathname}${rest ? `?${rest}` : ""}${hit ? settingsHash(hit.section, hit.anchor) : ""}`);
}
export default function SettingsPage() {
const [params, setParams] = useSearchParams();
const raw = params.get("tab");
const tab = TABS.some((t) => t.value === raw) ? (raw as string) : "automation";
const location = useLocation();
const navigate = useNavigate();
const { section, anchor } = parseSettingsHash(location.hash);
// 切分区回到顶部;带节锚点时滚到该节(内容可能还在加载,稍后再试一次)
useEffect(() => {
if (!anchor) {
window.scrollTo({ top: 0 });
return;
}
let tries = 0;
const go = () => {
const el = document.getElementById(anchorId(section.id, anchor));
if (el) el.scrollIntoView({ behavior: "smooth", block: "start" });
else if (tries++ < 10) timer = window.setTimeout(go, 150);
};
let timer = window.setTimeout(go, 0);
return () => window.clearTimeout(timer);
}, [section.id, anchor]);
return (
<>
<PageHeader title="系统设置" description="实例、路径、凭证、密钥、模型上游与自动化开关。凭证一律只写,不回显。" />
<Tabs
value={tab}
onValueChange={(v) =>
setParams(
(p) => {
p.set("tab", v);
return p;
},
{ replace: true },
)
}
>
<TabsList aria-label="设置分节">
{TABS.map((t) => (
<TabsTrigger key={t.value} value={t.value}>
<t.icon />
{t.label}
</TabsTrigger>
))}
</TabsList>
{TABS.map((t) => (
<TabsContent key={t.value} value={t.value}>
{tab === t.value ? t.render() : null}
</TabsContent>
))}
</Tabs>
<PageHeader title="设置" description="全局参数、接入的实例与凭证。凭证只写入,保存后不回显。" />
<div className="grid gap-6 lg:grid-cols-[13.5rem_minmax(0,1fr)] lg:gap-8">
{/* 窄屏:顶部下拉 */}
<div className="lg:hidden">
<Select value={section.id} onValueChange={(id) => navigate({ hash: settingsHash(id) })}>
<SelectTrigger className="w-full" aria-label="设置分区">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
{SECTIONS.map((s) => (
<SelectItem key={s.id} value={s.id}>
<s.icon />
{s.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="mt-2 text-caption text-muted-foreground">{section.description}</p>
</div>
{/* 宽屏:左侧分区导航 */}
<nav aria-label="设置分区" className="hidden lg:block">
<ul className="sticky top-20 grid gap-0.5">
{SECTIONS.map((s) => {
const active = s.id === section.id;
return (
<li key={s.id}>
<Link
to={{ hash: settingsHash(s.id) }}
aria-current={active ? "page" : undefined}
className={cn(
"flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm transition-colors duration-micro",
active
? "bg-foreground/[0.07] font-medium text-foreground"
: "text-muted-foreground hover:bg-foreground/[0.04] hover:text-foreground",
)}
>
<s.icon className="size-4 shrink-0" aria-hidden />
{s.label}
</Link>
{active && s.anchors.length > 1 ? (
<ul className="mt-0.5 mb-1.5 ml-[1.375rem] grid gap-0.5 border-l border-border/60 pl-3">
{s.anchors.map((a) => (
<li key={a.id}>
<Link
to={{ hash: settingsHash(s.id, a.id) }}
className={cn(
"block rounded-md px-2 py-1 text-caption transition-colors duration-micro hover:text-foreground",
anchor === a.id ? "text-foreground" : "text-muted-foreground",
)}
>
{a.label}
</Link>
</li>
))}
</ul>
) : null}
</li>
);
})}
</ul>
</nav>
<div className="min-w-0">
<div className="mb-6 hidden lg:block">
<h2 className="flex items-center gap-2 text-title font-semibold text-foreground">{section.label}</h2>
<p className="mt-0.5 text-caption text-muted-foreground">{section.description}</p>
</div>
<div key={section.id}>{section.render()}</div>
</div>
</div>
</>
);
}
@@ -1,494 +0,0 @@
import {
type GlobalSettings,
GlobalSettings as GlobalSettingsSchema,
type GlobalSettingsUpdate,
mergeSettings,
} from "@umt/contracts";
import { LoaderCircle, Pause, Play, ShieldAlert } 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 { 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 { cn } from "~/lib/utils.ts";
import { FieldControl, type FieldSpec, type FormValues, num, str } from "./resource-section.tsx";
// 全局设置表单:每张卡片一组字段,保存时 PATCH 对应分组;
// 校验 = 合并现值后用 contracts GlobalSettings 整体校验(含"恢复水位 < 硬阈值"等交叉规则)。
type CardSpec = {
title: string;
description?: ReactNode;
fields: FieldSpec[];
fromSettings: (s: GlobalSettings) => FormValues;
toPatch: (v: FormValues, s: GlobalSettings) => GlobalSettingsUpdate;
extra?: (s: GlobalSettings) => ReactNode;
};
function SettingsCard({ spec, settings }: { spec: CardSpec; settings: GlobalSettings }) {
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 (
<form onSubmit={save} noValidate className="pg-slab min-w-0 rounded-xl p-5">
<h3 className="text-body font-semibold text-foreground">{spec.title}</h3>
{spec.description ? (
<p className="mt-0.5 mb-4 text-caption text-muted-foreground">{spec.description}</p>
) : (
<div className="mb-4" />
)}
<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>
);
}
function useSettingsGate(render: (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 render(q.data);
}
const n = (v: number | null | undefined) => (v === null || v === undefined ? "" : String(v));
// ─── Pi 上游 ───
const THINKING = ["off", "minimal", "low", "medium", "high", "xhigh"].map((v) => ({ value: v, label: v }));
const PI_CARD: CardSpec = {
title: "Pi 上游与模型",
description: "Agent 使用的模型上游。密钥只写入、加密保存,不回显。",
fields: [
{
name: "pi.provider",
label: "Provider",
kind: "select",
required: true,
options: [
{ value: "anthropic", label: "anthropic" },
{ value: "openai-compatible", label: "openai-compatible(baseUrl 带 /v1)" },
{ value: "fake", label: "fake(脚本化假模型,不联网,演示用)" },
],
},
{ name: "pi.model", label: "模型", kind: "text", placeholder: "留空用 provider 默认" },
{ name: "pi.baseUrl", label: "Base URL", kind: "url", placeholder: "留空用官方地址", wide: true },
{ name: "pi.apiKey", label: "API Key", kind: "password", hint: "留空表示不修改", wide: true },
{ name: "pi.thinkingLevel", label: "思考档位", kind: "select", options: THINKING },
{ name: "pi.contextBudget", label: "上下文预算(token)", kind: "number", hint: "4096 – 2000000" },
{ 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,
},
{ name: "pi.subagentsEnabled", label: "允许子代理", kind: "switch" },
],
fromSettings: (s) => ({
"pi.provider": s.pi.provider,
"pi.model": s.pi.model ?? "",
"pi.baseUrl": s.pi.baseUrl ?? "",
"pi.apiKey": "",
"pi.thinkingLevel": s.pi.thinkingLevel,
"pi.contextBudget": n(s.pi.contextBudget),
"pi.compaction.enabled": s.pi.compaction.enabled,
"pi.compaction.thresholdRatio": n(s.pi.compaction.thresholdRatio),
"pi.subagentsEnabled": s.pi.subagentsEnabled,
}),
toPatch: (v) => ({
pi: {
provider: str(v["pi.provider"]),
model: str(v["pi.model"]) || null,
baseUrl: str(v["pi.baseUrl"]) || null,
...(str(v["pi.apiKey"]) ? { apiKey: str(v["pi.apiKey"]) } : {}),
thinkingLevel: str(v["pi.thinkingLevel"]) as GlobalSettings["pi"]["thinkingLevel"],
contextBudget: num(v["pi.contextBudget"]),
compaction: {
enabled: v["pi.compaction.enabled"] === true,
thresholdRatio: num(v["pi.compaction.thresholdRatio"]),
},
subagentsEnabled: v["pi.subagentsEnabled"] === true,
},
}),
extra: (s) => <PiKeyStatus set={!!s.pi.apiKeySecretRef} />,
};
function PiKeyStatus({ set }: { set: boolean }) {
const update = useUpdateSettings();
return (
<div className="mb-2 flex flex-wrap items-center gap-2 text-meta text-muted-foreground">
<StatusBadge tone={set ? "success" : "warning"}>{set ? "上游密钥已保存" : "未设置上游密钥"}</StatusBadge>
{set ? (
<Button
type="button"
variant="ghost"
size="xs"
className="hover:text-destructive"
disabled={update.isPending}
onClick={async () => {
try {
await update.mutateAsync({ pi: { apiKey: null } });
toast.success("已清除上游密钥");
} catch (err) {
toast.error(errorMessage(err, "清除失败"));
}
}}
>
清除密钥
</Button>
) : null}
</div>
);
}
const AGENT_BUDGET_CARD: CardSpec = {
title: "Agent 预算",
description: "超过预算的 Agent 运行会停在「超预算」状态,等你放行。",
fields: [
{ name: "budget.agentMaxRunsPerDay", label: "每日最多运行次数", kind: "number" },
{ name: "budget.agentMaxCostUsdPerDay", label: "每日费用上限(USD)", kind: "number" },
],
fromSettings: (s) => ({
"budget.agentMaxRunsPerDay": n(s.budget.agentMaxRunsPerDay),
"budget.agentMaxCostUsdPerDay": n(s.budget.agentMaxCostUsdPerDay),
}),
toPatch: (v) => ({
budget: {
agentMaxRunsPerDay: num(v["budget.agentMaxRunsPerDay"]),
agentMaxCostUsdPerDay: num(v["budget.agentMaxCostUsdPerDay"]),
},
}),
};
const AGENT_RUNTIME_CARD: CardSpec = {
title: "Agent 运行",
description:
"单次运行预算、后台自动调查(只读调查或授权内恢复);provider 选 fake 时按下面的脚本回放工具调用(留空用内置演示脚本)。",
fields: [
{ name: "agent.maxToolCalls", label: "单次工具调用上限", kind: "number", hint: "1 – 500" },
{ name: "agent.maxDurationSec", label: "单次时长上限(秒)", kind: "number" },
{ name: "agent.autoInvestigate", label: "待处理事项自动调查", kind: "switch" },
{
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",
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.goalPollSec", label: "目标落定轮询(秒)", kind: "number" },
{ name: "agent.goalWaitHours", label: "目标最长等待(小时)", kind: "number" },
{
name: "agent.fakeScript",
label: "fake 脚本(JSON)",
kind: "textarea",
hint: '{"match":"收进来","steps":[{"toolCalls":[{"capability":"inventory.query","input":{"query":"沙丘2"}}]}],"finalText":"…"},格式见 docs/runbook/agent.md',
},
{
name: "agent.sessionDir",
label: "会话目录",
kind: "text",
wide: true,
placeholder: "留空用部署默认:DATA_DIR/agent-sessions(容器内 /data/agent-sessions)",
hint: "绝对路径,api 与 worker 进程都要可读写;有运行中的会话时不要改(续跑会找不到旧会话文件)",
},
],
fromSettings: (s) => ({
"agent.maxToolCalls": n(s.agent.maxToolCalls),
"agent.maxDurationSec": n(s.agent.maxDurationSec),
"agent.autoInvestigate": s.agent.autoInvestigate,
"agent.autoInvestigateMode": s.agent.autoInvestigateMode,
"agent.investigateMaxToolCalls": n(s.agent.investigateMaxToolCalls),
"agent.investigateMaxAttempts": n(s.agent.investigateMaxAttempts),
"agent.goalPollSec": n(s.agent.goalPollSec),
"agent.goalWaitHours": n(s.agent.goalWaitHours),
"agent.fakeScript": s.agent.fakeScript ?? "",
"agent.sessionDir": s.agent.sessionDir ?? "",
}),
toPatch: (v) => ({
agent: {
maxToolCalls: num(v["agent.maxToolCalls"]),
maxDurationSec: num(v["agent.maxDurationSec"]),
autoInvestigate: v["agent.autoInvestigate"] === true,
autoInvestigateMode: v["agent.autoInvestigateMode"] === "recover" ? "recover" : "investigate",
investigateMaxToolCalls: num(v["agent.investigateMaxToolCalls"]),
investigateMaxAttempts: num(v["agent.investigateMaxAttempts"]),
goalPollSec: num(v["agent.goalPollSec"]),
goalWaitHours: num(v["agent.goalWaitHours"]),
fakeScript: str(v["agent.fakeScript"]).trim() || null,
sessionDir: str(v["agent.sessionDir"]).trim() || null,
},
}),
};
export function PiTab() {
return useSettingsGate((s) => (
<div className="grid gap-4 xl:grid-cols-[minmax(0,3fr)_minmax(0,2fr)]">
<SettingsCard spec={PI_CARD} settings={s} />
<div className="grid content-start gap-4">
<SettingsCard spec={AGENT_BUDGET_CARD} settings={s} />
<SettingsCard spec={AGENT_RUNTIME_CARD} settings={s} />
</div>
</div>
));
}
// ─── 缓存 / 入库 / 保留 / 下载预算 ───
const GiB = 1024 ** 3;
const CACHE_CARD: CardSpec = {
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 ORGANIZE_CARD: CardSpec = {
title: "入库方式与保留",
description: "默认 Copy 入库,下载源在缓存里保留到做种期满;跨卷 Move 走 Copy→核验→删源。",
fields: [
{
name: "organize.defaultMode",
label: "默认入库方式",
kind: "select",
options: [
{ value: "copy", label: "Copy(保留源文件做种)" },
{ value: "move", label: "Move(移走源文件)" },
],
},
{ name: "organize.allowCrossVolumeMove", label: "允许跨卷 Move", kind: "switch" },
{
name: "retention.seedRetentionDays",
label: "源文件保留天数",
kind: "number",
hint: "Copy 入库后至少保留;0 – 365",
},
{ name: "retention.logRetentionDays", label: "日志保留天数", kind: "number", hint: "观察与日志类数据" },
],
fromSettings: (s) => ({
"organize.defaultMode": s.organize.defaultMode,
"organize.allowCrossVolumeMove": s.organize.allowCrossVolumeMove,
"retention.seedRetentionDays": n(s.retention.seedRetentionDays),
"retention.logRetentionDays": n(s.retention.logRetentionDays),
}),
toPatch: (v) => ({
organize: {
defaultMode: str(v["organize.defaultMode"]) as "copy" | "move",
allowCrossVolumeMove: v["organize.allowCrossVolumeMove"] === true,
},
retention: {
seedRetentionDays: num(v["retention.seedRetentionDays"]),
logRetentionDays: num(v["retention.logRetentionDays"]),
},
}),
};
const DOWNLOAD_BUDGET_CARD: CardSpec = {
title: "下载预算",
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"]),
},
};
},
};
export function StorageTab() {
return useSettingsGate((s) => (
<div className="grid gap-4 lg:grid-cols-2">
<SettingsCard spec={CACHE_CARD} settings={s} />
<SettingsCard spec={ORGANIZE_CARD} settings={s} />
<SettingsCard spec={DOWNLOAD_BUDGET_CARD} settings={s} />
</div>
));
}
// ─── 自动化开关 ───
function ToggleRow({
icon,
title,
description,
checked,
onChange,
disabled,
tone,
}: {
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">
<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={`tg-${title}`} 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={`tg-${title}`} checked={checked} onCheckedChange={onChange} disabled={disabled} aria-label={title} />
</div>
);
}
export function AutomationTab() {
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 useSettingsGate((s) => (
<div className="pg-slab divide-y divide-border/60 rounded-xl px-5">
<ToggleRow
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
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>
));
}
+19
View File
@@ -269,3 +269,22 @@
- `components/agent/agent-conversation.tsx` 仍链到 `/console/tasks/:下载任务ID`(W7-B 范围),靠 `tasks.$id.tsx` 的兜底跳转可用,改成 `/console/downloads/:id` 可省一次 404 探测。
- `components/agent/quick-goals.ts` 里的"待识别池"文案(W7-B 范围)。
- 原站视图仍是占位。
## 6. 设置分区已实施对照(IA-2,2026-09-28)
路由保持 `/console/settings`(routes.ts 归 IA-1),分区用 URL hash 定位:`#<分区>` 或 `#<分区>/<节>`;旧 `?tab=` 由设置页 `clientLoader` 跳到对应 hash。
分区定义在 `packages/contracts/src/settings.ts` 的 `SETTINGS_SECTIONS`(label、说明、锚点、字段、`legacyTabs`),测试断言 8 个存储 key 的每个字段恰好归属一个分区。组件在 `apps/web/app/components/settings/`。
| 分区 | 节(锚点) | 实际内容 | 与 §3.4 的差异 |
| --- | --- | --- | --- |
| 自动化 `automation` | switches / cache / budget / retention | 总开关、只读对账(切换即生效);缓存水位 4 项;每日新作品数、下载量、并发;源文件保留天数、日志保留天数 | 无 |
| 站点与账号 `sites` | sites / accounts | 站点列表(启停开关、快照周期与新鲜度、账号数、健康);抽屉改地址、定时快照、快照周期、限流三项,并录入本站凭证;从内置站点定义登记新站点;账号列表(启停、删除)+ 录入凭证抽屉 | 浏览器服务能力未放进来(无对应写接口) |
| 下载器 `downloaders` | instances | qB 实例 CRUD + 连通测试;路径映射并入实例抽屉(只列本实例的映射,可加、校验、删) | §3.4 把映射放在"媒体库与目录",按 IA-2 任务改为挂在实例抽屉;`?tab=path-mappings` → `#downloaders` |
| 媒体库与目录 `library` | roots / organize | 库根 CRUD + 哨兵检查;默认整理方式(复制/移动)、允许跨卷移动 | 没有命名模板字段,"整理默认"只有这两项 |
| 媒体服务器 `media-servers` | instances | Emby/Jellyfin CRUD + 测试;抽屉里同样挂本实例的路径映射 | 无 |
| 元数据 `metadata` | tmdb | TMDB API Key(只写,显示已保存/清除)、Base URL | 语言、刷新周期在 contracts 里没有字段,未做 |
| Agent `agent` | upstream / runtime / budget / investigate / advanced | 上游列表(名称、provider、认证、模型、状态、默认)+ 新建向导(选 provider → 凭据与模型 → 登录/测试/设默认)+ 编辑抽屉(改配置、登录/重新登录/退出、测试、设默认、删除);上下文预算、压缩、子代理;每日与单次预算;自动调查;高级(轮询、等待、会话目录、fake 脚本) | 多上游已由 W7-A 落地,§3.4 说明里"多上游等 Agent 侧需求"作废;旧 `pi.provider/model/baseUrl/apiKey` 字段不再展示 |
| API Key `api-keys` | keys | 列表、创建(明文只显示一次)、撤销、scope 说明 | 无 |
| 账户与安全 `account` | password / totp / session / shortcuts | 改密、TOTP 开启/关闭、当前会话到期与退出、本机 Token、Agent 快捷键 | 后端没有会话列表/吊销接口,只能看当前会话;改密会注销其它会话 |
每节独立保存:全局设置类的节有"未保存"标记、保存/还原按钮,保存后 sonner 一句;实例、站点、上游类在抽屉里保存。窄屏左栏折叠为顶部下拉。
+184
View File
@@ -289,3 +289,187 @@ export const SettingEntry = z.object({
updatedAt: Timestamp,
});
export type SettingEntry = z.infer<typeof SettingEntry>;
// ── 设置页分区(docs/design/console-ia.md §3.4) ──
// 只是"展示分区 → 字段"的映射,不改存储 key;每个字段恰好归属一个分区(测试断言)。
// 非 settings 表的对象(实例、站点、API Key、Pi 上游、账户)用 resources 标注,不进 fields。
export const SettingsSectionId = z.enum([
"automation",
"sites",
"downloaders",
"library",
"media-servers",
"metadata",
"agent",
"api-keys",
"account",
]);
export type SettingsSectionId = z.infer<typeof SettingsSectionId>;
export type SettingsSectionDef = {
id: SettingsSectionId;
label: string;
description: string;
/** 旧 `?tab=` 值 → 本分区(可带分区内锚点) */
legacyTabs: { tab: string; anchor?: string }[];
/** 分区内锚点(节),顺序即页面顺序 */
anchors: { id: string; label: string }[];
/** 归属本分区的 settings 字段(`key.field`,compaction 这类嵌套对象按整体算) */
fields: string[];
/** 本分区管理的非 settings 对象 */
resources: string[];
};
export const SETTINGS_SECTIONS: readonly SettingsSectionDef[] = [
{
id: "automation",
label: "自动化",
description: "总开关、缓存水位、每日预算、保留天数",
legacyTabs: [{ tab: "automation" }, { tab: "storage", anchor: "cache" }],
anchors: [
{ id: "switches", label: "开关" },
{ id: "cache", label: "缓存水位" },
{ id: "budget", label: "每日预算" },
{ id: "retention", label: "保留" },
],
fields: [
"automation.enabled",
"automation.readOnlyReconcile",
"cache.hardThresholdPercent",
"cache.resumePercent",
"cache.checkIntervalSec",
"cache.cleanupTargetPercent",
"budget.maxNewTitlesPerDay",
"budget.maxDownloadBytesPerDay",
"budget.maxConcurrentDownloads",
"retention.seedRetentionDays",
"retention.logRetentionDays",
],
resources: [],
},
{
id: "sites",
label: "站点与账号",
description: "站点启停、快照周期、限流、账号凭证",
legacyTabs: [{ tab: "site-credentials" }],
anchors: [
{ id: "sites", label: "站点" },
{ id: "accounts", label: "站点账号" },
],
fields: [],
resources: ["sites", "site-accounts"],
},
{
id: "downloaders",
label: "下载器",
description: "qBittorrent 实例、连通测试、路径映射",
legacyTabs: [{ tab: "downloaders" }, { tab: "path-mappings" }],
anchors: [{ id: "instances", label: "实例" }],
fields: [],
resources: ["downloader-instances", "path-mappings"],
},
{
id: "library",
label: "媒体库与目录",
description: "库根、哨兵状态、默认整理方式",
legacyTabs: [{ tab: "library-roots", anchor: "roots" }],
anchors: [
{ id: "roots", label: "库根" },
{ id: "organize", label: "整理方式" },
],
fields: ["organize.defaultMode", "organize.allowCrossVolumeMove"],
resources: ["library-roots"],
},
{
id: "media-servers",
label: "媒体服务器",
description: "Emby / Jellyfin 实例、连通测试、路径映射",
legacyTabs: [{ tab: "media-servers" }],
anchors: [{ id: "instances", label: "实例" }],
fields: [],
resources: ["media-server-instances", "path-mappings"],
},
{
id: "metadata",
label: "元数据",
description: "TMDB API Key 与地址",
legacyTabs: [],
anchors: [{ id: "tmdb", label: "TMDB" }],
fields: ["metadata.tmdbApiKeySecretRef", "metadata.tmdbBaseUrl"],
resources: [],
},
{
id: "agent",
label: "Agent",
description: "Agent 上游、运行预算、自动调查",
legacyTabs: [{ tab: "pi" }],
anchors: [
{ id: "upstream", label: "上游" },
{ id: "runtime", label: "上下文与子代理" },
{ id: "budget", label: "预算" },
{ id: "investigate", label: "自动调查" },
{ id: "advanced", label: "高级" },
],
fields: [
"pi.defaultUpstreamId",
"pi.provider",
"pi.baseUrl",
"pi.model",
"pi.thinkingLevel",
"pi.contextBudget",
"pi.compaction",
"pi.subagentsEnabled",
"pi.subagents",
"pi.apiKeySecretRef",
"budget.agentMaxRunsPerDay",
"budget.agentMaxCostUsdPerDay",
"agent.autoInvestigate",
"agent.autoInvestigateMode",
"agent.investigateMaxAttempts",
"agent.recoverVerifySec",
"agent.maxToolCalls",
"agent.maxDurationSec",
"agent.maxTokensPerRun",
"agent.maxCostUsdPerRun",
"agent.investigateMaxToolCalls",
"agent.goalPollSec",
"agent.goalWaitHours",
"agent.fakeScript",
"agent.sessionDir",
],
resources: ["pi-upstreams"],
},
{
id: "api-keys",
label: "API Key",
description: "签发给外部程序的 Key",
legacyTabs: [{ tab: "api-keys" }],
anchors: [{ id: "keys", label: "API Key" }],
fields: [],
resources: ["api-keys"],
},
{
id: "account",
label: "账户与安全",
description: "密码、二次验证、会话、快捷键",
legacyTabs: [],
anchors: [
{ id: "password", label: "密码" },
{ id: "totp", label: "二次验证" },
{ id: "session", label: "会话" },
{ id: "shortcuts", label: "快捷键" },
],
fields: [],
resources: ["auth"],
},
];
/** 旧 `?tab=` → 分区与锚点;不认识的返回 null */
export function settingsSectionForLegacyTab(tab: string): { section: SettingsSectionId; anchor?: string } | null {
for (const s of SETTINGS_SECTIONS) {
const hit = s.legacyTabs.find((t) => t.tab === tab);
if (hit) return { section: s.id, anchor: hit.anchor };
}
return null;
}
@@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import {
DEFAULT_SETTINGS,
SETTINGS_SECTIONS,
SettingsSectionId,
settingsSectionForLegacyTab,
} from "../src/settings.ts";
describe("SETTINGS_SECTIONS", () => {
it("9 个分区,顺序与 SettingsSectionId 一致", () => {
expect(SETTINGS_SECTIONS.map((s) => s.id)).toEqual(SettingsSectionId.options);
});
it("8 个存储 key 的每个字段恰好归属一个分区", () => {
const all = Object.entries(DEFAULT_SETTINGS).flatMap(([key, v]) => Object.keys(v).map((f) => `${key}.${f}`));
const owned = SETTINGS_SECTIONS.flatMap((s) => s.fields);
expect(new Set(owned).size).toBe(owned.length);
expect([...owned].sort()).toEqual([...all].sort());
});
it("旧 ?tab= 值都能落到分区", () => {
for (const tab of [
"automation",
"downloaders",
"media-servers",
"library-roots",
"path-mappings",
"site-credentials",
"api-keys",
"pi",
"storage",
]) {
expect(settingsSectionForLegacyTab(tab), tab).not.toBeNull();
}
expect(settingsSectionForLegacyTab("storage")).toEqual({ section: "automation", anchor: "cache" });
expect(settingsSectionForLegacyTab("path-mappings")).toEqual({ section: "downloaders", anchor: undefined });
expect(settingsSectionForLegacyTab("nope")).toBeNull();
});
});