Files
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

143 lines
4.3 KiB
TypeScript
Raw Permalink 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 { Undo2, X } from "lucide-react";
import { useState } from "react";
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Input } from "~/components/ui/input.tsx";
import { Textarea } from "~/components/ui/textarea.tsx";
import { SECRET_CLEAR, secretView } from "~/lib/secret.ts";
import { cn } from "~/lib/utils.ts";
/**
* 只写凭证输入框。已保存时框里显示掩码占位(「已设置 ••••••••」,服务端给了末 4 位就带上),
* 聚焦后直接输入即替换;留空保存 = 保持原值;「清除」确认后把值设为 SECRET_CLEAR,保存时才生效。
* 值约定见 ~/lib/secret.ts。明文只在用户输入时存在于这个表单里,从不由服务端回填。
*/
export function SecretInput({
id,
value,
onChange,
isSet,
mask,
clearable = false,
label = "凭证",
placeholder,
multiline = false,
disabled,
invalid,
className,
}: {
id?: string;
value: string;
onChange: (v: string) => void;
/** 服务端已保存(secretRef / hasCredential 等) */
isSet: boolean;
/** 服务端给的掩码 hint(如 credentialMask),只取末 4 位显示 */
mask?: string | null;
/** 允许清除(必填凭证不给) */
clearable?: boolean;
/** 确认弹窗里的名字:"API Key"、"密码" */
label?: string;
placeholder?: string;
/** Cookie 这类长串用多行 */
multiline?: boolean;
disabled?: boolean;
invalid?: boolean;
className?: string;
}) {
const [confirming, setConfirming] = useState(false);
const view = secretView(value, { isSet, mask, clearable });
if (view.state === "cleared") {
return (
<div className={cn("flex min-w-0 items-center gap-2", className)}>
<Input
id={id}
readOnly
value=""
placeholder="保存后清除"
aria-label={`${label}:保存后清除`}
className="min-w-0 flex-1 border-dashed placeholder:text-destructive"
disabled={disabled}
/>
<Button type="button" variant="ghost" size="sm" onClick={() => onChange("")} disabled={disabled}>
<Undo2 />
撤销
</Button>
</div>
);
}
const shownPlaceholder = view.state === "set" ? view.placeholder : placeholder;
const common = {
id,
value,
disabled,
// 没有外部 <label htmlFor> 时用名字作无障碍标签
"aria-label": id ? undefined : label,
"aria-invalid": invalid || undefined,
placeholder: shownPlaceholder,
// 掩码占位要像"有值",不能像空框
className: cn("min-w-0 flex-1", view.state === "set" && "placeholder:text-foreground"),
};
return (
<div className={cn("flex min-w-0 items-start gap-2", className)}>
{multiline ? (
<Textarea
{...common}
rows={3}
className={cn(common.className, "font-mono text-caption")}
autoComplete="off"
spellCheck={false}
onChange={(e) => onChange(e.target.value)}
/>
) : (
<Input
{...common}
type="password"
autoComplete="new-password"
spellCheck={false}
onChange={(e) => onChange(e.target.value)}
/>
)}
{view.state === "set" && view.canClear ? (
<>
<Button
type="button"
variant="ghost"
size="sm"
className="shrink-0 hover:text-destructive"
onClick={() => setConfirming(true)}
disabled={disabled}
>
<X />
清除
</Button>
<ConfirmDialog
open={confirming}
onOpenChange={setConfirming}
title={`清除已保存的${label}?`}
description="保存后生效,清除后无法找回,用到它的功能要重新填写。"
confirmLabel="清除"
destructive
onConfirm={() => onChange(SECRET_CLEAR)}
/>
</>
) : null}
{view.state === "typing" && view.replacing ? (
<Button
type="button"
variant="ghost"
size="sm"
className="shrink-0"
onClick={() => onChange("")}
disabled={disabled}
aria-label={`不替换${label}`}
>
<Undo2 />
撤销
</Button>
) : null}
</div>
);
}