feat(web): settings with 9 sidebar sections and Agent upstream management
- /console/settings#<section>[/<anchor>]; legacy ?tab= redirected in clientLoader - SETTINGS_SECTIONS in contracts with field-ownership test - Agent section: upstream list, create wizard (provider -> credentials/model -> login/test/default), edit drawer with OAuth browser/device login, test, set default, delete - sites section with site registration, snapshot/rate-limit drawer and account credentials - path mappings moved into downloader / media-server drawers; metadata and account sections Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
459991421e
commit
c67f6a9682
25 files changed
+3842
-942
No files matched your search
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+38
-185
@@ -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>
|
||||
);
|
||||
}
|
||||
+37
-168
@@ -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>
|
||||
);
|
||||
}
|
||||
+43
-42
@@ -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));
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
));
|
||||
}
|
||||
@@ -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 一句;实例、站点、上游类在抽屉里保存。窄屏左栏折叠为顶部下拉。
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user