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:
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
|
||||
|
||||
Reference in new issue
Block a user