Files
UHD_Media_Tool/apps/web/app/components/settings/account-passkeys.tsx
T
rootandClaude Opus 5.5 57ce40168d refactor(web): 技术信息折叠铺到媒体库 / 审计 / 运行状态,清理正常态徽章与解释型页描述(DEAI-2)
- 媒体库详情:作品 UUID 进技术信息;「实际路径与库根」改「文件位置」,正常态(已核验、
  可访问、主位置)不加徽章;外部身份显示「TMDB 90228 · 你确认的 / 自动匹配」,修订号进悬停;
  元数据字段来源默认收起(「显示来源」,跟随界面设置);别名默认只列中英文与手动加的;
  「内容单元」→「季与集」,「临时单元」→「编号待确认」;侧栏与页标题统一「媒体库」
- 审计:动作列不再重复英文码,对象列显示中文类型 / 能力名,ID 只在详情;补 capability.denied
- 运行状态:进程、已下线实例、趋势图与最大表收进折叠区;图例同名序列加序号,不再拼接
- 站点卡:账号正常与登录方式不加徽章,健康不显示毫秒,限流移出卡片
- 文件管理:目录「正常」徽章与重复的剩余空间去掉;已登记 → 已入库,临时文件 → 未完成
- 规则:「修订」列改「最后修改」;不支持的关键字合成一行;页签改「默认规则 / 自动收新作品 / 已忽略」;
  默认规则说明去掉「带层」等算法描述
- 站点 Passkey 与登录用通行密钥区分命名;命令面板提示去重
- 删解释型页描述(审计、搜索、下载、下载计划、RSS、规则、媒体库、运行状态、站点、任务、计划、整理)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-29 20:58:38 +08:00

206 lines
6.9 KiB
TypeScript
Raw 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 type { PasskeyInfo } from "@umt/contracts";
import { Check, KeyRound, LoaderCircle, Pencil, Plus, Trash2, X } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import {
isPasskeyCancel,
passkeySupported,
useDeletePasskey,
usePasskeys,
useRegisterPasskey,
useRenamePasskey,
} from "~/api/auth.ts";
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Input } from "~/components/ui/input.tsx";
import { errorMessage } from "~/lib/api.ts";
import { formatDateTime } from "~/lib/utils.ts";
import { SettingsBlock } from "./settings-block.tsx";
// Passkey:登记(可以多把,各自命名)、改名、删除。登录页有 Passkey 时显示"用 Passkey 登录"。
/** 按浏览器平台给个默认名,方便区分 */
function defaultName(): string {
const ua = typeof navigator === "undefined" ? "" : navigator.userAgent;
if (/iPhone|iPad/.test(ua)) return "iPhone / iPad";
if (/Mac OS X/.test(ua)) return "Mac";
if (/Android/.test(ua)) return "Android";
if (/Windows/.test(ua)) return "Windows";
return "Passkey";
}
function PasskeyRow({ item }: { item: PasskeyInfo }) {
const rename = useRenamePasskey();
const del = useDeletePasskey();
const [editing, setEditing] = useState(false);
const [name, setName] = useState(item.name);
const save = async (e: React.FormEvent) => {
e.preventDefault();
const v = name.trim();
if (!v || v === item.name) return setEditing(false);
try {
await rename.mutateAsync({ id: item.id, name: v });
setEditing(false);
} catch (err) {
toast.error(errorMessage(err));
}
};
return (
<li className="flex min-h-12 items-center gap-3 py-2">
<KeyRound className="size-4 shrink-0 text-muted-foreground" />
{editing ? (
<form onSubmit={save} className="flex min-w-0 flex-1 items-center gap-1.5">
<Input
aria-label="Passkey 名称"
value={name}
maxLength={60}
autoFocus
onChange={(e) => setName(e.target.value)}
className="h-8 max-w-64"
/>
<Button type="submit" size="icon-sm" variant="ghost" aria-label="保存名称" disabled={rename.isPending}>
{rename.isPending ? <LoaderCircle className="animate-spin" /> : <Check />}
</Button>
<Button
type="button"
size="icon-sm"
variant="ghost"
aria-label="取消改名"
onClick={() => {
setName(item.name);
setEditing(false);
}}
>
<X />
</Button>
</form>
) : (
<div className="min-w-0 flex-1">
<p className="truncate text-caption font-medium">{item.name}</p>
<p className="text-meta text-muted-foreground">
{formatDateTime(item.createdAt)} 登记 ·{" "}
{item.lastUsedAt ? `${formatDateTime(item.lastUsedAt)} 用过` : "还没用过"}
{item.backedUp ? " · 已同步到云端钥匙串" : ""}
</p>
</div>
)}
{editing ? null : (
<div className="flex shrink-0 gap-1">
<Button size="icon-sm" variant="ghost" aria-label={`重命名 ${item.name}`} onClick={() => setEditing(true)}>
<Pencil />
</Button>
<ConfirmDialog
trigger={
<Button size="icon-sm" variant="ghost" aria-label={`删除 ${item.name}`}>
<Trash2 />
</Button>
}
title={`删除 Passkey「${item.name}」?`}
description="删除后这把钥匙不能再登录控制台;设备上的钥匙要在系统设置里另外删。"
confirmLabel="删除"
destructive
onConfirm={async () => {
try {
await del.mutateAsync(item.id);
toast.success("Passkey 已删除");
} catch (err) {
toast.error(errorMessage(err));
throw err;
}
}}
/>
</div>
)}
</li>
);
}
export function PasskeyBlock({ disabled }: { disabled: boolean }) {
const list = usePasskeys(!disabled);
const register = useRegisterPasskey();
const [adding, setAdding] = useState(false);
const [name, setName] = useState("");
const supported = passkeySupported();
const items = list.data?.items ?? [];
const add = async (e: React.FormEvent) => {
e.preventDefault();
try {
const r = await register.mutateAsync(name.trim() || defaultName());
toast.success(`已登记「${r.name}」`);
setAdding(false);
setName("");
} catch (err) {
if (isPasskeyCancel(err)) return;
toast.error(errorMessage(err, "登记失败"));
}
};
let body: React.ReactNode;
if (disabled) body = <p className="text-caption text-muted-foreground">当前用 API Key 访问,登录会话才能修改。</p>;
else
body = (
<div className="grid gap-3">
{list.isPending ? (
<p className="text-caption text-muted-foreground">读取中…</p>
) : items.length ? (
<ul className="divide-y divide-border/60">
{items.map((p) => (
<PasskeyRow key={p.id} item={p} />
))}
</ul>
) : (
<p className="text-caption text-muted-foreground">还没有登记 Passkey。</p>
)}
{!supported ? (
<p className="text-meta text-muted-foreground">这个浏览器用不了 Passkey:需要 HTTPS 或 localhost 访问。</p>
) : adding ? (
<form onSubmit={add} className="flex max-w-md flex-wrap items-center gap-2">
<Input
aria-label="新 Passkey 的名称"
placeholder={defaultName()}
value={name}
maxLength={60}
autoFocus
onChange={(e) => setName(e.target.value)}
className="min-w-0 flex-1"
/>
<Button type="submit" size="sm" disabled={register.isPending}>
{register.isPending ? <LoaderCircle className="animate-spin" /> : null}
继续
</Button>
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => setAdding(false)}
disabled={register.isPending}
>
取消
</Button>
</form>
) : (
<div>
<Button size="sm" variant="outline" onClick={() => setAdding(true)}>
<Plus />
添加 Passkey
</Button>
</div>
)}
</div>
);
return (
<SettingsBlock
section="account"
anchor="passkeys"
title="通行密钥(Passkey)"
description="用指纹、面容或安全钥匙直接登录,不用输密码和验证码。可以登记多把。"
>
{body}
</SettingsBlock>
);
}