feat(web): W3-1 login, session shell, home dashboard, settings, engine panel and audit pages
- /login: setup/login/TOTP states driven by server error codes, fixed-height error slot - console layout: GET /auth/session gate, redirect to /login, account popover (change password, logout) - home: six dashboard sections with SSE-driven refresh, automation switch and read-only reconcile toggle - settings tabs: downloaders, media servers, library roots, path mappings, site credentials, API keys (one-time plaintext), Pi upstream, cache/organize/retention/budget, automation - engine: runs list (status/name filters, cursor paging, stats tiles), run detail (step timeline, json payloads, signals, cancel) - audit: filterable list with detail sheet - vite: allow repo root in server.fs so deno-installed deps under node_modules/.deno load in dev Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
ef73abe3df
commit
0dec0854df
21 files changed
+3905
-57
No files matched your search
@@ -0,0 +1,473 @@
|
||||
import {
|
||||
type DownloaderInstance,
|
||||
DownloaderInstanceCreate,
|
||||
DownloaderInstanceUpdate,
|
||||
type LibraryRoot,
|
||||
LibraryRootCreate,
|
||||
LibraryRootUpdate,
|
||||
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 { 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";
|
||||
|
||||
function Health({ status, at }: { status: string; at?: string | null }) {
|
||||
const h = healthLabel(status);
|
||||
return (
|
||||
<div className="flex flex-col items-start gap-0.5">
|
||||
<StatusBadge tone={h.tone}>{h.label}</StatusBadge>
|
||||
{at ? <span className="text-meta text-muted-foreground">{formatRelative(at)}</span> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Enabled({ on }: { on: boolean }) {
|
||||
return <StatusBadge tone={on ? "success" : "neutral"}>{on ? "启用" : "停用"}</StatusBadge>;
|
||||
}
|
||||
|
||||
/** 行内"测试连接"按钮:结果 toast,health 写回后列表自动刷新 */
|
||||
function TestButton({
|
||||
run,
|
||||
pending,
|
||||
label = "测试连接",
|
||||
}: {
|
||||
run: () => Promise<{ ok: boolean; message: string | null; version?: string | null }>;
|
||||
pending: boolean;
|
||||
label?: string;
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={pending}
|
||||
onClick={async () => {
|
||||
try {
|
||||
const r = await run();
|
||||
if (r.ok) toast.success(`${label}成功${r.version ? ` · ${r.version}` : ""}`);
|
||||
else toast.error(r.message || `${label}失败`);
|
||||
} catch (err) {
|
||||
toast.error(errorMessage(err, `${label}失败`));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<PlugZap />
|
||||
<span className="hidden md:inline">{label}</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
const credentialHint = { editHint: "留空表示不修改已保存的凭证" };
|
||||
|
||||
export function DownloadersSection() {
|
||||
const list = downloaders.useList();
|
||||
const create = downloaders.useCreate();
|
||||
const update = downloaders.useUpdate();
|
||||
const remove = downloaders.useRemove();
|
||||
const test = downloaders.useTest();
|
||||
return (
|
||||
<ResourceSection<DownloaderInstance>
|
||||
hooks={{ list, create, update, remove } as never}
|
||||
config={{
|
||||
noun: "下载器实例",
|
||||
description:
|
||||
"qBittorrent WebUI。系统只管理带管理标签的任务;共享同一缓存卷的多个实例填相同的缓存卷标识以合并水位。",
|
||||
emptyHint: "添加 qBittorrent 后才能提交下载。",
|
||||
columns: [
|
||||
{
|
||||
key: "name",
|
||||
header: "名称",
|
||||
cell: (r) => (
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium">{r.name}</p>
|
||||
<p className="truncate text-meta text-muted-foreground">{r.baseUrl}</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "tag",
|
||||
header: "管理标签",
|
||||
hideBelow: "md",
|
||||
cell: (r) => <code className="text-meta">{r.managedTag}</code>,
|
||||
},
|
||||
{ key: "vol", header: "缓存卷", hideBelow: "lg", cell: (r) => r.cacheVolumeKey ?? "—" },
|
||||
{ key: "enabled", header: "状态", hideBelow: "sm", cell: (r) => <Enabled on={r.enabled} /> },
|
||||
{ key: "health", header: "健康", cell: (r) => <Health status={r.healthStatus} at={r.lastSeenAt} /> },
|
||||
],
|
||||
rowActions: (r) => <TestButton pending={test.isPending} run={() => test.mutateAsync(r.id)} />,
|
||||
fields: [
|
||||
{ name: "name", label: "名称", kind: "text", required: true, placeholder: "家里的 qB" },
|
||||
{
|
||||
name: "baseUrl",
|
||||
label: "WebUI 地址",
|
||||
kind: "url",
|
||||
required: true,
|
||||
placeholder: "http://192.168.1.10:8080",
|
||||
},
|
||||
{ name: "username", label: "用户名", kind: "text", hint: "留空则把凭证当 WebUI API Key 使用" },
|
||||
{ name: "credential", label: "密码 / API Key", kind: "password", ...credentialHint },
|
||||
{ name: "managedTag", label: "管理标签", kind: "text", placeholder: "umt", hint: "只接管带此标签的任务" },
|
||||
{ name: "cacheVolumeKey", label: "缓存卷标识", kind: "text", placeholder: "cache-1" },
|
||||
{ name: "enabled", label: "启用", kind: "switch" },
|
||||
],
|
||||
initial: {
|
||||
name: "",
|
||||
baseUrl: "",
|
||||
username: "",
|
||||
credential: "",
|
||||
managedTag: "umt",
|
||||
cacheVolumeKey: "",
|
||||
enabled: true,
|
||||
},
|
||||
fromRow: (r) => ({
|
||||
name: r.name,
|
||||
baseUrl: r.baseUrl,
|
||||
username: r.username ?? "",
|
||||
credential: "",
|
||||
managedTag: r.managedTag,
|
||||
cacheVolumeKey: r.cacheVolumeKey ?? "",
|
||||
enabled: r.enabled,
|
||||
}),
|
||||
toBody: (v: FormValues, mode) => ({
|
||||
name: str(v.name),
|
||||
kind: mode === "create" ? "qbittorrent" : undefined,
|
||||
baseUrl: str(v.baseUrl),
|
||||
username: nullStr(v.username),
|
||||
credential: optStr(v.credential),
|
||||
managedTag: optStr(v.managedTag),
|
||||
cacheVolumeKey: nullStr(v.cacheVolumeKey),
|
||||
enabled: v.enabled === true,
|
||||
}),
|
||||
createSchema: DownloaderInstanceCreate,
|
||||
updateSchema: DownloaderInstanceUpdate,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function MediaServersSection() {
|
||||
const list = mediaServers.useList();
|
||||
const create = mediaServers.useCreate();
|
||||
const update = mediaServers.useUpdate();
|
||||
const remove = mediaServers.useRemove();
|
||||
const test = mediaServers.useTest();
|
||||
return (
|
||||
<ResourceSection<MediaServerInstance>
|
||||
hooks={{ list, create, update, remove } as never}
|
||||
config={{
|
||||
noun: "媒体服务器",
|
||||
description: "Emby / Jellyfin 只用于查询有无、季集与版本,查不到不等于缺失。",
|
||||
columns: [
|
||||
{
|
||||
key: "name",
|
||||
header: "名称",
|
||||
cell: (r) => (
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium">{r.name}</p>
|
||||
<p className="truncate text-meta text-muted-foreground">{r.baseUrl}</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: "kind", header: "类型", hideBelow: "md", cell: (r) => (r.kind === "emby" ? "Emby" : "Jellyfin") },
|
||||
{ key: "enabled", header: "状态", hideBelow: "sm", cell: (r) => <Enabled on={r.enabled} /> },
|
||||
{ key: "health", header: "健康", cell: (r) => <Health status={r.healthStatus} at={r.lastSeenAt} /> },
|
||||
],
|
||||
rowActions: (r) => <TestButton pending={test.isPending} run={() => test.mutateAsync(r.id)} />,
|
||||
fields: [
|
||||
{ name: "name", label: "名称", kind: "text", required: true },
|
||||
{
|
||||
name: "kind",
|
||||
label: "类型",
|
||||
kind: "select",
|
||||
options: [
|
||||
{ value: "emby", label: "Emby" },
|
||||
{ value: "jellyfin", label: "Jellyfin" },
|
||||
],
|
||||
},
|
||||
{ name: "baseUrl", label: "地址", kind: "url", required: true, placeholder: "http://192.168.1.10:8096" },
|
||||
{ name: "userId", label: "用户 ID", kind: "text", hint: "可选;按该用户视角查询" },
|
||||
{ name: "credential", label: "API Key", kind: "password", ...credentialHint },
|
||||
{ name: "enabled", label: "启用", kind: "switch" },
|
||||
],
|
||||
initial: { name: "", kind: "emby", baseUrl: "", userId: "", credential: "", enabled: true },
|
||||
fromRow: (r) => ({
|
||||
name: r.name,
|
||||
kind: r.kind,
|
||||
baseUrl: r.baseUrl,
|
||||
userId: r.userId ?? "",
|
||||
credential: "",
|
||||
enabled: r.enabled,
|
||||
}),
|
||||
toBody: (v) => ({
|
||||
name: str(v.name),
|
||||
kind: str(v.kind),
|
||||
baseUrl: str(v.baseUrl),
|
||||
userId: nullStr(v.userId),
|
||||
credential: optStr(v.credential),
|
||||
enabled: v.enabled === true,
|
||||
}),
|
||||
createSchema: MediaServerInstanceCreate,
|
||||
updateSchema: MediaServerInstanceUpdate,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const MEDIA_KIND = [
|
||||
{ value: "movie", label: "电影" },
|
||||
{ value: "series", label: "剧集" },
|
||||
{ value: "variety", label: "综艺" },
|
||||
{ value: "mixed", label: "混合" },
|
||||
{ value: "other", label: "其它" },
|
||||
];
|
||||
|
||||
export function LibraryRootsSection() {
|
||||
const list = libraryRoots.useList();
|
||||
const create = libraryRoots.useCreate();
|
||||
const update = libraryRoots.useUpdate();
|
||||
const remove = libraryRoots.useRemove();
|
||||
const test = libraryRoots.useTest();
|
||||
return (
|
||||
<ResourceSection<LibraryRoot>
|
||||
hooks={{ list, create, update, remove } as never}
|
||||
config={{
|
||||
noun: "库根",
|
||||
description:
|
||||
"登记或修改路径时会在目录里写入哨兵文件 .uhd-root,用来判断库根是否在线;目录不存在或不可写会被拒绝。",
|
||||
deleteHint: "只删除登记,不会删除磁盘上的任何文件。",
|
||||
columns: [
|
||||
{
|
||||
key: "name",
|
||||
header: "名称",
|
||||
cell: (r) => (
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium">{r.name}</p>
|
||||
<p className="truncate font-mono text-meta text-muted-foreground">{r.localPath}</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "kind",
|
||||
header: "类型",
|
||||
hideBelow: "md",
|
||||
cell: (r) => MEDIA_KIND.find((k) => k.value === r.mediaKind)?.label ?? r.mediaKind,
|
||||
},
|
||||
{
|
||||
key: "mode",
|
||||
header: "管理方式",
|
||||
hideBelow: "lg",
|
||||
cell: (r) => (r.managementMode === "managed" ? "自动维护" : "只登记"),
|
||||
},
|
||||
{ key: "health", header: "在线", cell: (r) => <Health status={r.healthStatus} at={r.lastHealthAt} /> },
|
||||
],
|
||||
rowActions: (r) => <TestButton label="检查哨兵" pending={test.isPending} run={() => test.mutateAsync(r.id)} />,
|
||||
fields: [
|
||||
{ name: "name", label: "名称", kind: "text", required: true, placeholder: "电影库" },
|
||||
{ name: "mediaKind", label: "内容类型", kind: "select", options: MEDIA_KIND },
|
||||
{
|
||||
name: "localPath",
|
||||
label: "本地路径",
|
||||
kind: "text",
|
||||
required: true,
|
||||
placeholder: "/media/movies",
|
||||
hint: "执行进程可访问的绝对路径",
|
||||
wide: true,
|
||||
},
|
||||
{
|
||||
name: "managementMode",
|
||||
label: "管理方式",
|
||||
kind: "select",
|
||||
options: [
|
||||
{ value: "register_only", label: "只登记(不自动维护)" },
|
||||
{ value: "managed", label: "自动维护(补齐/升级)" },
|
||||
],
|
||||
},
|
||||
{ name: "enabled", label: "启用", kind: "switch" },
|
||||
{ name: "notes", label: "备注", kind: "textarea" },
|
||||
],
|
||||
initial: {
|
||||
name: "",
|
||||
mediaKind: "movie",
|
||||
localPath: "",
|
||||
managementMode: "register_only",
|
||||
enabled: true,
|
||||
notes: "",
|
||||
},
|
||||
fromRow: (r) => ({
|
||||
name: r.name,
|
||||
mediaKind: r.mediaKind,
|
||||
localPath: r.localPath,
|
||||
managementMode: r.managementMode,
|
||||
enabled: r.enabled,
|
||||
notes: r.notes ?? "",
|
||||
}),
|
||||
toBody: (v) => ({
|
||||
name: str(v.name),
|
||||
mediaKind: str(v.mediaKind),
|
||||
localPath: str(v.localPath),
|
||||
managementMode: str(v.managementMode),
|
||||
enabled: v.enabled === true,
|
||||
notes: nullStr(v.notes),
|
||||
}),
|
||||
createSchema: LibraryRootCreate,
|
||||
updateSchema: LibraryRootUpdate,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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,407 @@
|
||||
import { type ApiKey, ApiKeyCreate, type ApiScope, SiteCredentialsPut } from "@umt/contracts";
|
||||
import { Check, Copy, KeyRound, 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 {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "~/components/ui/dialog.tsx";
|
||||
import { Input } from "~/components/ui/input.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { formatRelative } from "~/lib/labels.ts";
|
||||
import { cn, formatDateTime } from "~/lib/utils.ts";
|
||||
import { 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>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── API Key ───
|
||||
|
||||
const SCOPES: { value: ApiScope; label: string; hint: string }[] = [
|
||||
{ value: "read", label: "read", hint: "只读查询" },
|
||||
{ value: "organize.read", label: "organize.read", hint: "读取整理记录(增量同步)" },
|
||||
{ value: "organize.execute", label: "organize.execute", hint: "触发整理" },
|
||||
{ value: "admin", label: "admin", hint: "全部管理权限" },
|
||||
];
|
||||
|
||||
function CreatedKeyDialog({ plaintext, onClose }: { plaintext: string | null; onClose: () => void }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
return (
|
||||
<Dialog open={!!plaintext} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>API Key 已创建</DialogTitle>
|
||||
<DialogDescription>明文只显示这一次,关闭后无法再查看。请现在复制保存。</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex min-w-0 items-center gap-2 rounded-lg border border-warning/40 bg-warning/10 p-3">
|
||||
<code className="min-w-0 flex-1 font-mono text-caption break-all text-foreground">{plaintext}</code>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
aria-label="复制"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(plaintext ?? "");
|
||||
setCopied(true);
|
||||
toast.success("已复制");
|
||||
} catch {
|
||||
toast.error("复制失败,请手动选中复制");
|
||||
}
|
||||
}}
|
||||
>
|
||||
{copied ? <Check /> : <Copy />}
|
||||
</Button>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button onClick={onClose}>我已保存</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function ApiKeysSection() {
|
||||
const keys = useApiKeys();
|
||||
const create = useCreateApiKey();
|
||||
const revoke = useRevokeApiKey();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [name, setName] = useState("");
|
||||
const [scopes, setScopes] = useState<ApiScope[]>(["read"]);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [plaintext, setPlaintext] = useState<string | null>(null);
|
||||
const [revoking, setRevoking] = useState<ApiKey | null>(null);
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const parsed = ApiKeyCreate.safeParse({ name: name.trim(), scopes });
|
||||
if (!parsed.success) {
|
||||
const fe = zodFieldErrors(parsed.error.issues);
|
||||
if (fe.name) fe.name = "请填写名称(80 字以内)";
|
||||
if (fe.scopes) fe.scopes = "至少选择一个权限范围";
|
||||
setErrors(fe);
|
||||
return;
|
||||
}
|
||||
setErrors({});
|
||||
try {
|
||||
const r = await create.mutateAsync(parsed.data);
|
||||
setOpen(false);
|
||||
setName("");
|
||||
setScopes(["read"]);
|
||||
setPlaintext(r.plaintextKey);
|
||||
} catch (err) {
|
||||
toast.error(errorMessage(err, "创建失败"));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<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>
|
||||
<Button size="sm" onClick={() => setOpen(true)}>
|
||||
<Plus />
|
||||
创建 API Key
|
||||
</Button>
|
||||
</div>
|
||||
<DataTable
|
||||
rows={keys.data?.items}
|
||||
rowKey={(r) => r.id}
|
||||
loading={keys.isPending}
|
||||
error={keys.error}
|
||||
onRetry={() => keys.refetch()}
|
||||
empty={{ title: "还没有 API Key" }}
|
||||
columns={[
|
||||
{
|
||||
key: "name",
|
||||
header: "名称",
|
||||
cell: (r) => (
|
||||
<div className="min-w-0">
|
||||
<p className={cn("font-medium", r.revokedAt && "text-muted-foreground line-through")}>{r.name}</p>
|
||||
<p className="text-meta text-muted-foreground">创建于 {formatDateTime(r.createdAt)}</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "scopes",
|
||||
header: "权限",
|
||||
hideBelow: "sm",
|
||||
cell: (r) => (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{r.scopes.map((s) => (
|
||||
<code key={s} className="rounded-tight bg-foreground/[0.06] px-1.5 py-0.5 text-meta">
|
||||
{s}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: "used", header: "最近使用", hideBelow: "md", cell: (r) => formatRelative(r.lastUsedAt) },
|
||||
{
|
||||
key: "status",
|
||||
header: "状态",
|
||||
cell: (r) => (
|
||||
<StatusBadge tone={r.revokedAt ? "neutral" : "success"}>{r.revokedAt ? "已撤销" : "有效"}</StatusBadge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "act",
|
||||
header: <span className="sr-only">操作</span>,
|
||||
align: "right",
|
||||
cell: (r) =>
|
||||
r.revokedAt ? null : (
|
||||
<Button variant="ghost" size="sm" className="hover:text-destructive" onClick={() => setRevoking(r)}>
|
||||
撤销
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Dialog open={open} onOpenChange={(o) => !create.isPending && setOpen(o)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>创建 API Key</DialogTitle>
|
||||
<DialogDescription>创建后明文只显示一次。</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form id="apikey-form" onSubmit={submit} noValidate className="grid gap-1">
|
||||
<FormField label="名称" htmlFor="ak-name" error={errors.name} required>
|
||||
<Input
|
||||
id="ak-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
aria-invalid={!!errors.name}
|
||||
placeholder="emby-plugin"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="权限范围" error={errors.scopes}>
|
||||
<div className="grid gap-1.5">
|
||||
{SCOPES.map((s) => {
|
||||
const on = scopes.includes(s.value);
|
||||
return (
|
||||
<label
|
||||
key={s.value}
|
||||
className="flex cursor-pointer items-center gap-2.5 rounded-lg px-1 py-1 text-sm hover:bg-foreground/[0.04]"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 accent-current"
|
||||
checked={on}
|
||||
onChange={() => setScopes((xs) => (on ? xs.filter((x) => x !== s.value) : [...xs, s.value]))}
|
||||
/>
|
||||
<code className="text-caption">{s.label}</code>
|
||||
<span className="text-meta text-muted-foreground">{s.hint}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</FormField>
|
||||
</form>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)} disabled={create.isPending}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" form="apikey-form" disabled={create.isPending}>
|
||||
{create.isPending ? <LoaderCircle className="animate-spin" /> : null}
|
||||
创建
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<CreatedKeyDialog plaintext={plaintext} onClose={() => setPlaintext(null)} />
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!revoking}
|
||||
onOpenChange={(o) => !o && setRevoking(null)}
|
||||
title={`撤销「${revoking?.name ?? ""}」?`}
|
||||
description="撤销后使用这个 Key 的程序会立即失去访问权限,无法恢复。"
|
||||
confirmLabel="撤销"
|
||||
destructive
|
||||
onConfirm={async () => {
|
||||
if (!revoking) return;
|
||||
try {
|
||||
await revoke.mutateAsync(revoking.id);
|
||||
toast.success("已撤销");
|
||||
} catch (err) {
|
||||
toast.error(errorMessage(err, "撤销失败"));
|
||||
throw err;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
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: "text", required: true, placeholder: "anthropic" },
|
||||
{ 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"]),
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
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} />
|
||||
<SettingsCard spec={AGENT_BUDGET_CARD} settings={s} />
|
||||
</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>
|
||||
));
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
import type { UseMutationResult, UseQueryResult } from "@tanstack/react-query";
|
||||
import type { CursorPage } from "@umt/contracts";
|
||||
import { LoaderCircle, Pencil, Plus, Trash2 } from "lucide-react";
|
||||
import { type ReactNode, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import type { z } from "zod";
|
||||
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
|
||||
import { type DataColumn, DataTable } from "~/components/console/data-table.tsx";
|
||||
import { 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;
|
||||
export type FormValues = Record<string, FieldValue>;
|
||||
|
||||
export type FieldSpec = {
|
||||
name: string;
|
||||
label: string;
|
||||
kind: "text" | "url" | "password" | "number" | "select" | "switch" | "textarea";
|
||||
options?: { value: string; label: string }[];
|
||||
placeholder?: string;
|
||||
hint?: string;
|
||||
/** 编辑时的说明(如"留空表示不修改") */
|
||||
editHint?: string;
|
||||
required?: boolean;
|
||||
/** 编辑时不可改 */
|
||||
createOnly?: boolean;
|
||||
/** 条件显示 */
|
||||
visible?: (v: FormValues) => boolean;
|
||||
wide?: boolean;
|
||||
};
|
||||
|
||||
export type ResourceConfig<Row extends { id: string }> = {
|
||||
/** 单数名,用于标题与提示:"下载器实例" */
|
||||
noun: string;
|
||||
description?: ReactNode;
|
||||
columns: DataColumn<Row>[];
|
||||
fields: FieldSpec[];
|
||||
/** 新建时的初始值 */
|
||||
initial: FormValues;
|
||||
/** 编辑时从行取表单值(凭证字段给空串) */
|
||||
fromRow: (row: Row) => FormValues;
|
||||
/** 表单值 → 请求体;mode=edit 时凭证空串应省略 */
|
||||
toBody: (v: FormValues, mode: "create" | "edit") => unknown;
|
||||
createSchema: z.ZodType;
|
||||
updateSchema: z.ZodType;
|
||||
/** 行内额外动作(测试连接等) */
|
||||
rowActions?: (row: Row) => ReactNode;
|
||||
emptyHint?: string;
|
||||
/** 删除确认的额外说明 */
|
||||
deleteHint?: string;
|
||||
};
|
||||
|
||||
type Hooks<Row> = {
|
||||
list: UseQueryResult<CursorPage<Row>>;
|
||||
create: UseMutationResult<Row, Error, never>;
|
||||
update: UseMutationResult<Row, Error, { id: string; body: never }>;
|
||||
remove: UseMutationResult<unknown, Error, string>;
|
||||
};
|
||||
|
||||
export function ResourceSection<Row extends { id: string }>({
|
||||
config,
|
||||
hooks,
|
||||
}: {
|
||||
config: ResourceConfig<Row>;
|
||||
hooks: Hooks<Row>;
|
||||
}) {
|
||||
const [editing, setEditing] = useState<Row | "new" | null>(null);
|
||||
const [deleting, setDeleting] = useState<Row | null>(null);
|
||||
const { list, remove } = hooks;
|
||||
|
||||
const columns: DataColumn<Row>[] = [
|
||||
...config.columns,
|
||||
{
|
||||
key: "__actions",
|
||||
header: <span className="sr-only">操作</span>,
|
||||
align: "right",
|
||||
cell: (row) => (
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{config.rowActions?.(row)}
|
||||
<Button variant="ghost" size="icon-sm" aria-label={`编辑${config.noun}`} onClick={() => setEditing(row)}>
|
||||
<Pencil />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`删除${config.noun}`}
|
||||
className="hover:text-destructive"
|
||||
onClick={() => setDeleting(row)}
|
||||
>
|
||||
<Trash2 />
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-3 flex flex-wrap items-start justify-between gap-3">
|
||||
{config.description ? (
|
||||
<p className="max-w-2xl text-caption text-muted-foreground">{config.description}</p>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<Button size="sm" onClick={() => setEditing("new")}>
|
||||
<Plus />
|
||||
添加{config.noun}
|
||||
</Button>
|
||||
</div>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={list.data?.items}
|
||||
rowKey={(r) => r.id}
|
||||
loading={list.isPending}
|
||||
error={list.error}
|
||||
onRetry={() => list.refetch()}
|
||||
empty={{ title: `还没有${config.noun}`, description: config.emptyHint }}
|
||||
/>
|
||||
<ResourceFormDialog
|
||||
key={editing === "new" ? "new" : (editing?.id ?? "none")}
|
||||
config={config}
|
||||
hooks={hooks}
|
||||
row={editing}
|
||||
onClose={() => setEditing(null)}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
open={!!deleting}
|
||||
onOpenChange={(o) => !o && setDeleting(null)}
|
||||
title={`删除${config.noun}?`}
|
||||
description={config.deleteHint ?? "删除后无法恢复。"}
|
||||
confirmLabel="删除"
|
||||
destructive
|
||||
onConfirm={async () => {
|
||||
if (!deleting) return;
|
||||
try {
|
||||
await remove.mutateAsync(deleting.id);
|
||||
toast.success(`已删除${config.noun}`);
|
||||
} catch (err) {
|
||||
toast.error(errorMessage(err, "删除失败"));
|
||||
throw err;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ResourceFormDialog<Row extends { id: string }>({
|
||||
config,
|
||||
hooks,
|
||||
row,
|
||||
onClose,
|
||||
}: {
|
||||
config: ResourceConfig<Row>;
|
||||
hooks: Hooks<Row>;
|
||||
row: Row | "new" | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const mode = row === "new" ? "create" : "edit";
|
||||
const [values, setValues] = useState<FormValues>(() =>
|
||||
row && row !== "new" ? config.fromRow(row) : { ...config.initial },
|
||||
);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const pending = hooks.create.isPending || hooks.update.isPending;
|
||||
|
||||
const set = (name: string, v: FieldValue) => setValues((s) => ({ ...s, [name]: v }));
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const body = config.toBody(values, mode);
|
||||
const parsed = (mode === "create" ? config.createSchema : config.updateSchema).safeParse(body);
|
||||
if (!parsed.success) {
|
||||
const fe = zodFieldErrors(parsed.error.issues);
|
||||
// 顶层 refine 错误(path 为空)挂到第一个字段下
|
||||
if (fe[""]) {
|
||||
fe[config.fields[0]?.name ?? ""] ??= fe[""];
|
||||
}
|
||||
setErrors(fe);
|
||||
return;
|
||||
}
|
||||
setErrors({});
|
||||
try {
|
||||
if (mode === "create") await hooks.create.mutateAsync(parsed.data as never);
|
||||
else if (row && row !== "new") await hooks.update.mutateAsync({ id: row.id, body: parsed.data as never });
|
||||
toast.success(mode === "create" ? `已添加${config.noun}` : `已保存${config.noun}`);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
toast.error(errorMessage(err, "保存失败"));
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
<Button variant="outline" onClick={onClose} disabled={pending}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" form="resource-form" disabled={pending}>
|
||||
{pending ? <LoaderCircle className="animate-spin" /> : null}
|
||||
保存
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function FieldControl({
|
||||
spec,
|
||||
value,
|
||||
error,
|
||||
onChange,
|
||||
disabled,
|
||||
hint,
|
||||
}: {
|
||||
spec: FieldSpec;
|
||||
value: FieldValue | undefined;
|
||||
error?: string;
|
||||
onChange: (v: FieldValue) => void;
|
||||
disabled?: boolean;
|
||||
hint?: string;
|
||||
}) {
|
||||
const id = `f-${spec.name}`;
|
||||
const wide = spec.wide || spec.kind === "textarea";
|
||||
if (spec.kind === "switch") {
|
||||
return (
|
||||
<div className={wide ? "sm:col-span-2" : undefined}>
|
||||
<label htmlFor={id} className="flex min-h-9 items-center justify-between gap-3 text-caption font-medium">
|
||||
{spec.label}
|
||||
<Switch id={id} checked={value === true} onCheckedChange={onChange} disabled={disabled} />
|
||||
</label>
|
||||
<p className="min-h-4 text-meta text-muted-foreground">{hint ?? ""}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
let control: ReactNode;
|
||||
if (spec.kind === "select") {
|
||||
control = (
|
||||
<Select value={String(value ?? "")} onValueChange={onChange} disabled={disabled}>
|
||||
<SelectTrigger id={id} className="w-full" aria-invalid={!!error}>
|
||||
<SelectValue placeholder={spec.placeholder ?? "请选择"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
{(spec.options ?? []).map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
} else if (spec.kind === "textarea") {
|
||||
control = (
|
||||
<Textarea
|
||||
id={id}
|
||||
value={String(value ?? "")}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={spec.placeholder}
|
||||
disabled={disabled}
|
||||
aria-invalid={!!error}
|
||||
rows={3}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
control = (
|
||||
<Input
|
||||
id={id}
|
||||
type={
|
||||
spec.kind === "password"
|
||||
? "password"
|
||||
: spec.kind === "number"
|
||||
? "number"
|
||||
: spec.kind === "url"
|
||||
? "url"
|
||||
: "text"
|
||||
}
|
||||
autoComplete={spec.kind === "password" ? "new-password" : "off"}
|
||||
value={String(value ?? "")}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={spec.placeholder}
|
||||
disabled={disabled}
|
||||
aria-invalid={!!error}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<FormField
|
||||
label={spec.label}
|
||||
htmlFor={id}
|
||||
error={error}
|
||||
hint={hint}
|
||||
required={spec.required}
|
||||
className={wide ? "sm:col-span-2" : undefined}
|
||||
>
|
||||
{control}
|
||||
</FormField>
|
||||
);
|
||||
}
|
||||
|
||||
/** 表单值工具 */
|
||||
export const str = (v: FieldValue | undefined) => (typeof v === "string" ? v.trim() : "");
|
||||
export const optStr = (v: FieldValue | undefined) => str(v) || undefined;
|
||||
export const nullStr = (v: FieldValue | undefined) => str(v) || null;
|
||||
export const num = (v: FieldValue | undefined) => (str(v) === "" ? undefined : Number(str(v)));
|
||||
Reference in new issue
Block a user