Files
UHD_Media_Tool/apps/web/app/components/sites/site-accounts-tab.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

265 lines
10 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 { 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 { StatusBadge } from "~/components/console/status-badge.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Input } from "~/components/ui/input.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("填写 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">
<Input
id="pk"
type="password"
autoComplete="off"
value={v}
onChange={(e) => setV(e.target.value)}
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>
);
}