Files
UHD_Media_Tool/apps/web/app/components/settings/resource-section.tsx
T
rootandClaude Opus 5.5 fa344a7184 feat(web): token 数以 K 输入显示,已存凭证显示掩码占位(FIX-8)
- 新增 UnitInput 与 kind=tokens:上下文预算、单次 token 上限、自定义模型上下文窗口以 K 输入(允许小数),存储仍为原始 token 数;校验与提示用 K 表述;列表/详情 token 数统一 K/M 简写(formatTokens)
- 新增 SecretInput 与 kind=secret:已保存时显示「已设置 ••••••••」(有服务端掩码时带末 4 位),聚焦直接输入替换,留空保持,「清除」确认后保存生效;替换模型服务、TMDB、网页搜索、下载器、媒体服务器、浏览器服务、站点账号、Passkey、本机 Token 各处
- 统一只写凭证服务端语义:省略或空串 = 不变,null = 清除(contracts secretInput);下载器/媒体服务器新增清除;站点凭证同类更新可留空保持密码/Cookie/API Key/TOTP,TOTP 可 null 清除,不再误删 TOTP 与 UA
- contextBudget 最小值 4096→4000、contextWindow 1024→1000,便于按 K 填写
- 测试:换算与校验、SecretInput 渲染与状态、各接口留空不变/显式清除

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-30 00:10:46 +08:00

464 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { UseMutationResult, UseQueryResult } from "@tanstack/react-query";
import type { CursorPage } from "@umt/contracts";
import { LoaderCircle, Pencil, Plus, Trash2 } from "lucide-react";
import { type ReactNode, useState } from "react";
import { toast } from "sonner";
import type { z } from "zod";
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
import { type DataColumn, DataTable } from "~/components/console/data-table.tsx";
import { DetailSheet } from "~/components/console/detail-sheet.tsx";
import { FormField, zodFieldErrors } from "~/components/console/form-field.tsx";
import { SecretInput } from "~/components/console/secret-input.tsx";
import { UnitInput } from "~/components/console/unit-input.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Input } from "~/components/ui/input.tsx";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
import { Switch } from "~/components/ui/switch.tsx";
import { Textarea } from "~/components/ui/textarea.tsx";
import { errorMessage } from "~/lib/api.ts";
import { revealFirstError } from "~/lib/form-errors.ts";
import { secretHint } from "~/lib/secret.ts";
import { type TokenRange, tokenInputError, tokenRangeText } from "~/lib/tokens.ts";
import { cn } from "~/lib/utils.ts";
// 设置页里"实例类"资源的通用段落:列表 + 新建/编辑抽屉 + 删除 + 行内动作。
// 表单用 contracts 的 Create/Update schema 做 zod 校验;凭证字段只写(kind=secret,值约定见 ~/lib/secret.ts):
// 留空不变、SECRET_CLEAR 清除;token 数字段(kind=tokens)以 K 输入,toBody 里用 kToTokens 换回原始值。
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" | "tokens" | "secret";
options?: { value: string; label: string }[];
placeholder?: string;
hint?: string;
/** 编辑时的说明(如"留空表示不修改") */
editHint?: string;
required?: boolean;
/** 编辑时不可改 */
createOnly?: boolean;
/** 条件显示 */
visible?: (v: FormValues) => boolean;
wide?: boolean;
/** kind=tokens:以 K 为单位输入(表单值是 K 字符串),范围按原始 token 数 */
tokens?: TokenRange;
/** kind=secret:只写凭证。set/mask 从表单值(fromRow / fromSettings 放进去的隐藏键)或闭包取 */
secret?: {
set: (v: FormValues) => boolean;
mask?: (v: FormValues) => string | null | undefined;
clearable?: boolean;
multiline?: boolean;
};
};
/** kind=tokens 字段先在本地校验(错误用 K 表述),通过后再走 zod */
export function localFieldErrors(fields: FieldSpec[], values: FormValues): Record<string, string> {
const out: Record<string, string> = {};
for (const f of fields) {
if (f.visible && !f.visible(values)) continue;
if (f.kind === "tokens") {
const e = tokenInputError(str(values[f.name]), { ...f.tokens, required: f.tokens?.required ?? f.required });
if (e) out[f.name] = e;
}
}
return out;
}
/** 字段说明:凭证字段已设置时补"留空不修改",token 字段缺省给范围 */
export function fieldHint(spec: FieldSpec, value: FieldValue | undefined, values: FormValues, hint?: string) {
if (spec.kind === "secret") return secretHint(hint, value, spec.secret?.set(values) ?? false);
if (spec.kind === "tokens" && hint === undefined && spec.tokens) return tokenRangeText(spec.tokens) ?? undefined;
return hint;
}
export type ResourceConfig<Row extends { id: string }> = {
/** 单数名,用于标题与提示:"下载器实例" */
noun: string;
description?: ReactNode;
columns: DataColumn<Row>[];
fields: FieldSpec[];
/** 新建时的初始值 */
initial: FormValues;
/** 编辑时从行取表单值(凭证字段给空串) */
fromRow: (row: Row) => FormValues;
/** 表单值 → 请求体;mode=edit 时凭证空串应省略 */
toBody: (v: FormValues, mode: "create" | "edit") => unknown;
createSchema: z.ZodType;
updateSchema: z.ZodType;
/** 行内额外动作(测试连接等) */
rowActions?: (row: Row) => ReactNode;
emptyHint?: string;
/** 删除确认的额外说明 */
deleteHint?: string;
/** 编辑抽屉里表单下方的附加内容(如该实例的路径映射) */
drawerExtra?: (row: Row) => ReactNode;
};
type Hooks<Row> = {
list: UseQueryResult<CursorPage<Row>>;
create: UseMutationResult<Row, Error, never>;
update: UseMutationResult<Row, Error, { id: string; body: never }>;
remove: UseMutationResult<unknown, Error, string>;
};
export function ResourceSection<Row extends { id: string }>({
config,
hooks,
}: {
config: ResourceConfig<Row>;
hooks: Hooks<Row>;
}) {
const [editing, setEditing] = useState<Row | "new" | null>(null);
const [deleting, setDeleting] = useState<Row | null>(null);
const { list, remove } = hooks;
const columns: DataColumn<Row>[] = [
...config.columns,
{
key: "__actions",
header: <span className="sr-only">操作</span>,
align: "right",
cell: (row) => (
// biome-ignore lint/a11y/noStaticElementInteractions: 只拦截冒泡,避免行内按钮触发整行打开抽屉
<div
className="flex items-center justify-end gap-1"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
{config.rowActions?.(row)}
<Button variant="ghost" size="icon-sm" aria-label={`编辑${config.noun}`} onClick={() => setEditing(row)}>
<Pencil />
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label={`删除${config.noun}`}
className="hover:text-destructive"
onClick={() => setDeleting(row)}
>
<Trash2 />
</Button>
</div>
),
},
];
return (
<div>
<div className="mb-3 flex flex-wrap items-start justify-between gap-3">
{config.description ? (
<p className="max-w-2xl text-caption text-muted-foreground">{config.description}</p>
) : (
<span />
)}
<Button size="sm" className="ml-auto" onClick={() => setEditing("new")}>
<Plus />
添加{config.noun}
</Button>
</div>
<DataTable
columns={columns}
rows={list.data?.items}
rowKey={(r) => r.id}
loading={list.isPending}
error={list.error}
onRetry={() => list.refetch()}
onRowClick={(r) => setEditing(r)}
empty={{ title: `还没有${config.noun}`, description: config.emptyHint }}
/>
<ResourceFormDrawer
key={editing === "new" ? "new" : (editing?.id ?? "none")}
config={config}
hooks={hooks}
row={editing}
onClose={() => setEditing(null)}
/>
<ConfirmDialog
open={!!deleting}
onOpenChange={(o) => !o && setDeleting(null)}
title={`删除${config.noun}?`}
description={config.deleteHint ?? "删除后无法恢复。"}
confirmLabel={`删除${config.noun}`}
destructive
onConfirm={async () => {
if (!deleting) return;
try {
await remove.mutateAsync(deleting.id);
toast.success(`已删除${config.noun}`);
} catch (err) {
toast.error(errorMessage(err, "删除失败"));
throw err;
}
}}
/>
</div>
);
}
function ResourceFormDrawer<Row extends { id: string }>({
config,
hooks,
row,
onClose,
}: {
config: ResourceConfig<Row>;
hooks: Hooks<Row>;
row: Row | "new" | null;
onClose: () => void;
}) {
const mode = row === "new" ? "create" : "edit";
const [values, setValues] = useState<FormValues>(() =>
row && row !== "new" ? config.fromRow(row) : { ...config.initial },
);
const [errors, setErrors] = useState<Record<string, string>>({});
const pending = hooks.create.isPending || hooks.update.isPending;
const set = (name: string, v: FieldValue) => setValues((s) => ({ ...s, [name]: v }));
const submit = async (e: React.FormEvent) => {
e.preventDefault();
const local = localFieldErrors(config.fields, values);
if (Object.keys(local).length) {
setErrors(local);
revealFirstError();
return;
}
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);
revealFirstError();
return;
}
setErrors({});
try {
if (mode === "create") await hooks.create.mutateAsync(parsed.data as never);
else if (row && row !== "new") await hooks.update.mutateAsync({ id: row.id, body: parsed.data as never });
toast.success(mode === "create" ? `已添加${config.noun}` : `已保存${config.noun}`);
onClose();
} catch (err) {
toast.error(errorMessage(err, "保存失败"));
}
};
const formId = `resource-form-${config.noun}`;
return (
<DetailSheet
open={!!row}
onOpenChange={(o) => !o && !pending && onClose()}
title={`${mode === "create" ? "添加" : "编辑"}${config.noun}`}
description="凭证只写入,保存后不回显。"
footer={
<>
<Button variant="outline" onClick={onClose} disabled={pending}>
取消
</Button>
<Button type="submit" form={formId} disabled={pending}>
{pending ? <LoaderCircle className="animate-spin" /> : null}
保存
</Button>
</>
}
>
<form id={formId} onSubmit={submit} noValidate className="grid gap-x-4 gap-y-1 sm:grid-cols-2">
{config.fields
.filter((f) => !f.visible || f.visible(values))
.map((f) => (
<FieldControl
key={f.name}
spec={f}
value={values[f.name]}
error={errors[f.name]}
onChange={(v) => set(f.name, v)}
disabled={pending || (mode === "edit" && !!f.createOnly)}
hint={mode === "edit" && f.editHint ? f.editHint : f.hint}
values={values}
/>
))}
</form>
{row && row !== "new" && config.drawerExtra ? config.drawerExtra(row) : null}
</DetailSheet>
);
}
export function FieldControl({
spec,
value,
error,
onChange,
disabled,
hint,
values,
}: {
spec: FieldSpec;
value: FieldValue | undefined;
error?: string;
onChange: (v: FieldValue) => void;
disabled?: boolean;
hint?: string;
/** 整个表单的值(凭证字段据此判断是否已设置);单独使用时可省略 */
values?: FormValues;
}) {
const id = `f-${spec.name}`;
const all = values ?? {};
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>
);
}
return (
<FormField
label={spec.label}
htmlFor={id}
error={error}
hint={fieldHint(spec, value, all, hint)}
required={spec.required}
className={wide ? "sm:col-span-2" : undefined}
>
<FieldInput
id={id}
spec={spec}
value={value}
values={all}
error={error}
onChange={onChange}
disabled={disabled}
/>
</FormField>
);
}
/** 字段的控件本体(不含标签与说明);开关也在这里 */
export function FieldInput({
id,
spec,
value,
error,
onChange,
disabled,
className,
values,
}: {
id: string;
spec: FieldSpec;
value: FieldValue | undefined;
error?: string;
onChange: (v: FieldValue) => void;
disabled?: boolean;
className?: string;
values?: FormValues;
}) {
if (spec.kind === "secret") {
const all = values ?? {};
return (
<SecretInput
id={id}
value={typeof value === "string" ? value : ""}
onChange={onChange}
isSet={spec.secret?.set(all) ?? false}
mask={spec.secret?.mask?.(all)}
clearable={spec.secret?.clearable}
multiline={spec.secret?.multiline}
label={spec.label}
placeholder={spec.placeholder}
disabled={disabled}
invalid={!!error}
className={className}
/>
);
}
if (spec.kind === "tokens") {
return (
<UnitInput
id={id}
unit="K"
value={String(value ?? "")}
onChange={(e) => onChange(e.target.value)}
placeholder={spec.placeholder}
disabled={disabled}
aria-invalid={!!error}
className={className}
/>
);
}
if (spec.kind === "switch") {
return (
<Switch
id={id}
checked={value === true}
onCheckedChange={onChange}
disabled={disabled}
className={className}
aria-invalid={!!error}
/>
);
}
if (spec.kind === "select") {
return (
<Select value={String(value ?? "")} onValueChange={onChange} disabled={disabled}>
<SelectTrigger id={id} className={cn("w-full", className)} 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>
);
}
if (spec.kind === "textarea") {
return (
<Textarea
id={id}
value={String(value ?? "")}
onChange={(e) => onChange(e.target.value)}
placeholder={spec.placeholder}
disabled={disabled}
aria-invalid={!!error}
rows={3}
className={className}
/>
);
}
return (
<Input
id={id}
type={
spec.kind === "password" ? "password" : spec.kind === "number" ? "number" : spec.kind === "url" ? "url" : "text"
}
inputMode={spec.kind === "number" ? "decimal" : undefined}
autoComplete={spec.kind === "password" ? "new-password" : "off"}
value={String(value ?? "")}
onChange={(e) => onChange(e.target.value)}
placeholder={spec.placeholder}
disabled={disabled}
aria-invalid={!!error}
className={className}
/>
);
}
/** 表单值工具 */
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)));