Files
UHD_Media_Tool/apps/web/app/components/settings/api-keys.tsx
T
rootandClaude Opus 5.5 2c8c33c31c refactor(web): 下载盘、RSS、订阅、设置与待处理的剩余去噪项(DEAI-2)
- 下载面板:下载盘只显示「已用 x / 总量」与进度条,暂停线 / 在途 / 预留 / 可清理 / 做种保留收进详情;
  阈值文案统一「暂停线 / 恢复线 / 下载盘满暂停」
- RSS 页去掉与站点页重复的站点表,节标题「RSS 地址」;用途只在非通用时显示
- 订阅:一条都没有时不铺筛选;表单里「空 = 24;0 = 关」等等式改成 placeholder + 一句说明
- 设置:浏览器服务、元数据识别、MCP 说明去掉组件名与实现细节;会话目录与 fake 脚本只在开发构建显示;
  API Key scope 前加中文名;命名模板变量去掉「MP 无」
- 待处理顶部计数卡改成一行筛选按钮
- 智能体悬浮按钮只在没有侧栏的窄屏出现,抽屉打开时不再显示「收起」胶囊

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-29 21:02:30 +08:00

261 lines
9.5 KiB
TypeScript

import { type ApiKey, ApiKeyCreate, type ApiScope } from "@umt/contracts";
import { Check, Copy, LoaderCircle, Plus } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { useApiKeys, useCreateApiKey, useRevokeApiKey } from "~/api/api-keys.ts";
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
import { DataTable } from "~/components/console/data-table.tsx";
import { FormField, zodFieldErrors } from "~/components/console/form-field.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { Button } from "~/components/ui/button.tsx";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog.tsx";
import { Input } from "~/components/ui/input.tsx";
import { errorMessage } from "~/lib/api.ts";
import { formatRelative } from "~/lib/labels.ts";
import { cn, formatDateTime } from "~/lib/utils.ts";
import { SettingsBlock } from "./settings-block.tsx";
// ─── API Key ───
const SCOPES: { value: ApiScope; label: string; hint: string }[] = [
{ value: "read", label: "只读 · read", hint: "只读查询全部数据" },
{ value: "organize.read", label: "读整理记录 · organize.read", hint: "只读整理记录,供外部增量同步" },
{ value: "organize.execute", label: "执行整理 · organize.execute", hint: "发起整理" },
{ value: "admin", label: "管理员 · admin", hint: "全部管理权限,含改设置" },
];
function CreatedKeyDialog({ plaintext, onClose }: { plaintext: string | null; onClose: () => void }) {
const [copied, setCopied] = useState(false);
return (
<Dialog open={!!plaintext} onOpenChange={(o) => !o && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>API Key 已创建</DialogTitle>
<DialogDescription>明文只显示这一次,关闭后无法再查看。</DialogDescription>
</DialogHeader>
<div className="flex min-w-0 items-center gap-2 rounded-lg border border-warning/40 bg-warning/10 p-3">
<code className="min-w-0 flex-1 font-mono text-caption break-all text-foreground">{plaintext}</code>
<Button
variant="outline"
size="icon-sm"
aria-label="复制"
onClick={async () => {
try {
await navigator.clipboard.writeText(plaintext ?? "");
setCopied(true);
toast.success("已复制");
} catch {
toast.error("复制失败,手动选中复制");
}
}}
>
{copied ? <Check /> : <Copy />}
</Button>
</div>
<DialogFooter>
<Button onClick={onClose}>我已保存</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export function ApiKeysSection() {
const keys = useApiKeys();
const create = useCreateApiKey();
const revoke = useRevokeApiKey();
const [open, setOpen] = useState(false);
const [name, setName] = useState("");
const [scopes, setScopes] = useState<ApiScope[]>(["read"]);
const [errors, setErrors] = useState<Record<string, string>>({});
const [plaintext, setPlaintext] = useState<string | null>(null);
const [revoking, setRevoking] = useState<ApiKey | null>(null);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
const parsed = ApiKeyCreate.safeParse({ name: name.trim(), scopes });
if (!parsed.success) {
const fe = zodFieldErrors(parsed.error.issues);
if (fe.name) fe.name = "名称必填,80 字以内";
if (fe.scopes) fe.scopes = "至少选一个权限范围";
setErrors(fe);
return;
}
setErrors({});
try {
const r = await create.mutateAsync(parsed.data);
setOpen(false);
setName("");
setScopes(["read"]);
setPlaintext(r.plaintextKey);
} catch (err) {
toast.error(errorMessage(err, "创建失败"));
}
};
return (
<SettingsBlock
section="api-keys"
anchor="keys"
title="已签发的 Key"
description="给媒体服务器插件、脚本等外部程序用,按权限范围授权。本机 Token 在「账户与安全」。"
plain
actions={
<Button size="sm" onClick={() => setOpen(true)}>
<Plus />
创建 API Key
</Button>
}
>
<ScopeLegend />
<DataTable
rows={keys.data?.items}
rowKey={(r) => r.id}
loading={keys.isPending}
error={keys.error}
onRetry={() => keys.refetch()}
empty={{ title: "还没有 API Key" }}
columns={[
{
key: "name",
header: "名称",
cell: (r) => (
<div className="min-w-0">
<p className={cn("font-medium", r.revokedAt && "text-muted-foreground line-through")}>{r.name}</p>
<p className="text-meta text-muted-foreground">创建于 {formatDateTime(r.createdAt)}</p>
</div>
),
},
{
key: "scopes",
header: "权限",
hideBelow: "sm",
cell: (r) => (
<div className="flex flex-wrap gap-1">
{r.scopes.map((s) => (
<code key={s} className="rounded-tight bg-foreground/[0.06] px-1.5 py-0.5 text-meta">
{s}
</code>
))}
</div>
),
},
{ key: "used", header: "最近使用", hideBelow: "md", cell: (r) => formatRelative(r.lastUsedAt) },
{
key: "status",
header: "状态",
cell: (r) => (
<StatusBadge tone={r.revokedAt ? "neutral" : "success"}>{r.revokedAt ? "已撤销" : "有效"}</StatusBadge>
),
},
{
key: "act",
header: <span className="sr-only">操作</span>,
align: "right",
cell: (r) =>
r.revokedAt ? null : (
<Button variant="ghost" size="sm" className="hover:text-destructive" onClick={() => setRevoking(r)}>
撤销
</Button>
),
},
]}
/>
<Dialog open={open} onOpenChange={(o) => !create.isPending && setOpen(o)}>
<DialogContent>
<DialogHeader>
<DialogTitle>创建 API Key</DialogTitle>
<DialogDescription>创建后明文只显示一次。</DialogDescription>
</DialogHeader>
<form id="apikey-form" onSubmit={submit} noValidate className="grid gap-1">
<FormField label="名称" htmlFor="ak-name" error={errors.name} required>
<Input
id="ak-name"
value={name}
onChange={(e) => setName(e.target.value)}
aria-invalid={!!errors.name}
placeholder="emby-plugin"
/>
</FormField>
<FormField label="权限范围" error={errors.scopes}>
<div className="grid gap-1.5">
{SCOPES.map((s) => {
const on = scopes.includes(s.value);
return (
<label
key={s.value}
className="flex cursor-pointer items-center gap-2.5 rounded-lg px-1 py-1 text-sm hover:bg-foreground/[0.04]"
>
<input
type="checkbox"
className="h-4 w-4 accent-current"
checked={on}
onChange={() => setScopes((xs) => (on ? xs.filter((x) => x !== s.value) : [...xs, s.value]))}
/>
<span className="text-caption">{s.label}</span>
<span className="text-meta text-muted-foreground">{s.hint}</span>
</label>
);
})}
</div>
</FormField>
</form>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)} disabled={create.isPending}>
取消
</Button>
<Button type="submit" form="apikey-form" disabled={create.isPending}>
{create.isPending ? <LoaderCircle className="animate-spin" /> : null}
创建
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<CreatedKeyDialog plaintext={plaintext} onClose={() => setPlaintext(null)} />
<ConfirmDialog
open={!!revoking}
onOpenChange={(o) => !o && setRevoking(null)}
title={`撤销「${revoking?.name ?? ""}」?`}
description="使用这个 Key 的程序立即失去访问权限,无法恢复。"
confirmLabel="撤销 Key"
destructive
onConfirm={async () => {
if (!revoking) return;
try {
await revoke.mutateAsync(revoking.id);
toast.success("已撤销");
} catch (err) {
toast.error(errorMessage(err, "撤销失败"));
throw err;
}
}}
/>
</SettingsBlock>
);
}
function ScopeLegend() {
return (
<dl className="mb-3 grid gap-x-4 gap-y-1 text-meta sm:grid-cols-[auto_1fr]">
{SCOPES.map((s) => (
<div key={s.value} className="contents">
<dt>
<span className="rounded-tight bg-foreground/[0.06] px-1.5 py-0.5">{s.label}</span>
</dt>
<dd className="text-muted-foreground">{s.hint}</dd>
</div>
))}
</dl>
);
}