feat(web): site center page, add-site wizard, credentials, health and user-data chart (FE-2)
- 资源 → 站点:卡片 / 列表切换(URL ?view= + localStorage 记住),筛选全部 / 已启用 / 需处理, 按分享率 / 上传 / 上次登录排序;卡片显示账号状态、凭证类型、上次登录、上传 / 下载 / 分享率、 等级 / VIP、做种、健康检测结果与时间、快照、限流 - 操作:检测健康、刷新登录(wait:true 同步结果)、更新凭证、编辑(限流 / 快照 / 代理)、启停、 删除(确认框列出影响) - 添加站点向导:按适配器分组选 23 个定义(未实测标注)→ 名称 / 域名覆盖 / 凭证三选一 (馒头默认 API Key)→ 保存并测试(刷新登录 + 健康检测);验证码 / 二次验证提示改用 Cookie 并链接待处理 - 站点详情:概览(用户数据磁贴 + 30 天上传 / 下载与分享率折线,recharts,无彩)/ 资源 / 快照与 Feed / 账号(凭证管理、Passkey、登录历史) - 设置"站点与账号"改为"站点默认"(限流、快照周期、UA、代理、做种页数) - 侧栏改名"站点";smoke 补站点路由;依赖新增 recharts Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
81bf010e3e
commit
f8362a3f4c
21 files changed
+3083
-1126
No files matched your search
@@ -0,0 +1,264 @@
|
||||
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</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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user