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,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: "失败",
|
||||
};
|
||||
Reference in new issue
Block a user