- 新增 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]>
266 lines
10 KiB
TypeScript
266 lines
10 KiB
TypeScript
import type { SiteAccount, SiteListItem } from "@umt/contracts";
|
||
import { KeyRound, LoaderCircle, RefreshCw, Trash2, UserRound } from "lucide-react";
|
||
import { useState } from "react";
|
||
import { toast } from "sonner";
|
||
import { useDeleteSiteAccount, useLoginHistory, usePutCredentials } from "~/api/sites.ts";
|
||
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
|
||
import { DetailSection } from "~/components/console/detail-sheet.tsx";
|
||
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
||
import { FormField } from "~/components/console/form-field.tsx";
|
||
import { KvList } from "~/components/console/kv-list.tsx";
|
||
import { SecretInput } from "~/components/console/secret-input.tsx";
|
||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||
import { Button } from "~/components/ui/button.tsx";
|
||
import { relTime } from "~/components/workbench/release-fields.ts";
|
||
import { errorMessage } from "~/lib/api.ts";
|
||
import { formatDateTime } from "~/lib/utils.ts";
|
||
import type { SiteDialog, useSiteChecks } from "./site-dialogs.tsx";
|
||
import { ACCOUNT_OVERALL, AUTH_KIND_LABEL, loginFailureText, REASON_SHORT } from "./site-fields.ts";
|
||
|
||
// 站点详情"账号":凭证管理(更新 / 刷新登录 / 删除 / Passkey)+ 登录历史
|
||
|
||
type Checks = ReturnType<typeof useSiteChecks>;
|
||
|
||
export function SiteAccountsTab({
|
||
site,
|
||
checks,
|
||
onDialog,
|
||
}: {
|
||
site: SiteListItem;
|
||
checks: Checks;
|
||
onDialog: (d: SiteDialog) => void;
|
||
}) {
|
||
const accounts = site.accounts ?? [];
|
||
if (!accounts.length)
|
||
return (
|
||
<EmptyState
|
||
icon={UserRound}
|
||
title="还没有账号"
|
||
description="填写凭证后,这里显示登录状态和登录历史"
|
||
action={
|
||
<Button size="sm" onClick={() => onDialog({ kind: "credentials", site })}>
|
||
<KeyRound />
|
||
填写凭证
|
||
</Button>
|
||
}
|
||
/>
|
||
);
|
||
return (
|
||
<div className="space-y-6 pt-2">
|
||
{accounts.map((a) => (
|
||
<AccountBlock key={a.id} site={site} account={a} checks={checks} onDialog={onDialog} />
|
||
))}
|
||
<PasskeyForm site={site} label={accounts[0]?.label ?? site.key} has={accounts.some((a) => a.hasPasskey)} />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function AccountBlock({
|
||
site,
|
||
account: a,
|
||
checks,
|
||
onDialog,
|
||
}: {
|
||
site: SiteListItem;
|
||
account: SiteAccount;
|
||
checks: Checks;
|
||
onDialog: (d: SiteDialog) => void;
|
||
}) {
|
||
const del = useDeleteSiteAccount();
|
||
const st = ACCOUNT_OVERALL[a.enabled ? a.status : "unconfigured"];
|
||
const busy = checks.isBusy(site.id, "login");
|
||
const loginKind = a.authKind === "passkey" ? undefined : a.authKind;
|
||
return (
|
||
<div className="grid gap-6 lg:grid-cols-[minmax(0,3fr)_minmax(0,2fr)]">
|
||
<section className="pg-slab min-w-0 rounded-xl p-4">
|
||
<div className="mb-3 flex flex-wrap items-start justify-between gap-3">
|
||
<div className="min-w-0">
|
||
<h2 className="text-lead font-semibold text-foreground">账号 · {a.label}</h2>
|
||
<div className="mt-1 flex flex-wrap gap-1.5">
|
||
<StatusBadge tone={st.tone}>{a.enabled ? st.label : "已停用"}</StatusBadge>
|
||
<span className="rounded-full border border-border px-2 py-0.5 text-meta text-muted-foreground">
|
||
{AUTH_KIND_LABEL[a.authKind]}
|
||
</span>
|
||
{a.hasPasskey && a.authKind !== "passkey" ? (
|
||
<span className="rounded-full border border-border px-2 py-0.5 text-meta text-muted-foreground">
|
||
Passkey
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
<div className="flex flex-wrap gap-2">
|
||
{a.authKind !== "passkey" ? (
|
||
<Button variant="outline" size="sm" disabled={busy} onClick={() => void checks.refreshLogin(site, a)}>
|
||
{busy ? <LoaderCircle className="animate-spin" /> : <RefreshCw />}
|
||
刷新登录
|
||
</Button>
|
||
) : null}
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => onDialog({ kind: "credentials", site, account: a, loginKind })}
|
||
>
|
||
<KeyRound />
|
||
更新凭证
|
||
</Button>
|
||
<ConfirmDialog
|
||
trigger={
|
||
<Button variant="ghost" size="sm" className="hover:text-destructive">
|
||
<Trash2 />
|
||
删除账号
|
||
</Button>
|
||
}
|
||
title={`删除账号「${a.label}」?`}
|
||
description="账号、凭证和用户数据历史一起删除,不可恢复。站点和资源保留。"
|
||
confirmLabel="删除账号"
|
||
destructive
|
||
onConfirm={async () => {
|
||
try {
|
||
await del.mutateAsync(a.id);
|
||
toast.success(`已删除账号 ${a.label}`);
|
||
} catch (err) {
|
||
toast.error(errorMessage(err, "删除失败"));
|
||
throw err;
|
||
}
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<KvList
|
||
items={[
|
||
{ label: "登录名", value: a.loginName },
|
||
{ label: "用户名", value: a.userData?.username },
|
||
{
|
||
label: "上次登录",
|
||
value: a.lastLoginAt ? `${relTime(a.lastLoginAt)} · ${formatDateTime(a.lastLoginAt)}` : null,
|
||
},
|
||
{ label: "上次验证", value: relTime(a.lastVerifiedAt) },
|
||
{
|
||
label: "二次验证",
|
||
value: a.authKind === "password" ? (a.hasTotp ? "已填 TOTP 密钥" : "未填 TOTP 密钥") : null,
|
||
},
|
||
{
|
||
label: "会话 Cookie",
|
||
value: a.authKind === "password" ? (a.hasSession ? "已保存" : "还没有") : null,
|
||
},
|
||
{ label: "User-Agent", value: a.userAgent ?? "站点默认", wide: true, mono: !!a.userAgent },
|
||
...(a.lastError
|
||
? [
|
||
{
|
||
label: `最近错误 · ${relTime(a.lastError.at)}`,
|
||
value: (
|
||
<span className="text-warning">
|
||
{loginFailureText(a.lastError.reason, a.lastError.message, a.authKind).text}
|
||
</span>
|
||
),
|
||
wide: true,
|
||
},
|
||
]
|
||
: []),
|
||
]}
|
||
/>
|
||
</section>
|
||
<LoginHistory accountId={a.id} />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const ACTION_LABEL: Record<string, string> = {
|
||
"site_account.login_refreshed": "刷新登录",
|
||
"site_account.login_refresh_requested": "请求刷新登录",
|
||
"site_account.credentials_set": "更新凭证",
|
||
"site_account.created": "添加账号",
|
||
"site_account.updated": "修改账号",
|
||
};
|
||
|
||
function LoginHistory({ accountId }: { accountId: string }) {
|
||
const q = useLoginHistory(accountId);
|
||
const items = (q.data?.items ?? []).filter((e) => e.action in ACTION_LABEL);
|
||
return (
|
||
<DetailSection title="登录历史">
|
||
{q.isPending ? (
|
||
<LoadingState rows={3} />
|
||
) : q.error ? (
|
||
<ErrorState plain error={q.error} onRetry={() => void q.refetch()} />
|
||
) : !items.length ? (
|
||
<p className="text-caption text-muted-foreground">还没有记录。刷新登录或更新凭证后出现在这里</p>
|
||
) : (
|
||
<ol className="space-y-2">
|
||
{items.map((e) => {
|
||
const ok = e.payload.ok;
|
||
const reason = typeof e.payload.reason === "string" ? e.payload.reason : null;
|
||
return (
|
||
<li key={e.id} className="flex items-start justify-between gap-3 text-caption">
|
||
<span className="min-w-0">
|
||
<span className="text-foreground">{ACTION_LABEL[e.action]}</span>
|
||
{ok === true ? (
|
||
<StatusBadge tone="success" className="ml-2">
|
||
成功
|
||
</StatusBadge>
|
||
) : ok === false ? (
|
||
<StatusBadge tone="destructive" className="ml-2">
|
||
{(reason && REASON_SHORT[reason as keyof typeof REASON_SHORT]) || "失败"}
|
||
</StatusBadge>
|
||
) : null}
|
||
</span>
|
||
<time className="shrink-0 text-meta text-muted-foreground" dateTime={e.at} title={formatDateTime(e.at)}>
|
||
{relTime(e.at)}
|
||
</time>
|
||
</li>
|
||
);
|
||
})}
|
||
</ol>
|
||
)}
|
||
</DetailSection>
|
||
);
|
||
}
|
||
|
||
function PasskeyForm({ site, label, has }: { site: SiteListItem; label: string; has: boolean }) {
|
||
const put = usePutCredentials();
|
||
const [v, setV] = useState("");
|
||
const [err, setErr] = useState<string | null>(null);
|
||
return (
|
||
<form
|
||
className="pg-slab max-w-xl rounded-xl p-4"
|
||
noValidate
|
||
onSubmit={async (e) => {
|
||
e.preventDefault();
|
||
if (!v.trim()) {
|
||
setErr(has ? "填写新的 Passkey 再保存" : "填写 Passkey");
|
||
return;
|
||
}
|
||
setErr(null);
|
||
try {
|
||
await put.mutateAsync({ siteId: site.id, body: { kind: "passkey", label, passkey: v.trim() } });
|
||
setV("");
|
||
toast.success("Passkey 已保存");
|
||
} catch (error) {
|
||
toast.error(errorMessage(error, "保存失败"));
|
||
}
|
||
}}
|
||
>
|
||
<h2 className="text-lead font-semibold text-foreground">站点 Passkey(RSS 与下载链接用)</h2>
|
||
<p className="mb-3 text-caption text-muted-foreground">
|
||
用于 RSS 和下载链接,不参与登录。{has ? "填写新值保存会替换。" : "还没有保存。"}
|
||
</p>
|
||
<div className="flex flex-wrap items-start gap-2">
|
||
<FormField label="Passkey" htmlFor="pk" error={err} className="min-w-0 flex-1">
|
||
<SecretInput
|
||
id="pk"
|
||
label="Passkey"
|
||
value={v}
|
||
isSet={has}
|
||
invalid={!!err}
|
||
onChange={setV}
|
||
disabled={put.isPending}
|
||
/>
|
||
</FormField>
|
||
<Button type="submit" size="sm" className="mt-6" disabled={put.isPending}>
|
||
{put.isPending ? <LoaderCircle className="animate-spin" /> : null}
|
||
保存 Passkey
|
||
</Button>
|
||
</div>
|
||
</form>
|
||
);
|
||
}
|