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:
rootandClaude Opus 5.5 committed 2026-09-28 16:08:29 +08:00
1 parent 81bf010e3e
commit f8362a3f4c
21 files changed
+3083 -1126

No files matched your search

@@ -0,0 +1,213 @@
import type { SiteCredentialsBody } from "@umt/contracts";
import { FormField } from "~/components/console/form-field.tsx";
import { Input } from "~/components/ui/input.tsx";
import { Switch } from "~/components/ui/switch.tsx";
import { Textarea } from "~/components/ui/textarea.tsx";
import { cn } from "~/lib/utils.ts";
import { AUTH_KIND_LABEL, type LoginKind } from "./site-fields.ts";
// 凭证三选一(账号密码 / Cookie / API Key)。只写不回显;添加站点向导与"更新凭证"对话框共用。
export type CredentialDraft = {
kind: LoginKind;
username: string;
password: string;
totpSecret: string;
saveCookieOnly: boolean;
cookie: string;
apiKey: string;
userAgent: string;
};
export const emptyCredential = (kind: LoginKind): CredentialDraft => ({
kind,
username: "",
password: "",
totpSecret: "",
saveCookieOnly: false,
cookie: "",
apiKey: "",
userAgent: "",
});
/** 草稿 → 请求体;缺必填返回字段错误 */
export function credentialBody(
d: CredentialDraft,
label: string,
): { body: SiteCredentialsBody; errors: null } | { body: null; errors: Record<string, string> } {
const ua = d.userAgent.trim() || undefined;
if (d.kind === "password") {
const errors: Record<string, string> = {};
if (!d.username.trim()) errors.username = "填写登录名";
if (!d.password) errors.password = "填写密码";
if (Object.keys(errors).length) return { body: null, errors };
return {
body: {
kind: "password",
label,
username: d.username.trim(),
password: d.password,
totpSecret: d.totpSecret.trim() || undefined,
saveCookieOnly: d.saveCookieOnly || undefined,
userAgent: ua,
},
errors: null,
};
}
if (d.kind === "cookie") {
if (!d.cookie.trim()) return { body: null, errors: { cookie: "粘贴 Cookie" } };
return { body: { kind: "cookie", label, cookie: d.cookie.trim(), userAgent: ua }, errors: null };
}
if (!d.apiKey.trim()) return { body: null, errors: { apiKey: "填写 API Key" } };
return { body: { kind: "api_key", label, apiKey: d.apiKey.trim() }, errors: null };
}
export function CredentialFields({
value,
onChange,
kinds,
errors,
disabled,
idPrefix = "cred",
}: {
value: CredentialDraft;
onChange: (v: CredentialDraft) => void;
kinds: LoginKind[];
errors: Record<string, string>;
disabled?: boolean;
idPrefix?: string;
}) {
const set = <K extends keyof CredentialDraft>(k: K, v: CredentialDraft[K]) => onChange({ ...value, [k]: v });
const id = (s: string) => `${idPrefix}-${s}`;
return (
<div className="grid gap-x-4 gap-y-1">
{kinds.length > 1 ? (
<div className="mb-4" role="radiogroup" aria-label="凭证类型">
<p className="mb-1.5 text-caption font-medium text-foreground">凭证类型</p>
<div className="inline-flex flex-wrap gap-1 rounded-xl border border-border p-1">
{kinds.map((k) => (
// biome-ignore lint/a11y/useSemanticElements: 分段按钮,radio 语义由 role 表达
<button
key={k}
type="button"
role="radio"
aria-checked={value.kind === k}
disabled={disabled}
onClick={() => set("kind", k)}
className={cn(
"rounded-lg px-3 py-1.5 text-caption transition-colors",
value.kind === k
? "bg-foreground text-background"
: "text-muted-foreground hover:bg-accent hover:text-foreground",
)}
>
{AUTH_KIND_LABEL[k]}
</button>
))}
</div>
</div>
) : null}
{value.kind === "password" ? (
<>
<div className="grid gap-x-4 sm:grid-cols-2">
<FormField label="登录名" htmlFor={id("user")} required error={errors.username}>
<Input
id={id("user")}
autoComplete="off"
value={value.username}
aria-invalid={!!errors.username}
onChange={(e) => set("username", e.target.value)}
disabled={disabled}
/>
</FormField>
<FormField label="密码" htmlFor={id("pass")} required error={errors.password}>
<Input
id={id("pass")}
type="password"
autoComplete="new-password"
value={value.password}
aria-invalid={!!errors.password}
onChange={(e) => set("password", e.target.value)}
disabled={disabled}
/>
</FormField>
</div>
<FormField
label="TOTP 密钥"
htmlFor={id("totp")}
hint="站点开了二次验证时填 base32 密钥,登录时自动生成 6 位码"
>
<Input
id={id("totp")}
type="password"
autoComplete="off"
value={value.totpSecret}
onChange={(e) => set("totpSecret", e.target.value)}
disabled={disabled}
/>
</FormField>
<div className="mb-4 flex items-start gap-3">
<Switch
id={id("cookie-only")}
checked={value.saveCookieOnly}
onCheckedChange={(v) => set("saveCookieOnly", v)}
disabled={disabled}
/>
<label htmlFor={id("cookie-only")} className="text-caption">
<span className="block font-medium text-foreground">只换 Cookie</span>
<span className="block text-muted-foreground">
密码加密保存,用于 Cookie 失效后自动重新登录。打开后登录成功只留 Cookie,不保存密码
</span>
</label>
</div>
</>
) : value.kind === "cookie" ? (
<FormField
label="Cookie"
htmlFor={id("cookie")}
required
error={errors.cookie}
hint="浏览器登录站点后,从开发者工具复制请求头里的整段 Cookie"
>
<Textarea
id={id("cookie")}
rows={3}
className="font-mono text-caption"
value={value.cookie}
aria-invalid={!!errors.cookie}
onChange={(e) => set("cookie", e.target.value)}
disabled={disabled}
/>
</FormField>
) : (
<FormField label="API Key" htmlFor={id("apikey")} required error={errors.apiKey} hint="在站点控制台生成">
<Input
id={id("apikey")}
type="password"
autoComplete="off"
value={value.apiKey}
aria-invalid={!!errors.apiKey}
onChange={(e) => set("apiKey", e.target.value)}
disabled={disabled}
/>
</FormField>
)}
{value.kind !== "api_key" ? (
<FormField
label="User-Agent"
htmlFor={id("ua")}
hint={value.kind === "cookie" ? "Cookie 与浏览器 UA 绑定时填写;留空用站点默认" : "留空用站点默认"}
>
<Input
id={id("ua")}
value={value.userAgent}
onChange={(e) => set("userAgent", e.target.value)}
disabled={disabled}
/>
</FormField>
) : null}
</div>
);
}
@@ -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>
);
}
@@ -0,0 +1,395 @@
import type { SiteDefinitionView, SiteHealthResult, SiteListItem, SiteLoginResult } from "@umt/contracts";
import { ArrowLeft, Check, LoaderCircle, Search } from "lucide-react";
import { useMemo, useState } from "react";
import { useNavigate } from "react-router";
import { toast } from "sonner";
import {
useCreateSite,
useHealthCheck,
usePutCredentials,
useRefreshLogin,
useSiteDefinitions,
useUpdateSite,
} from "~/api/sites.ts";
import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { FormField } from "~/components/console/form-field.tsx";
import { consolePath } from "~/components/console/nav.ts";
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 { cn } from "~/lib/utils.ts";
import { type CredentialDraft, CredentialFields, credentialBody, emptyCredential } from "./credential-fields.tsx";
import { LoginResultView } from "./site-dialogs.tsx";
import { ADAPTER_LABEL, hostOf, type LoginKind, supportedKinds } from "./site-fields.ts";
// 添加站点向导(site-center §4.3):① 选定义 → ② 名称 / 域名 / 凭证 → ③ 测试(保存站点与凭证,刷新登录 + 检测健康)
const CUSTOM = "__custom_rss";
type Step = 1 | 2 | 3;
const STEP_LABEL = ["选择站点", "填写凭证", "测试"] as const;
const GROUP_ORDER = ["nexusphp", "mteam_api", "api", "generic_rss"] as const;
/** 馒头默认 API Key,其余默认 Cookie(验证码站点账密大概率失败) */
function defaultKind(kinds: LoginKind[]): LoginKind {
if (kinds.includes("api_key") && !kinds.includes("cookie")) return "api_key";
return kinds.includes("cookie") ? "cookie" : (kinds[0] ?? "cookie");
}
export function SiteAddDialog({ onClose }: { onClose: () => void }) {
const defs = useSiteDefinitions();
const navigate = useNavigate();
const [step, setStep] = useState<Step>(1);
const [q, setQ] = useState("");
const [defKey, setDefKey] = useState<string | null>(null);
const [form, setForm] = useState({ name: "", baseUrl: "", key: "" });
const [cred, setCred] = useState<CredentialDraft>(emptyCredential("cookie"));
const [errors, setErrors] = useState<Record<string, string>>({});
const [created, setCreated] = useState<SiteListItem | null>(null);
const [login, setLogin] = useState<SiteLoginResult | null>(null);
const [health, setHealth] = useState<SiteHealthResult | null>(null);
const [phase, setPhase] = useState<string | null>(null);
const create = useCreateSite();
const update = useUpdateSite();
const put = usePutCredentials();
const refresh = useRefreshLogin();
const check = useHealthCheck();
const items = defs.data?.items ?? [];
const def = items.find((d) => d.key === defKey);
const custom = defKey === CUSTOM;
const kinds: LoginKind[] = custom ? [] : supportedKinds(def);
const busy = phase !== null;
const groups = useMemo(() => {
const needle = q.trim().toLowerCase();
const hit = (d: SiteDefinitionView) =>
!needle || d.name.toLowerCase().includes(needle) || d.key.includes(needle) || d.baseUrl.includes(needle);
return GROUP_ORDER.map((a) => ({
adapter: a,
items: items.filter((d) => d.adapter === a && hit(d)).sort((x, y) => x.name.localeCompare(y.name, "zh-CN")),
})).filter((g) => g.items.length);
}, [items, q]);
const pick = (key: string) => {
const d = items.find((x) => x.key === key);
setDefKey(key);
setForm(d ? { name: d.name, baseUrl: d.baseUrl, key: d.key } : { name: "", baseUrl: "", key: "" });
setCred(emptyCredential(defaultKind(d ? supportedKinds(d) : [])));
setErrors({});
setStep(2);
};
const validate = () => {
const fe: Record<string, string> = {};
if (!form.name.trim()) fe.name = "填写名称";
try {
const u = new URL(form.baseUrl.trim());
if (!/^https?:$/.test(u.protocol)) throw new Error();
} catch {
fe.baseUrl = "填完整地址,如 https://example.org";
}
if (custom && !/^[a-z][a-z0-9_-]*$/.test(form.key.trim())) fe.key = "小写字母开头,只含小写字母、数字、- 和 _";
let body = null;
if (kinds.length) {
// 账号标签用站点标识:服务端 secret 名按标签生成且全局唯一,多站都叫 default 会冲突
const b = credentialBody(cred, (custom ? form.key.trim() : def?.key) || "default");
if (b.errors) Object.assign(fe, b.errors);
body = b.body;
}
setErrors(fe);
return Object.keys(fe).length ? null : { body };
};
/** 保存站点(已建则按表单更新)→ 写凭证 → 刷新登录 → 检测健康 */
const test = async () => {
const v = validate();
if (!v) return;
setLogin(null);
setHealth(null);
try {
setPhase("保存站点…");
let site = created;
const base = { name: form.name.trim(), baseUrl: form.baseUrl.trim() };
if (!site) {
site = await create.mutateAsync({
key: custom ? form.key.trim() : (def?.key ?? form.key),
adapter: custom ? "generic_rss" : (def?.adapter ?? "nexusphp"),
snapshotEnabled: !custom,
...base,
});
setCreated(site);
} else if (site.name !== base.name || site.baseUrl !== base.baseUrl) {
site = await update.mutateAsync({ id: site.id, body: base });
setCreated(site);
}
setStep(3);
if (v.body) {
setPhase("保存凭证…");
const saved = await put.mutateAsync({ siteId: site.id, body: v.body });
setPhase("登录中…");
const r = await refresh.mutateAsync({ id: site.id, accountId: saved.siteAccountId });
setLogin(r.result);
}
setPhase("检测健康…");
setHealth(await check.mutateAsync(site.id).catch(() => null));
} catch (err) {
toast.error(errorMessage(err, "保存失败"));
} finally {
setPhase(null);
}
};
const finish = () => {
onClose();
if (created) navigate(consolePath("sites", created.id));
};
return (
<Dialog open onOpenChange={(o) => !o && !busy && onClose()}>
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle>添加站点</DialogTitle>
<DialogDescription asChild>
<ol className="flex flex-wrap items-center gap-x-3 gap-y-1 text-meta">
{STEP_LABEL.map((l, i) => (
<li
key={l}
className={cn(
"flex items-center gap-1.5",
step === i + 1 ? "font-medium text-foreground" : "text-muted-foreground",
)}
>
<span
className={cn(
"grid h-5 w-5 place-items-center rounded-full border text-micro",
step > i + 1
? "border-foreground bg-foreground text-background"
: step === i + 1
? "border-foreground"
: "border-border",
)}
>
{step > i + 1 ? <Check className="h-3 w-3" /> : i + 1}
</span>
{l}
</li>
))}
</ol>
</DialogDescription>
</DialogHeader>
{step === 1 ? (
<div className="min-w-0">
<div className="relative mb-3">
<Search className="pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
autoFocus
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="搜索站点名或域名"
className="pl-9"
aria-label="搜索站点定义"
/>
</div>
<div className="max-h-[55vh] space-y-4 overflow-y-auto pr-1">
{defs.isPending ? (
<LoadingState rows={5} />
) : defs.error ? (
<ErrorState plain error={defs.error} onRetry={() => void defs.refetch()} />
) : (
<>
{groups.map((g) => (
<section key={g.adapter}>
<h3 className="mb-1.5 text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase">
{ADAPTER_LABEL[g.adapter]} · {g.items.length}
</h3>
<ul className="grid gap-1.5 sm:grid-cols-2">
{g.items.map((d) => {
const taken = !!d.siteId;
return (
<li key={d.key}>
<button
type="button"
disabled={taken}
onClick={() => pick(d.key)}
className="flex w-full min-w-0 items-center justify-between gap-2 rounded-lg border border-border/70 px-3 py-2 text-left transition-colors hover:border-foreground/40 hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-transparent"
>
<span className="min-w-0">
<span className="block truncate text-caption font-medium text-foreground">
{d.name}
</span>
<span className="block truncate text-meta text-muted-foreground">
{hostOf(d.baseUrl)}
</span>
</span>
{taken ? (
<span className="shrink-0 text-meta text-muted-foreground">已添加</span>
) : !d.shippedEnabled ? (
<StatusBadge tone="warning" className="shrink-0">
未实测
</StatusBadge>
) : null}
</button>
</li>
);
})}
</ul>
</section>
))}
{!groups.length ? (
<p className="py-6 text-center text-caption text-muted-foreground">没有匹配的站点定义</p>
) : null}
<section>
<h3 className="mb-1.5 text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase">
未适配站点
</h3>
<button
type="button"
onClick={() => pick(CUSTOM)}
className="w-full rounded-lg border border-dashed border-border px-3 py-2 text-left text-caption text-muted-foreground hover:border-foreground/40 hover:text-foreground"
>
自定义 RSS 站点:只抓 Feed,不登录
</button>
</section>
</>
)}
</div>
<p className="mt-3 text-meta text-muted-foreground">
"未实测"的定义没有在真实站点上验证过,列表和登录可能失败。
</p>
</div>
) : step === 2 ? (
<form
id="site-add"
noValidate
onSubmit={(e) => {
e.preventDefault();
void test();
}}
className="max-h-[60vh] min-w-0 overflow-y-auto pr-1"
>
<div className="grid gap-x-4 sm:grid-cols-2">
<FormField label="名称" htmlFor="add-name" required error={errors.name}>
<Input
id="add-name"
value={form.name}
aria-invalid={!!errors.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
disabled={busy}
/>
</FormField>
<FormField
label="地址"
htmlFor="add-url"
required
error={errors.baseUrl}
hint={custom ? undefined : "站点换了域名时改这里"}
>
<Input
id="add-url"
inputMode="url"
placeholder="https://"
value={form.baseUrl}
aria-invalid={!!errors.baseUrl}
onChange={(e) => setForm((f) => ({ ...f, baseUrl: e.target.value }))}
disabled={busy}
/>
</FormField>
{custom ? (
<FormField label="标识" htmlFor="add-key" required error={errors.key} hint="添加后不能改">
<Input
id="add-key"
className="font-mono"
value={form.key}
aria-invalid={!!errors.key}
onChange={(e) => setForm((f) => ({ ...f, key: e.target.value }))}
disabled={busy}
/>
</FormField>
) : null}
</div>
{kinds.length ? (
<>
<div className="mt-1 mb-3 border-t border-border/60" />
<CredentialFields
value={cred}
onChange={setCred}
kinds={kinds}
errors={errors}
disabled={busy}
idPrefix="add"
/>
{kinds.length === 1 && kinds[0] === "api_key" ? (
<p className="text-meta text-muted-foreground">这个站点只支持 API Key 登录。</p>
) : null}
</>
) : (
<p className="text-caption text-muted-foreground">
不需要登录凭证。添加后在站点详情的"快照与 Feed"里新建 Feed。
</p>
)}
</form>
) : (
<div className="min-w-0 space-y-3" data-state={phase ? "running" : "done"} aria-busy={busy}>
<p className="text-caption text-muted-foreground">
{created ? `已添加「${created.name}」。` : ""}
{phase ?? (login?.ok === false ? "可以返回修改凭证后重试,或先保存,之后在站点页处理。" : "")}
</p>
{phase ? <LoadingState rows={2} /> : null}
<LoginResultView
result={login}
health={health}
onUseCookie={
kinds.includes("cookie")
? () => {
setCred(emptyCredential("cookie"));
setStep(2);
}
: undefined
}
/>
</div>
)}
<DialogFooter className="gap-2">
{step === 2 ? (
<>
<Button variant="ghost" onClick={() => setStep(1)} disabled={busy || !!created}>
<ArrowLeft />
重选站点
</Button>
<Button type="submit" form="site-add" disabled={busy}>
{busy ? <LoaderCircle className="animate-spin" /> : null}
{kinds.length ? "保存并测试" : "保存并检测"}
</Button>
</>
) : step === 3 ? (
<>
<Button variant="outline" onClick={() => setStep(2)} disabled={busy}>
<ArrowLeft />
返回修改
</Button>
<Button onClick={finish} disabled={busy}>
完成
</Button>
</>
) : (
<Button variant="outline" onClick={onClose}>
取消
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+455
View File
@@ -0,0 +1,455 @@
import type { SiteListItem } from "@umt/contracts";
import {
Activity,
Camera,
Ellipsis,
ExternalLink,
KeyRound,
LoaderCircle,
Pencil,
Power,
RefreshCw,
Trash2,
} from "lucide-react";
import { type ReactNode, useState } from "react";
import { Link } from "react-router";
import { toast } from "sonner";
import { snapshotStale, useSnapshotSite, useToggleSite } from "~/api/sites.ts";
import { DataTable } from "~/components/console/data-table.tsx";
import { consolePath } from "~/components/console/nav.ts";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover.tsx";
import { Switch } from "~/components/ui/switch.tsx";
import { circuitLabel, relTime } from "~/components/workbench/release-fields.ts";
import { errorMessage } from "~/lib/api.ts";
import { cn } from "~/lib/utils.ts";
import type { SiteDialog, useSiteChecks } from "./site-dialogs.tsx";
import {
ACCOUNT_OVERALL,
AUTH_KIND_LABEL,
bytes,
dataAccount,
healthLine,
healthMessage,
hostOf,
loginFailureText,
overallStatus,
primaryAccount,
rateLimitText,
ratioText,
} from "./site-fields.ts";
type Checks = ReturnType<typeof useSiteChecks>;
type Props = { site: SiteListItem; checks: Checks; onDialog: (d: SiteDialog) => void };
/** 按凭证类型给"更新 xx"文案 */
function updateCredLabel(site: SiteListItem): string {
const k = primaryAccount(site.accounts)?.authKind;
if (k === "api_key") return "更新 API Key";
if (k === "password") return "更新密码";
if (k === "cookie") return "更新 Cookie";
return "填写凭证";
}
function EnableSwitch({ site, withLabel }: { site: SiteListItem; withLabel?: boolean }) {
const toggle = useToggleSite();
return (
<span className="flex shrink-0 items-center gap-2 text-meta text-muted-foreground">
{withLabel ? <label htmlFor={`site-en-${site.id}`}>{site.enabled ? "已启用" : "已停用"}</label> : null}
<Switch
id={`site-en-${site.id}`}
checked={site.enabled}
disabled={toggle.isPending}
aria-label={`${site.enabled ? "停用" : "启用"}${site.name}`}
onCheckedChange={(v) =>
toggle.mutate(
{ id: site.id, enabled: v },
{
onSuccess: () => toast.success(v ? `已启用 ${site.name}` : `已停用 ${site.name}`),
onError: (e) => toast.error(errorMessage(e)),
},
)
}
/>
</span>
);
}
/** "更多"菜单:更新凭证、编辑、快照、启停、删除 */
export function SiteMoreMenu({ site, onDialog }: { site: SiteListItem; onDialog: (d: SiteDialog) => void }) {
const [open, setOpen] = useState(false);
const snap = useSnapshotSite();
const toggle = useToggleSite();
const item = (
icon: ReactNode,
label: string,
onClick: () => void,
opts: { danger?: boolean; disabled?: boolean } = {},
) => (
<button
type="button"
role="menuitem"
disabled={opts.disabled}
onClick={() => {
setOpen(false);
onClick();
}}
className={cn(
"flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-caption transition-colors hover:bg-accent disabled:opacity-50 [&_svg]:h-3.5 [&_svg]:w-3.5",
opts.danger ? "text-destructive" : "text-foreground",
)}
>
{icon}
{label}
</button>
);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="ghost" size="icon-sm" aria-label={`${site.name} 更多操作`}>
<Ellipsis />
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-44 p-1.5" role="menu">
{item(<KeyRound />, updateCredLabel(site), () => onDialog({ kind: "credentials", site }))}
{item(<Pencil />, "编辑站点", () => onDialog({ kind: "edit", site }))}
{item(
<Camera />,
"抓取快照",
() =>
snap.mutate(
{ id: site.id },
{ onSuccess: () => toast.success("快照已排队"), onError: (e) => toast.error(errorMessage(e)) },
),
{ disabled: !site.enabled },
)}
{item(<Power />, site.enabled ? "停用站点" : "启用站点", () =>
toggle.mutate(
{ id: site.id, enabled: !site.enabled },
{
onSuccess: () => toast.success(site.enabled ? `已停用 ${site.name}` : `已启用 ${site.name}`),
onError: (e) => toast.error(errorMessage(e)),
},
),
)}
<div className="my-1 border-t border-border/60" />
{item(<Trash2 />, "删除站点", () => onDialog({ kind: "delete", site }), { danger: true })}
</PopoverContent>
</Popover>
);
}
function CheckButtons({ site, checks, compact }: Omit<Props, "onDialog"> & { compact?: boolean }) {
const h = checks.isBusy(site.id, "health");
const l = checks.isBusy(site.id, "login");
const hasAccount = !!site.accounts?.some((a) => a.enabled && a.authKind !== "passkey");
return (
<>
<Button
variant="outline"
size="sm"
disabled={h || !site.enabled}
title={site.enabled ? "首页连通 + 登录态,走站点限流" : "站点已停用"}
onClick={() => void checks.checkHealth(site)}
>
{h ? <LoaderCircle className="animate-spin" /> : <Activity />}
{compact ? <span className="sr-only sm:not-sr-only">检测健康</span> : "检测健康"}
</Button>
<Button
variant="outline"
size="sm"
disabled={l || !hasAccount}
title={hasAccount ? "用凭证刷新登录状态并拉取用户数据" : "先填写凭证"}
onClick={() => void checks.refreshLogin(site)}
>
{l ? <LoaderCircle className="animate-spin" /> : <RefreshCw />}
{compact ? <span className="sr-only sm:not-sr-only">刷新登录</span> : "刷新登录"}
</Button>
</>
);
}
function AccountBadges({ site }: { site: SiteListItem }) {
const st = ACCOUNT_OVERALL[overallStatus(site)];
const acc = primaryAccount(site.accounts);
const hasPasskey = site.accounts?.some((a) => a.hasPasskey || a.authKind === "passkey");
return (
<>
<StatusBadge tone={st.tone}>账号{st.label}</StatusBadge>
{acc && acc.authKind !== "passkey" ? (
<span className="rounded-full border border-border px-2 py-0.5 text-meta text-muted-foreground">
{AUTH_KIND_LABEL[acc.authKind]}
</span>
) : null}
{hasPasskey ? (
<span className="rounded-full border border-border px-2 py-0.5 text-meta text-muted-foreground">Passkey</span>
) : null}
</>
);
}
function Stat({ label, children, className }: { label: string; children: ReactNode; className?: string }) {
return (
<div className={cn("min-w-0", className)}>
<dt className="text-meta text-muted-foreground">{label}</dt>
<dd className="truncate text-caption font-medium text-foreground tabular-nums">{children}</dd>
</div>
);
}
export function SiteCard({ site, checks, onDialog }: Props) {
const acc = primaryAccount(site.accounts);
const dAcc = dataAccount(site.accounts);
const u = dAcc?.userData ?? null;
const health = healthLine(site.lastHealth, site.lastHealthAt);
const hmsg = healthMessage(site.lastHealth);
const stale = site.snapshotEnabled && snapshotStale(site);
const circuit = circuitLabel(site);
const lastErr = acc && acc.status !== "active" ? acc.lastError : null;
return (
<article className={cn("pg-slab flex min-w-0 flex-col rounded-xl p-4", !site.enabled && "opacity-75")}>
<header className="flex items-start justify-between gap-3">
<div className="min-w-0">
<Link
to={consolePath("sites", site.id)}
className="block truncate text-lead font-semibold text-foreground hover:underline"
>
{site.name}
</Link>
<a
href={site.baseUrl}
target="_blank"
rel="noreferrer noopener"
className="inline-flex max-w-full items-center gap-1 truncate text-meta text-muted-foreground hover:text-foreground"
>
<span className="truncate">{hostOf(site.baseUrl)}</span>
<ExternalLink className="h-3 w-3 shrink-0" />
</a>
</div>
<EnableSwitch site={site} withLabel />
</header>
<div className="mt-3 flex flex-wrap items-center gap-1.5">
<AccountBadges site={site} />
{u?.vip ? <StatusBadge tone="neutral">VIP</StatusBadge> : null}
{circuit ? <StatusBadge tone="warning">{circuit}</StatusBadge> : null}
</div>
{lastErr ? (
<p className="mt-2 line-clamp-2 text-meta text-warning" title={lastErr.message}>
{loginFailureText(lastErr.reason, lastErr.message, acc?.authKind).text}
</p>
) : null}
<div className="mt-3 rounded-lg border border-border/60 px-3 py-2.5">
{u ? (
<>
<dl className="grid grid-cols-3 gap-x-3 gap-y-2">
<Stat label="上传">{bytes(u.upload)}</Stat>
<Stat label="下载">{bytes(u.download)}</Stat>
<Stat label="分享率">{ratioText(u)}</Stat>
<Stat label="等级">{u.userLevel ?? "—"}</Stat>
<Stat label="做种">
{u.seeding}
{u.seedingSize ? <span className="text-muted-foreground"> · {bytes(u.seedingSize)}</span> : null}
</Stat>
{u.bonus !== null ? (
<Stat label="魔力">{u.bonus.toLocaleString("zh-CN", { maximumFractionDigits: 1 })}</Stat>
) : (
<Stat label="未读消息">{u.messageUnread || "—"}</Stat>
)}
</dl>
<p className="mt-2 flex flex-wrap justify-between gap-x-3 text-meta text-muted-foreground">
<span>{u.username ?? ""}</span>
<span>
{u.messageUnread > 0 && u.bonus !== null ? `${u.messageUnread} 条未读 · ` : ""}数据{" "}
{relTime(dAcc?.userDataAt)}
</span>
</p>
</>
) : (
<p className="py-2 text-caption text-muted-foreground">
{acc ? "还没有用户数据。刷新登录后显示" : "还没有凭证。填写凭证后显示账号数据"}
</p>
)}
</div>
<dl className="mt-3 grid grid-cols-2 gap-x-4 gap-y-2 text-caption">
<div className="min-w-0">
<dt className="text-meta text-muted-foreground">上次登录</dt>
<dd className="text-foreground">{relTime(acc?.lastLoginAt)}</dd>
</div>
<div className="min-w-0">
<dt className="text-meta text-muted-foreground">健康检测</dt>
<dd className="truncate" title={hmsg ?? undefined}>
<StatusDot tone={health.tone} />
{health.text}
</dd>
</div>
<div className="min-w-0">
<dt className="text-meta text-muted-foreground">快照</dt>
<dd className={cn("text-foreground", stale && "text-warning")}>
{!site.snapshotEnabled
? "未开启"
: site.lastSnapshotAt
? `${relTime(site.lastSnapshotAt)}${stale ? " · 已过期" : ""}`
: "还没有快照"}
</dd>
</div>
<div className="min-w-0">
<dt className="text-meta text-muted-foreground">限流</dt>
<dd className="truncate text-foreground">{rateLimitText(site.rateLimit)}</dd>
</div>
</dl>
<footer className="mt-4 flex flex-wrap items-center justify-end gap-2 border-t border-border/60 pt-3">
{acc ? (
<CheckButtons site={site} checks={checks} />
) : (
<Button size="sm" onClick={() => onDialog({ kind: "credentials", site })}>
<KeyRound />
填写凭证
</Button>
)}
<SiteMoreMenu site={site} onDialog={onDialog} />
</footer>
</article>
);
}
function StatusDot({ tone }: { tone: "success" | "warning" | "destructive" | "neutral" }) {
return (
<span
aria-hidden
className={cn(
"mr-1.5 inline-block h-1.5 w-1.5 rounded-full align-middle",
tone === "success"
? "bg-success"
: tone === "warning"
? "bg-warning"
: tone === "destructive"
? "bg-destructive"
: "bg-muted-foreground",
)}
/>
);
}
/** 列表视图:同字段,窄屏隐藏次要列 */
export function SiteTable({
sites,
checks,
onDialog,
}: {
sites: SiteListItem[];
checks: Checks;
onDialog: (d: SiteDialog) => void;
}) {
return (
<DataTable
rows={sites}
rowKey={(r) => r.id}
columns={[
{
key: "name",
header: "站点",
cell: (s) => (
<div className="min-w-0">
<Link to={consolePath("sites", s.id)} className="font-medium text-foreground hover:underline">
{s.name}
</Link>
<p className="truncate text-meta text-muted-foreground">{hostOf(s.baseUrl)}</p>
</div>
),
},
{
key: "account",
header: "账号",
cell: (s) => (
<div className="flex flex-wrap items-center gap-1">
<AccountBadges site={s} />
{dataAccount(s.accounts)?.userData?.vip ? <StatusBadge>VIP</StatusBadge> : null}
</div>
),
},
{
key: "login",
header: "上次登录",
hideBelow: "lg",
cell: (s) => (
<span className="text-muted-foreground">{relTime(primaryAccount(s.accounts)?.lastLoginAt)}</span>
),
},
{
key: "traffic",
header: "上传 / 下载",
hideBelow: "md",
cell: (s) => {
const u = dataAccount(s.accounts)?.userData;
return u ? (
<span className="whitespace-nowrap tabular-nums">
{bytes(u.upload)} / {bytes(u.download)}
</span>
) : (
"—"
);
},
},
{
key: "ratio",
header: "分享率",
hideBelow: "sm",
align: "right",
cell: (s) => <span className="tabular-nums">{ratioText(dataAccount(s.accounts)?.userData)}</span>,
},
{
key: "level",
header: "等级 · 做种",
hideBelow: "lg",
cell: (s) => {
const u = dataAccount(s.accounts)?.userData;
return u ? `${u.userLevel ?? "—"} · ${u.seeding}` : "—";
},
},
{
key: "health",
header: "健康检测",
hideBelow: "md",
cell: (s) => {
const h = healthLine(s.lastHealth, s.lastHealthAt);
return (
<span className="text-caption" title={healthMessage(s.lastHealth) ?? undefined}>
<StatusDot tone={h.tone} />
{h.text}
</span>
);
},
},
{
key: "snapshot",
header: "快照",
hideBelow: "lg",
cell: (s) => (
<span className={s.snapshotEnabled && snapshotStale(s) ? "text-warning" : "text-muted-foreground"}>
{!s.snapshotEnabled ? "未开启" : s.lastSnapshotAt ? relTime(s.lastSnapshotAt) : "还没有"}
</span>
),
},
{
key: "actions",
header: <span className="sr-only">操作</span>,
align: "right",
cell: (s) => (
<div className="flex items-center justify-end gap-1.5">
<EnableSwitch site={s} />
{primaryAccount(s.accounts) ? <CheckButtons site={s} checks={checks} compact /> : null}
<SiteMoreMenu site={s} onDialog={onDialog} />
</div>
),
},
]}
/>
);
}
@@ -0,0 +1,534 @@
import type { SiteAccount, SiteHealthResult, SiteListItem, SiteLoginResult } from "@umt/contracts";
import { CircleCheck, CircleX, KeyRound, LoaderCircle, TriangleAlert } from "lucide-react";
import { useState } from "react";
import { Link } from "react-router";
import { toast } from "sonner";
import {
useDeleteSite,
useHealthCheck,
usePutCredentials,
useRefreshLogin,
useSiteDefinitions,
useUpdateSite,
} from "~/api/sites.ts";
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
import { DetailSheet } from "~/components/console/detail-sheet.tsx";
import { FormField } from "~/components/console/form-field.tsx";
import { consolePath } from "~/components/console/nav.ts";
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 { Switch } from "~/components/ui/switch.tsx";
import { errorMessage } from "~/lib/api.ts";
import { cn } from "~/lib/utils.ts";
import { CredentialFields, credentialBody, emptyCredential } from "./credential-fields.tsx";
import {
AUTH_KIND_LABEL,
bytes,
healthLine,
type LoginKind,
loginFailureText,
primaryAccount,
ratioText,
supportedKinds,
} from "./site-fields.ts";
// 站点页的对话框与动作:检测健康、刷新登录、更新凭证、编辑、删除(卡片、列表、详情页共用)
export type SiteDialog =
| { kind: "credentials"; site: SiteListItem; account?: SiteAccount | null; loginKind?: LoginKind }
| { kind: "edit"; site: SiteListItem }
| { kind: "delete"; site: SiteListItem };
/** 检测健康 + 刷新登录,结果用 toast 说 */
export function useSiteChecks(onDialog?: (d: SiteDialog) => void) {
const health = useHealthCheck();
const login = useRefreshLogin();
const [busy, setBusy] = useState<{ id: string; what: "health" | "login" } | null>(null);
const checkHealth = async (site: SiteListItem) => {
setBusy({ id: site.id, what: "health" });
try {
const r = await health.mutateAsync(site.id);
const line = healthLine(r, r.checkedAt);
if (r.ok) toast.success(`${site.name}:${line.text}`);
else toast.warning(`${site.name}:${line.text}`, { description: r.message ?? undefined });
} catch (e) {
toast.error(errorMessage(e, "检测失败"));
} finally {
setBusy(null);
}
};
const refreshLogin = async (site: SiteListItem, account?: SiteAccount | null) => {
setBusy({ id: site.id, what: "login" });
try {
const r = await login.mutateAsync({ id: site.id, accountId: account?.id });
const res = r.result;
if (!res) toast.success("刷新登录已排队");
else notifyLogin(site, res, account ?? primaryAccount(site.accounts), onDialog);
} catch (e) {
toast.error(errorMessage(e, "刷新登录失败"));
} finally {
setBusy(null);
}
};
return {
checkHealth,
refreshLogin,
isBusy: (id: string, what: "health" | "login") => busy?.id === id && busy.what === what,
};
}
function notifyLogin(
site: SiteListItem,
res: SiteLoginResult,
account: SiteAccount | null,
onDialog?: (d: SiteDialog) => void,
) {
if (res.ok) {
const u = res.userData;
toast.success(`${site.name} 已登录`, {
description: u ? [u.username, u.userLevel, `分享率 ${ratioText(u)}`].filter(Boolean).join(" · ") : undefined,
});
return;
}
const f = loginFailureText(res.reason, res.message, account?.authKind);
toast.error(`${site.name}:${f.text}`, {
duration: 10_000,
action:
f.suggestCookie && onDialog
? { label: "改用 Cookie", onClick: () => onDialog({ kind: "credentials", site, account, loginKind: "cookie" }) }
: undefined,
});
}
/** 登录结果块(向导第 ③ 步、凭证对话框保存后) */
export function LoginResultView({
result,
health,
onUseCookie,
}: {
result: SiteLoginResult | null;
health?: SiteHealthResult | null;
onUseCookie?: () => void;
}) {
return (
<div className="space-y-2">
{result ? (
result.ok ? (
<ResultRow ok title="登录成功">
{result.userData ? (
<span>
{[result.userData.username, result.userData.userLevel].filter(Boolean).join(" · ")}
{result.userData.vip ? " · VIP" : ""} · 上传 {bytes(result.userData.upload)} · 分享率{" "}
{ratioText(result.userData)}
</span>
) : (
<span>{result.message ?? "没有取到用户数据"}</span>
)}
</ResultRow>
) : (
<FailureRow result={result} onUseCookie={onUseCookie} />
)
) : null}
{health ? (
<ResultRow ok={health.ok} warn={!health.ok} title="健康检测">
<span className="block">{healthLine(health, health.checkedAt).text}</span>
{!health.ok && health.message ? <span className="block">{health.message}</span> : null}
</ResultRow>
) : null}
</div>
);
}
function FailureRow({ result, onUseCookie }: { result: SiteLoginResult; onUseCookie?: () => void }) {
const f = loginFailureText(result.reason, result.message);
return (
<ResultRow ok={false} title="登录失败">
<span className="block">{f.text}</span>
{f.suggestCookie || result.decisionId ? (
<span className="mt-1.5 flex flex-wrap gap-2">
{f.suggestCookie && onUseCookie ? (
<Button type="button" size="xs" variant="outline" onClick={onUseCookie}>
<KeyRound />
改用 Cookie
</Button>
) : null}
{result.decisionId ? (
<Button asChild size="xs" variant="ghost">
<Link to={consolePath("inbox")}>查看待处理</Link>
</Button>
) : null}
</span>
) : null}
</ResultRow>
);
}
function ResultRow({
ok,
warn,
title,
children,
}: {
ok: boolean;
warn?: boolean;
title: string;
children: React.ReactNode;
}) {
const Icon = ok ? CircleCheck : warn ? TriangleAlert : CircleX;
return (
<div
className={cn(
"flex items-start gap-2 rounded-lg border px-3 py-2 text-caption",
ok
? "border-success/30 bg-success/10"
: warn
? "border-warning/40 bg-warning/10"
: "border-destructive/30 bg-destructive/10",
)}
>
<Icon
className={cn("mt-0.5 h-4 w-4 shrink-0", ok ? "text-success" : warn ? "text-warning" : "text-destructive")}
/>
<div className="min-w-0 break-words">
<p className="font-medium text-foreground">{title}</p>
<div className="text-muted-foreground">{children}</div>
</div>
</div>
);
}
/** 更新凭证:保存后立即刷新登录,结果留在对话框里 */
export function CredentialDialog({
site,
account,
loginKind,
onClose,
}: {
site: SiteListItem;
account?: SiteAccount | null;
loginKind?: LoginKind;
onClose: () => void;
}) {
const defs = useSiteDefinitions();
const def = defs.data?.items.find((d) => d.siteId === site.id || d.key === site.key);
const kinds = supportedKinds(def ?? { adapter: site.adapter, authSupported: [] });
const current = account ?? primaryAccount(site.accounts);
const initialKind: LoginKind =
loginKind ??
(current && current.authKind !== "passkey" && kinds.includes(current.authKind as LoginKind)
? (current.authKind as LoginKind)
: (kinds[0] ?? "cookie"));
const [draft, setDraft] = useState(() => emptyCredential(initialKind));
const [errors, setErrors] = useState<Record<string, string>>({});
const [result, setResult] = useState<SiteLoginResult | null>(null);
const put = usePutCredentials();
const login = useRefreshLogin();
const busy = put.isPending || login.isPending;
const shownKinds = kinds.includes(draft.kind) ? kinds : [...kinds, draft.kind];
const submit = async (e: React.FormEvent) => {
e.preventDefault();
const b = credentialBody(draft, current?.label ?? site.key);
if (!b.body) {
setErrors(b.errors);
return;
}
setErrors({});
setResult(null);
try {
const saved = await put.mutateAsync({ siteId: site.id, body: b.body });
const r = await login.mutateAsync({ id: site.id, accountId: saved.siteAccountId });
if (r.result) {
setResult(r.result);
if (r.result.ok) {
toast.success(`${site.name} 凭证已更新并登录`);
onClose();
}
} else {
toast.success("凭证已保存,刷新登录已排队");
onClose();
}
} catch (err) {
toast.error(errorMessage(err, "保存失败"));
}
};
return (
<Dialog open onOpenChange={(o) => !o && !busy && onClose()}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>更新凭证 · {site.name}</DialogTitle>
<DialogDescription>
{current
? `当前:${AUTH_KIND_LABEL[current.authKind]}。凭证加密保存,不回显;保存后立即刷新登录`
: "凭证加密保存,不回显;保存后立即刷新登录"}
</DialogDescription>
</DialogHeader>
<form id="site-cred" onSubmit={submit} noValidate>
<CredentialFields
value={draft}
onChange={setDraft}
kinds={shownKinds}
errors={errors}
disabled={busy}
idPrefix="upd"
/>
</form>
{result && !result.ok ? (
<LoginResultView
result={result}
onUseCookie={
draft.kind !== "cookie"
? () => {
setDraft(emptyCredential("cookie"));
setResult(null);
}
: undefined
}
/>
) : null}
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={busy}>
{result ? "关闭" : "取消"}
</Button>
<Button type="submit" form="site-cred" disabled={busy}>
{busy ? <LoaderCircle className="animate-spin" /> : null}
{busy ? (login.isPending ? "登录中…" : "保存中…") : "保存并登录"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
/** 编辑抽屉:名称、域名、限流(空 = 站点默认)、定时快照、代理 */
export function SiteEditSheet({ site, onClose }: { site: SiteListItem; onClose: () => void }) {
const update = useUpdateSite();
const [v, setV] = useState({
name: site.name,
baseUrl: site.baseUrl,
rpm: site.rateLimit.requestsPerMinute ? String(site.rateLimit.requestsPerMinute) : "",
concurrency: site.rateLimit.concurrency ? String(site.rateLimit.concurrency) : "",
snapshotEnabled: site.snapshotEnabled,
intervalMin: site.snapshotIntervalSec ? String(Math.round(site.snapshotIntervalSec / 60)) : "",
proxy: site.proxy,
});
const [errors, setErrors] = useState<Record<string, string>>({});
const set = <K extends keyof typeof v>(k: K, val: (typeof v)[K]) => setV((s) => ({ ...s, [k]: val }));
const busy = update.isPending;
const save = async () => {
const fe: Record<string, string> = {};
if (!v.name.trim()) fe.name = "填写名称";
try {
new URL(v.baseUrl.trim());
} catch {
fe.baseUrl = "填完整地址,如 https://example.org";
}
const posInt = (s: string, k: string, min = 1) => {
if (!s.trim()) return undefined;
const n = Number(s);
if (!Number.isInteger(n) || n < min) fe[k] = `填不小于 ${min} 的整数,留空用站点默认`;
return n;
};
const rpm = posInt(v.rpm, "rpm");
const concurrency = posInt(v.concurrency, "concurrency");
const interval = posInt(v.intervalMin, "intervalMin", 5);
if (Object.keys(fe).length) {
setErrors(fe);
return;
}
setErrors({});
const rateLimit = {
...(rpm ? { requestsPerMinute: rpm } : {}),
...(concurrency ? { concurrency } : {}),
};
try {
await update.mutateAsync({
id: site.id,
body: {
name: v.name.trim(),
baseUrl: v.baseUrl.trim(),
rateLimit,
snapshotEnabled: v.snapshotEnabled,
snapshotIntervalSec: interval ? interval * 60 : null,
proxy: v.proxy,
},
});
toast.success(`${v.name.trim()} 已保存`);
onClose();
} catch (err) {
toast.error(errorMessage(err, "保存失败"));
}
};
return (
<DetailSheet
open
onOpenChange={(o) => !o && !busy && onClose()}
title={`编辑 · ${site.name}`}
description="限流和快照周期留空时用「设置 → 站点默认」"
footer={
<>
<Button variant="outline" onClick={onClose} disabled={busy}>
取消
</Button>
<Button onClick={() => void save()} disabled={busy}>
{busy ? <LoaderCircle className="animate-spin" /> : null}
保存
</Button>
</>
}
>
<div className="grid gap-x-4 gap-y-1">
<FormField label="名称" htmlFor="se-name" required error={errors.name}>
<Input id="se-name" value={v.name} onChange={(e) => set("name", e.target.value)} disabled={busy} />
</FormField>
<FormField label="地址" htmlFor="se-url" required error={errors.baseUrl} hint="站点换域名时改这里">
<Input
id="se-url"
inputMode="url"
value={v.baseUrl}
onChange={(e) => set("baseUrl", e.target.value)}
disabled={busy}
/>
</FormField>
<div className="grid gap-x-4 sm:grid-cols-2">
<FormField label="每分钟请求数" htmlFor="se-rpm" error={errors.rpm} hint="留空用站点默认">
<Input
id="se-rpm"
inputMode="numeric"
value={v.rpm}
onChange={(e) => set("rpm", e.target.value)}
disabled={busy}
/>
</FormField>
<FormField label="并发" htmlFor="se-conc" error={errors.concurrency} hint="留空用站点默认">
<Input
id="se-conc"
inputMode="numeric"
value={v.concurrency}
onChange={(e) => set("concurrency", e.target.value)}
disabled={busy}
/>
</FormField>
</div>
<div className="mb-4 flex items-center gap-3">
<Switch
id="se-snap"
checked={v.snapshotEnabled}
onCheckedChange={(x) => set("snapshotEnabled", x)}
disabled={busy}
/>
<label htmlFor="se-snap" className="text-caption text-foreground">
定时抓取快照
</label>
</div>
{v.snapshotEnabled ? (
<FormField label="快照周期(分钟)" htmlFor="se-int" error={errors.intervalMin} hint="留空用站点默认">
<Input
id="se-int"
inputMode="numeric"
value={v.intervalMin}
onChange={(e) => set("intervalMin", e.target.value)}
disabled={busy}
/>
</FormField>
) : null}
<div className="mb-2 flex items-start gap-3">
<Switch id="se-proxy" checked={v.proxy} onCheckedChange={(x) => set("proxy", x)} disabled={busy} />
<label htmlFor="se-proxy" className="text-caption">
<span className="block font-medium text-foreground">使用代理</span>
<span className="block text-muted-foreground">走「站点默认」里的代理地址;没填代理地址时不生效</span>
</label>
</div>
<p className="text-meta text-muted-foreground">User-Agent 随凭证保存,在"更新凭证"里填写。</p>
</div>
</DetailSheet>
);
}
/** 删除站点确认:写明影响 */
export function DeleteSiteDialog({
site,
onClose,
onDeleted,
}: {
site: SiteListItem;
onClose: () => void;
onDeleted?: () => void;
}) {
const del = useDeleteSite();
return (
<ConfirmDialog
open
onOpenChange={(o) => !o && onClose()}
title={`删除站点「${site.name}」?`}
description="不可恢复。"
confirmLabel="删除站点"
destructive
onConfirm={async () => {
try {
await del.mutateAsync(site.id);
toast.success(`已删除 ${site.name}`);
onDeleted?.();
} catch (err) {
toast.error(errorMessage(err, "删除失败"));
throw err;
}
}}
>
<ul className="list-disc space-y-1 pl-5 text-caption text-muted-foreground">
<li>站点、{site.accounts?.length ?? 0} 个账号和全部凭证删除</li>
<li>已抓到的资源保留,标为"站点已关闭",不再产生新候选</li>
<li>该站 Feed 停用;已有下载任务按原策略继续</li>
<li>同一站点以后可以重新添加</li>
</ul>
</ConfirmDialog>
);
}
/** 页面级对话框出口 */
export function SiteDialogHost({
dialog,
onClose,
onDeleted,
}: {
dialog: SiteDialog | null;
onClose: () => void;
onDeleted?: () => void;
}) {
if (!dialog) return null;
if (dialog.kind === "credentials")
return (
<CredentialDialog
key={`${dialog.site.id}-${dialog.loginKind ?? ""}`}
site={dialog.site}
account={dialog.account}
loginKind={dialog.loginKind}
onClose={onClose}
/>
);
if (dialog.kind === "edit") return <SiteEditSheet key={dialog.site.id} site={dialog.site} onClose={onClose} />;
return (
<DeleteSiteDialog
site={dialog.site}
onClose={onClose}
onDeleted={() => {
onClose();
onDeleted?.();
}}
/>
);
}
@@ -0,0 +1,208 @@
import type {
SiteAccount,
SiteAccountOverall,
SiteAdapter,
SiteAuthKind,
SiteDefinitionView,
SiteLastHealth,
SiteListItem,
SiteLoginFailureReason,
SiteRateLimit,
} from "@umt/contracts";
import type { StatusTone } from "~/components/console/status-badge.tsx";
import { relTime } from "~/components/workbench/release-fields.ts";
import { formatBytes } from "~/lib/utils.ts";
// 站点页的展示口径(docs/design/site-center.md §4.2)。缺失字段显示"—",不当成 0。
export const ADAPTER_LABEL: Record<SiteAdapter, string> = {
nexusphp: "NexusPHP",
mteam_api: "M-Team API",
api: "站点 API",
generic_rss: "通用 RSS",
};
export const ACCOUNT_OVERALL: Record<SiteAccountOverall, { label: string; tone: StatusTone }> = {
active: { label: "正常", tone: "success" },
expired: { label: "已失效", tone: "destructive" },
invalid: { label: "已失效", tone: "destructive" },
challenge: { label: "需验证", tone: "warning" },
unknown: { label: "未验证", tone: "neutral" },
unconfigured: { label: "未配置", tone: "neutral" },
};
export const AUTH_KIND_LABEL: Record<SiteAuthKind, string> = {
cookie: "Cookie",
api_key: "API Key",
password: "账号密码",
passkey: "Passkey",
};
/** 主凭证三选一(passkey 只用于 RSS / 下载,不参与登录) */
export type LoginKind = "password" | "cookie" | "api_key";
/** 定义支持的登录方式;定义没写 authSupported 时按适配器推断 */
export function supportedKinds(def: Pick<SiteDefinitionView, "adapter" | "authSupported"> | undefined): LoginKind[] {
if (!def) return ["cookie", "password", "api_key"];
const map: Record<string, LoginKind> = {
cookie: "cookie",
credential: "password",
password: "password",
apikey: "api_key",
api_key: "api_key",
};
const listed = [...new Set(def.authSupported.map((a) => map[a]).filter((x): x is LoginKind => !!x))];
if (listed.length) return listed;
if (def.adapter === "mteam_api") return ["api_key"];
if (def.adapter === "nexusphp") return ["cookie", "password"];
if (def.adapter === "api") return ["api_key", "cookie"];
return [];
}
/** 站点主账号:启用的里取最差状态那个;没有启用账号取第一个 */
const WORST: SiteAccount["status"][] = ["invalid", "expired", "challenge", "unknown", "active"];
export function primaryAccount(accounts: SiteAccount[] | undefined): SiteAccount | null {
const list = accounts ?? [];
const enabled = list.filter((a) => a.enabled);
if (!enabled.length) return list[0] ?? null;
return [...enabled].sort((a, b) => WORST.indexOf(a.status) - WORST.indexOf(b.status))[0] ?? null;
}
/** 有用户数据的账号(优先正常的) */
export function dataAccount(accounts: SiteAccount[] | undefined): SiteAccount | null {
const list = (accounts ?? []).filter((a) => a.enabled && a.userData);
return list.find((a) => a.status === "active") ?? list[0] ?? null;
}
export function overallStatus(site: SiteListItem): SiteAccountOverall {
if (site.accountStatus) return site.accountStatus;
const acc = primaryAccount(site.accounts);
return acc?.enabled ? acc.status : "unconfigured";
}
/** 需处理:账号非正常(已配置)或健康故障 */
export function needsAttention(site: SiteListItem): boolean {
const st = overallStatus(site);
return (
site.enabled && (st === "expired" || st === "invalid" || st === "challenge" || site.healthStatus === "failing")
);
}
export const bytes = (n: number | null | undefined) => (n === null || n === undefined ? "—" : formatBytes(n, 2));
/** 分享率:download=0 显示 ∞;null 显示 — */
export function ratioText(d: { ratio: number | null; download: number } | null | undefined): string {
if (!d) return "—";
if (d.download === 0 && d.ratio === null) return "∞";
if (d.ratio === null) return "—";
return d.ratio >= 1000 ? d.ratio.toFixed(0) : d.ratio.toFixed(2);
}
export function hostOf(url: string): string {
try {
return new URL(url).host;
} catch {
return url;
}
}
const HEALTH_STATUS_TEXT: Record<string, string> = {
ok: "正常",
auth_missing: "未登录",
auth_expired: "登录已失效",
auth_invalid: "凭证无效",
rate_limited: "被站点限流",
unreachable: "连不上站点",
site_maintenance: "站点维护中",
parse_error: "页面解析失败",
};
/** 健康检测一行:"正常 · 320 ms · 5 分钟前" / 失败原因一句 */
export function healthLine(h: SiteLastHealth | null, at: string | null): { text: string; tone: StatusTone } {
if (!h) return { text: "未检测", tone: "neutral" };
const parts = [HEALTH_STATUS_TEXT[h.status] ?? h.status];
if (h.latencyMs !== null) parts.push(`${h.latencyMs} ms`);
parts.push(relTime(at));
const tone: StatusTone = h.ok ? "success" : h.status.startsWith("auth_") ? "destructive" : "warning";
return { text: parts.join(" · "), tone };
}
export function healthMessage(h: SiteLastHealth | null): string | null {
if (!h || h.ok) return null;
return h.message;
}
/** 限流:"20 次/分";空对象 = 默认 */
export function rateLimitText(r: SiteRateLimit | null | undefined): string {
if (!r || (!r.requestsPerMinute && !r.minIntervalMs && !r.concurrency)) return "默认";
const parts: string[] = [];
if (r.requestsPerMinute) parts.push(`${r.requestsPerMinute} 次/分`);
if (r.minIntervalMs) parts.push(`间隔 ${(r.minIntervalMs / 1000).toFixed(1).replace(/\.0$/, "")} 秒`);
if (r.concurrency) parts.push(`并发 ${r.concurrency}`);
return parts.join(" · ");
}
/** 登录失败分类文案(先说发生了什么,再说怎么办) */
export function loginFailureText(
reason: SiteLoginFailureReason | null,
message: string | null,
authKind?: SiteAuthKind,
): { text: string; suggestCookie: boolean } {
switch (reason) {
case "captcha":
return { text: "登录失败:站点要求验证码。改用 Cookie,或稍后重试", suggestCookie: true };
case "two_factor":
return { text: "登录失败:站点要求二次验证。填写 TOTP 密钥后重试,或改用 Cookie", suggestCookie: true };
case "challenge":
return { text: "站点返回 Cloudflare 挑战页,无法自动登录。改用 Cookie", suggestCookie: true };
case "bad_credentials":
return { text: "登录失败:账号或密码错误", suggestCookie: false };
case "account_blocked":
return { text: `登录失败:账号被封禁或未确认${message ? `(${message})` : ""}`, suggestCookie: false };
case "auth_expired":
return {
text:
authKind === "api_key"
? "API Key 无效。更新 API Key 后重新刷新登录"
: "Cookie 已失效。更新 Cookie 后重新刷新登录",
suggestCookie: false,
};
case "network":
return { text: `连不上站点${message ? `:${message}` : ""}`, suggestCookie: false };
case "unsupported":
return { text: message ?? "这个站点不支持该凭证类型的自动登录", suggestCookie: false };
default:
return { text: message ?? "登录失败,原因未知", suggestCookie: false };
}
}
/** 列表排序键 */
export type SortKey = "name" | "ratio" | "upload" | "lastLogin";
export function sortSites(items: SiteListItem[], key: SortKey): SiteListItem[] {
const val = (s: SiteListItem): number => {
const acc = dataAccount(s.accounts);
if (key === "ratio")
return acc?.userData ? (acc.userData.download === 0 ? Infinity : (acc.userData.ratio ?? -1)) : -2;
if (key === "upload") return acc?.userData?.upload ?? -1;
if (key === "lastLogin") {
const t = primaryAccount(s.accounts)?.lastLoginAt;
return t ? new Date(t).getTime() : -1;
}
return 0;
};
if (key === "name") return [...items].sort((a, b) => a.name.localeCompare(b.name, "zh-CN"));
return [...items].sort((a, b) => val(b) - val(a));
}
/** 失败原因短标签(登录历史徽标) */
export const REASON_SHORT: Record<SiteLoginFailureReason, string> = {
captcha: "需验证码",
two_factor: "需二次验证",
bad_credentials: "密码错误",
account_blocked: "账号封禁",
challenge: "Cloudflare 挑战",
auth_expired: "凭证失效",
network: "网络失败",
unsupported: "不支持",
unknown: "失败",
};
@@ -0,0 +1,115 @@
import type { SiteListItem } from "@umt/contracts";
import { useSiteUserData } from "~/api/sites.ts";
import { DetailSection } from "~/components/console/detail-sheet.tsx";
import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { KvList } from "~/components/console/kv-list.tsx";
import { StatTile } from "~/components/console/stat-tile.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { relTime } from "~/components/workbench/release-fields.ts";
import {
ACCOUNT_OVERALL,
AUTH_KIND_LABEL,
bytes,
dataAccount,
healthLine,
healthMessage,
overallStatus,
primaryAccount,
rateLimitText,
ratioText,
} from "./site-fields.ts";
import { UserDataChart } from "./user-data-chart.tsx";
/** 站点详情"概览":账号数据磁贴 + 30 天趋势 + 状态 */
export function SiteOverview({ site }: { site: SiteListItem }) {
const dAcc = dataAccount(site.accounts);
const acc = primaryAccount(site.accounts);
const u = dAcc?.userData ?? null;
const history = useSiteUserData(site.id, dAcc?.id, 30);
const st = ACCOUNT_OVERALL[overallStatus(site)];
const h = healthLine(site.lastHealth, site.lastHealthAt);
return (
<div className="space-y-6 pt-2">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
<StatTile label="上传" value={bytes(u?.upload)} />
<StatTile label="下载" value={bytes(u?.download)} />
<StatTile label="分享率" value={ratioText(u)} />
<StatTile label="等级" value={u?.userLevel ?? "—"} hint={u?.vip ? "VIP" : undefined} />
<StatTile label="做种" value={u ? u.seeding : "—"} hint={u?.seedingSize ? bytes(u.seedingSize) : undefined} />
<StatTile
label={u?.bonus !== null && u?.bonus !== undefined ? "魔力" : "未读消息"}
value={
u
? u.bonus !== null
? u.bonus.toLocaleString("zh-CN", { maximumFractionDigits: 1 })
: u.messageUnread || "—"
: "—"
}
hint={u && u.bonus !== null && u.messageUnread ? `${u.messageUnread} 条未读` : undefined}
/>
</div>
<div className="grid gap-6 lg:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]">
<section className="pg-slab min-w-0 rounded-xl p-4">
<div className="mb-3 flex flex-wrap items-baseline justify-between gap-2">
<h2 className="text-lead font-semibold text-foreground">近 30 天</h2>
<span className="text-meta text-muted-foreground">
{dAcc ? `账号 ${dAcc.label} · 数据 ${relTime(dAcc.userDataAt)}` : "没有账号数据"}
</span>
</div>
{history.isPending && dAcc ? (
<LoadingState rows={4} />
) : history.error ? (
<ErrorState plain title="读取历史失败" error={history.error} onRetry={() => void history.refetch()} />
) : (
<UserDataChart points={history.data?.items ?? []} />
)}
</section>
<div className="space-y-6">
<DetailSection title="账号">
<KvList
columns={1}
items={[
{ label: "状态", value: <StatusBadge tone={st.tone}>{st.label}</StatusBadge> },
{ label: "凭证", value: acc ? AUTH_KIND_LABEL[acc.authKind] : "未配置" },
{ label: "用户名", value: u?.username ?? acc?.loginName },
{ label: "上次登录", value: relTime(acc?.lastLoginAt) },
...(acc?.lastError && acc.status !== "active"
? [{ label: "最近错误", value: <span className="text-warning">{acc.lastError.message}</span> }]
: []),
]}
/>
</DetailSection>
<DetailSection title="站点">
<KvList
columns={1}
items={[
{
label: "健康检测",
value: (
<span>
{h.text}
{healthMessage(site.lastHealth) ? (
<span className="block text-meta text-muted-foreground">{healthMessage(site.lastHealth)}</span>
) : null}
</span>
),
},
{
label: "快照",
value: site.snapshotEnabled
? `${site.lastSnapshotAt ? relTime(site.lastSnapshotAt) : "还没有快照"} · ${site.snapshotIntervalSec ? `每 ${Math.round(site.snapshotIntervalSec / 60)} 分钟` : "默认周期"}`
: "未开启",
},
{ label: "限流", value: rateLimitText(site.rateLimit) },
{ label: "代理", value: site.proxy ? "使用" : "不使用" },
]}
/>
</DetailSection>
</div>
</div>
</div>
);
}
@@ -0,0 +1,199 @@
import type { SiteUserDataPoint } from "@umt/contracts";
import { useState } from "react";
import { CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import { Button } from "~/components/ui/button.tsx";
import { formatBytes } from "~/lib/utils.ts";
import { ratioText } from "./site-fields.ts";
// 用户数据 30 天折线。主色无彩(interface-materials §1):系列靠明暗 + 实线 / 虚线区分,不用色相;
// 字节与分享率量纲不同,分两张图,不做双轴。
const INK = "var(--foreground)";
const INK_2 = "var(--muted-foreground)";
const GRID = "var(--border)";
type Row = { day: string; upload: number; download: number; ratio: number | null };
const dayLabel = (d: string) => d.slice(5).replace("-", "/");
function Legend({ items }: { items: { label: string; dashed?: boolean; muted?: boolean }[] }) {
return (
<ul className="flex flex-wrap gap-x-4 gap-y-1 text-meta text-muted-foreground">
{items.map((i) => (
<li key={i.label} className="flex items-center gap-1.5">
<svg width="18" height="6" aria-hidden className="shrink-0">
<line
x1="1"
y1="3"
x2="17"
y2="3"
stroke={i.muted ? INK_2 : INK}
strokeWidth="2"
strokeLinecap="round"
strokeDasharray={i.dashed ? "4 3" : undefined}
/>
</svg>
{i.label}
</li>
))}
</ul>
);
}
function TipBox({
active,
payload,
label,
kind,
}: {
active?: boolean;
payload?: { payload: Row }[];
label?: string;
kind: "bytes" | "ratio";
}) {
const row = payload?.[0]?.payload;
if (!active || !row) return null;
return (
<div className="pg-float rounded-lg px-3 py-2 text-meta">
<p className="mb-1 font-medium text-foreground">{label}</p>
{kind === "bytes" ? (
<>
<p className="text-muted-foreground">
上传 <span className="text-foreground tabular-nums">{formatBytes(row.upload)}</span>
</p>
<p className="text-muted-foreground">
下载 <span className="text-foreground tabular-nums">{formatBytes(row.download)}</span>
</p>
</>
) : (
<p className="text-muted-foreground">
分享率 <span className="text-foreground tabular-nums">{ratioText(row)}</span>
</p>
)}
</div>
);
}
const axisProps = {
stroke: GRID,
tick: { fill: "var(--muted-foreground)", fontSize: 11 },
tickLine: false,
axisLine: false,
} as const;
export function UserDataChart({ points }: { points: SiteUserDataPoint[] }) {
const [table, setTable] = useState(false);
const rows: Row[] = [...points]
.sort((a, b) => a.day.localeCompare(b.day))
.map((p) => ({ day: dayLabel(p.day), upload: p.upload, download: p.download, ratio: p.ratio }));
if (rows.length < 2)
return (
<p className="py-6 text-center text-caption text-muted-foreground">
{rows.length ? "只有 1 天的数据,满 2 天后显示趋势" : "还没有历史数据。刷新登录成功后每天记一次"}
</p>
);
return (
<div className="space-y-5">
<figure className="min-w-0">
<figcaption className="mb-2 flex flex-wrap items-center justify-between gap-2">
<span className="text-caption font-medium text-foreground">上传 / 下载</span>
<Legend items={[{ label: "上传" }, { label: "下载", dashed: true, muted: true }]} />
</figcaption>
<div className="h-48 w-full">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={rows} margin={{ top: 6, right: 8, bottom: 0, left: 0 }}>
<CartesianGrid stroke={GRID} strokeDasharray="0" vertical={false} />
<XAxis dataKey="day" {...axisProps} minTickGap={24} />
<YAxis {...axisProps} width={64} tickFormatter={(v: number) => formatBytes(v, 0)} />
<Tooltip
cursor={{ stroke: INK_2, strokeWidth: 1 }}
content={(p) => <TipBox {...(p as object)} kind="bytes" />}
/>
<Line
type="monotone"
dataKey="upload"
name="上传"
stroke={INK}
strokeWidth={2}
dot={false}
activeDot={{ r: 4, stroke: "var(--background)", strokeWidth: 2, fill: INK }}
isAnimationActive={false}
/>
<Line
type="monotone"
dataKey="download"
name="下载"
stroke={INK_2}
strokeWidth={2}
strokeDasharray="5 4"
dot={false}
activeDot={{ r: 4, stroke: "var(--background)", strokeWidth: 2, fill: INK_2 }}
isAnimationActive={false}
/>
</LineChart>
</ResponsiveContainer>
</div>
</figure>
<figure className="min-w-0">
<figcaption className="mb-2 text-caption font-medium text-foreground">分享率</figcaption>
<div className="h-32 w-full">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={rows} margin={{ top: 6, right: 8, bottom: 0, left: 0 }}>
<CartesianGrid stroke={GRID} vertical={false} />
<XAxis dataKey="day" {...axisProps} minTickGap={24} />
<YAxis {...axisProps} width={64} domain={["auto", "auto"]} tickFormatter={(v: number) => v.toFixed(1)} />
<Tooltip
cursor={{ stroke: INK_2, strokeWidth: 1 }}
content={(p) => <TipBox {...(p as object)} kind="ratio" />}
/>
<Line
type="monotone"
dataKey="ratio"
name="分享率"
stroke={INK}
strokeWidth={2}
dot={false}
connectNulls
activeDot={{ r: 4, stroke: "var(--background)", strokeWidth: 2, fill: INK }}
isAnimationActive={false}
/>
</LineChart>
</ResponsiveContainer>
</div>
</figure>
<div>
<Button variant="ghost" size="xs" onClick={() => setTable((t) => !t)} aria-expanded={table}>
{table ? "收起数据表" : "查看数据表"}
</Button>
{table ? (
<div className="mt-2 max-h-64 overflow-auto rounded-lg border border-border/60">
<table className="w-full text-meta tabular-nums">
<thead className="sticky top-0 bg-background text-muted-foreground">
<tr>
<th className="px-3 py-1.5 text-left font-medium">日期</th>
<th className="px-3 py-1.5 text-right font-medium">上传</th>
<th className="px-3 py-1.5 text-right font-medium">下载</th>
<th className="px-3 py-1.5 text-right font-medium">分享率</th>
</tr>
</thead>
<tbody className="divide-y divide-border/60 text-foreground">
{[...rows].reverse().map((r) => (
<tr key={r.day}>
<td className="px-3 py-1.5">{r.day}</td>
<td className="px-3 py-1.5 text-right">{formatBytes(r.upload)}</td>
<td className="px-3 py-1.5 text-right">{formatBytes(r.download)}</td>
<td className="px-3 py-1.5 text-right">{ratioText(r)}</td>
</tr>
))}
</tbody>
</table>
</div>
) : null}
</div>
</div>
);
}