- 新增 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]>
250 lines
8.8 KiB
TypeScript
250 lines
8.8 KiB
TypeScript
import type { SiteAccount, SiteCredentialsBody } from "@umt/contracts";
|
|
import { FormField } from "~/components/console/form-field.tsx";
|
|
import { SecretInput } from "~/components/console/secret-input.tsx";
|
|
import { Input } from "~/components/ui/input.tsx";
|
|
import { Switch } from "~/components/ui/switch.tsx";
|
|
import { secretHint, secretPatch } from "~/lib/secret.ts";
|
|
import { cn } from "~/lib/utils.ts";
|
|
import { AUTH_KIND_LABEL, type LoginKind } from "./site-fields.ts";
|
|
|
|
// 凭证三选一(账号密码 / Cookie / API Key)。只写不回显;添加站点向导与"更新凭证"对话框共用。
|
|
// 更新已有账号且类型不变时,已存的密码 / Cookie / API Key / TOTP 显示掩码占位,留空 = 保持原值(值约定见 ~/lib/secret.ts)。
|
|
|
|
/** 已存账号里与表单相关的非密信息 */
|
|
export type SavedCredential = { kind: LoginKind; hasTotp: boolean };
|
|
|
|
/** 已有账号 → 表单初值:同类型时带上登录名与 UA(非密),凭证留空表示保持 */
|
|
export function draftFromAccount(kind: LoginKind, acc: SiteAccount | null | undefined): CredentialDraft {
|
|
const d = emptyCredential(kind);
|
|
if (!acc || acc.authKind !== kind) return d;
|
|
return { ...d, username: acc.loginName ?? "", userAgent: acc.userAgent ?? "" };
|
|
}
|
|
|
|
export type CredentialDraft = {
|
|
kind: LoginKind;
|
|
username: string;
|
|
password: string;
|
|
totpSecret: string;
|
|
saveCookieOnly: boolean;
|
|
cookie: string;
|
|
apiKey: string;
|
|
userAgent: string;
|
|
};
|
|
|
|
export const emptyCredential = (kind: LoginKind): CredentialDraft => ({
|
|
kind,
|
|
username: "",
|
|
password: "",
|
|
totpSecret: "",
|
|
saveCookieOnly: false,
|
|
cookie: "",
|
|
apiKey: "",
|
|
userAgent: "",
|
|
});
|
|
|
|
/** 草稿 → 请求体;缺必填返回字段错误 */
|
|
export function credentialBody(
|
|
d: CredentialDraft,
|
|
label: string,
|
|
saved?: SavedCredential | null,
|
|
): { body: SiteCredentialsBody; errors: null } | { body: null; errors: Record<string, string> } {
|
|
const ua = d.userAgent.trim() || undefined;
|
|
// 同类型已存:密码 / Cookie / API Key 可以留空保持
|
|
const keep = saved?.kind === d.kind;
|
|
if (d.kind === "password") {
|
|
const errors: Record<string, string> = {};
|
|
if (!d.username.trim()) errors.username = "填写登录名";
|
|
const password = secretPatch(d.password);
|
|
if (!password && !keep) errors.password = "填写密码";
|
|
if (Object.keys(errors).length) return { body: null, errors };
|
|
const totp = secretPatch(d.totpSecret);
|
|
return {
|
|
body: {
|
|
kind: "password",
|
|
label,
|
|
username: d.username.trim(),
|
|
...(password ? { password } : {}),
|
|
...(totp !== undefined ? { totpSecret: totp } : {}),
|
|
saveCookieOnly: d.saveCookieOnly || undefined,
|
|
userAgent: ua,
|
|
},
|
|
errors: null,
|
|
};
|
|
}
|
|
if (d.kind === "cookie") {
|
|
const cookie = secretPatch(d.cookie);
|
|
if (!cookie && !keep) return { body: null, errors: { cookie: "粘贴 Cookie" } };
|
|
return { body: { kind: "cookie", label, ...(cookie ? { cookie } : {}), userAgent: ua }, errors: null };
|
|
}
|
|
const apiKey = secretPatch(d.apiKey);
|
|
if (!apiKey && !keep) return { body: null, errors: { apiKey: "填写 API Key" } };
|
|
return { body: { kind: "api_key", label, ...(apiKey ? { apiKey } : {}) }, errors: null };
|
|
}
|
|
|
|
export function CredentialFields({
|
|
value,
|
|
onChange,
|
|
kinds,
|
|
errors,
|
|
disabled,
|
|
idPrefix = "cred",
|
|
saved,
|
|
}: {
|
|
value: CredentialDraft;
|
|
onChange: (v: CredentialDraft) => void;
|
|
kinds: LoginKind[];
|
|
errors: Record<string, string>;
|
|
disabled?: boolean;
|
|
idPrefix?: string;
|
|
/** 更新已有账号时传:同类型的已存凭证显示掩码,留空保持 */
|
|
saved?: SavedCredential | null;
|
|
}) {
|
|
const set = <K extends keyof CredentialDraft>(k: K, v: CredentialDraft[K]) => onChange({ ...value, [k]: v });
|
|
const isSet = saved?.kind === value.kind;
|
|
const keepHint = (h?: string) => (isSet ? (h ? `${h};留空不修改` : "留空不修改") : h);
|
|
const id = (s: string) => `${idPrefix}-${s}`;
|
|
return (
|
|
<div className="grid gap-x-4 gap-y-1">
|
|
{kinds.length > 1 ? (
|
|
<div className="mb-4" role="radiogroup" aria-label="凭证类型">
|
|
<p className="mb-1.5 text-caption font-medium text-foreground">凭证类型</p>
|
|
<div className="inline-flex flex-wrap gap-1 rounded-xl border border-border p-1">
|
|
{kinds.map((k) => (
|
|
// biome-ignore lint/a11y/useSemanticElements: 分段按钮,radio 语义由 role 表达
|
|
<button
|
|
key={k}
|
|
type="button"
|
|
role="radio"
|
|
aria-checked={value.kind === k}
|
|
disabled={disabled}
|
|
onClick={() => set("kind", k)}
|
|
className={cn(
|
|
"rounded-lg px-3 py-1.5 text-caption transition-colors",
|
|
value.kind === k
|
|
? "bg-foreground text-background"
|
|
: "text-muted-foreground hover:bg-accent hover:text-foreground",
|
|
)}
|
|
>
|
|
{AUTH_KIND_LABEL[k]}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
{value.kind === "password" ? (
|
|
<>
|
|
<div className="grid gap-x-4 sm:grid-cols-2">
|
|
<FormField label="登录名" htmlFor={id("user")} required error={errors.username}>
|
|
<Input
|
|
id={id("user")}
|
|
autoComplete="off"
|
|
value={value.username}
|
|
aria-invalid={!!errors.username}
|
|
onChange={(e) => set("username", e.target.value)}
|
|
disabled={disabled}
|
|
/>
|
|
</FormField>
|
|
<FormField label="密码" htmlFor={id("pass")} required error={errors.password} hint={keepHint()}>
|
|
<SecretInput
|
|
id={id("pass")}
|
|
label="密码"
|
|
value={value.password}
|
|
isSet={isSet}
|
|
invalid={!!errors.password}
|
|
onChange={(v) => set("password", v)}
|
|
disabled={disabled}
|
|
/>
|
|
</FormField>
|
|
</div>
|
|
<FormField
|
|
label="TOTP 密钥"
|
|
htmlFor={id("totp")}
|
|
hint={secretHint(
|
|
"站点开了二次验证时填 base32 密钥,登录时自动生成 6 位码",
|
|
value.totpSecret,
|
|
isSet && !!saved?.hasTotp,
|
|
)}
|
|
>
|
|
<SecretInput
|
|
id={id("totp")}
|
|
label="TOTP 密钥"
|
|
value={value.totpSecret}
|
|
isSet={isSet && !!saved?.hasTotp}
|
|
clearable
|
|
onChange={(v) => set("totpSecret", v)}
|
|
disabled={disabled}
|
|
/>
|
|
</FormField>
|
|
<div className="mb-4 flex items-start gap-3">
|
|
<Switch
|
|
id={id("cookie-only")}
|
|
checked={value.saveCookieOnly}
|
|
onCheckedChange={(v) => set("saveCookieOnly", v)}
|
|
disabled={disabled}
|
|
/>
|
|
<label htmlFor={id("cookie-only")} className="text-caption">
|
|
<span className="block font-medium text-foreground">只换 Cookie</span>
|
|
<span className="block text-muted-foreground">
|
|
密码加密保存,用于 Cookie 失效后自动重新登录。打开后登录成功只留 Cookie,不保存密码
|
|
</span>
|
|
</label>
|
|
</div>
|
|
</>
|
|
) : value.kind === "cookie" ? (
|
|
<FormField
|
|
label="Cookie"
|
|
htmlFor={id("cookie")}
|
|
required
|
|
error={errors.cookie}
|
|
hint={keepHint("浏览器登录站点后,从开发者工具复制请求头里的整段 Cookie")}
|
|
>
|
|
<SecretInput
|
|
id={id("cookie")}
|
|
label="Cookie"
|
|
multiline
|
|
value={value.cookie}
|
|
isSet={isSet}
|
|
invalid={!!errors.cookie}
|
|
onChange={(v) => set("cookie", v)}
|
|
disabled={disabled}
|
|
/>
|
|
</FormField>
|
|
) : (
|
|
<FormField
|
|
label="API Key"
|
|
htmlFor={id("apikey")}
|
|
required
|
|
error={errors.apiKey}
|
|
hint={keepHint("在站点控制台生成")}
|
|
>
|
|
<SecretInput
|
|
id={id("apikey")}
|
|
label="API Key"
|
|
value={value.apiKey}
|
|
isSet={isSet}
|
|
invalid={!!errors.apiKey}
|
|
onChange={(v) => set("apiKey", v)}
|
|
disabled={disabled}
|
|
/>
|
|
</FormField>
|
|
)}
|
|
|
|
{value.kind !== "api_key" ? (
|
|
<FormField
|
|
label="User-Agent"
|
|
htmlFor={id("ua")}
|
|
hint={value.kind === "cookie" ? "Cookie 与浏览器 UA 绑定时填写;留空用站点默认" : "留空用站点默认"}
|
|
>
|
|
<Input
|
|
id={id("ua")}
|
|
value={value.userAgent}
|
|
onChange={(e) => set("userAgent", e.target.value)}
|
|
disabled={disabled}
|
|
/>
|
|
</FormField>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|