Files
rootandClaude Opus 5.5 c9b4ceb301 fix(web): 控制台界面细节与 uhd_web 控件规范对齐
- 顶栏搜索移进右侧玻璃胶囊;输入框改淡玻璃(边线 0.46 → 0.16,悬停加深,聚焦 / 出错保留光晕)
- 新增 SegmentedControl(滑动 seg-thumb),替换站点、下载、订阅、文件、智能体列表及对话框里的实心选中块;Tabs 改为同一枚滑块
- 开关型按钮统一 outline + aria-pressed 选中外观;页头 / 工具栏次操作描边、图标钮描边圆;文件排序改 Select;下载总速度加标签
- 分页一页放得下时只留总数、空列表不渲染、按容器宽度收起按钮
- 运行状态存储区重排;智能体列表页与会话页共用外壳路由,标题与左栏不再重挂载
- 站点卡片「⋯」不再单独换行;设置导航、状态计数、多选条件改玻璃选中态

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
2026-10-08 12:02:48 +08:00

240 lines
8.4 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 { SegmentedControl } from "~/components/ui/segmented.tsx";
import { Switch } from "~/components/ui/switch.tsx";
import { secretHint, secretPatch } from "~/lib/secret.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">
<p className="mb-1.5 text-caption font-medium text-foreground">凭证类型</p>
<SegmentedControl
label="凭证类型"
value={value.kind}
onChange={(k) => set("kind", k)}
options={kinds.map((k) => ({ value: k, label: AUTH_KIND_LABEL[k], disabled }))}
/>
</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 时,填那个浏览器的 UA"
: "留空跟随浏览器(推荐)"
}
>
<Input
id={id("ua")}
placeholder="跟随浏览器"
value={value.userAgent}
onChange={(e) => set("userAgent", e.target.value)}
disabled={disabled}
/>
</FormField>
) : null}
</div>
);
}