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,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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user