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]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-30 00:10:46 +08:00
1 parent 68f53390f6
commit fa344a7184
39 files changed
+1175 -232

No files matched your search

@@ -8,6 +8,8 @@ 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";
@@ -15,10 +17,13 @@ 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 校验;凭证字段只写,编辑时留空表示不变。
// 表单用 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>;
@@ -26,7 +31,7 @@ export type FormValues = Record<string, FieldValue>;
export type FieldSpec = {
name: string;
label: string;
kind: "text" | "url" | "password" | "number" | "select" | "switch" | "textarea";
kind: "text" | "url" | "password" | "number" | "select" | "switch" | "textarea" | "tokens" | "secret";
options?: { value: string; label: string }[];
placeholder?: string;
hint?: string;
@@ -38,8 +43,37 @@ export type FieldSpec = {
/** 条件显示 */
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;
@@ -186,6 +220,12 @@ function ResourceFormDrawer<Row extends { id: string }>({
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) {
@@ -240,6 +280,7 @@ function ResourceFormDrawer<Row extends { id: string }>({
onChange={(v) => set(f.name, v)}
disabled={pending || (mode === "edit" && !!f.createOnly)}
hint={mode === "edit" && f.editHint ? f.editHint : f.hint}
values={values}
/>
))}
</form>
@@ -255,6 +296,7 @@ export function FieldControl({
onChange,
disabled,
hint,
values,
}: {
spec: FieldSpec;
value: FieldValue | undefined;
@@ -262,8 +304,11 @@ export function FieldControl({
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 (
@@ -281,11 +326,19 @@ export function FieldControl({
label={spec.label}
htmlFor={id}
error={error}
hint={hint}
hint={fieldHint(spec, value, all, hint)}
required={spec.required}
className={wide ? "sm:col-span-2" : undefined}
>
<FieldInput id={id} spec={spec} value={value} error={error} onChange={onChange} disabled={disabled} />
<FieldInput
id={id}
spec={spec}
value={value}
values={all}
error={error}
onChange={onChange}
disabled={disabled}
/>
</FormField>
);
}
@@ -299,6 +352,7 @@ export function FieldInput({
onChange,
disabled,
className,
values,
}: {
id: string;
spec: FieldSpec;
@@ -307,7 +361,41 @@ export function FieldInput({
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