- sidebar: 概览 / 获取 / 整理 / 系统. Tasks moves to 系统; sites, subscriptions and rules form the 获取 group (ACQUIRE_GROUP_TITLE in nav.ts, the single place to rename it); standalone RSS nav item removed - site detail gets an RSS tab with everything the RSS page had (create, edit, enable, fetch now, fetch state) plus recent items; tabs and header actions follow the site's capabilities - old /console/sources links redirect to the site's RSS tab (?feed= looks up the owning site) or the site list; task links to RSS point at sites - site list shows and filters by type; add-site wizard starts with a type step (PT still picks from the definition library; public BT / custom RSS source only need name, URL and RSS URL) Co-Authored-By: Claude Opus 5.5 <[email protected]>
228 lines
8.9 KiB
TypeScript
228 lines
8.9 KiB
TypeScript
import {
|
|
SITE_KINDS,
|
|
type SiteAccount,
|
|
type SiteAccountOverall,
|
|
type SiteAdapter,
|
|
type SiteAuthKind,
|
|
type SiteCapability,
|
|
type SiteDefinitionView,
|
|
type SiteKind,
|
|
type SiteLastHealth,
|
|
type SiteListItem,
|
|
type SiteLoginFailureReason,
|
|
type 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",
|
|
};
|
|
|
|
/** 站点类型名(IA-2);旧数据缺 kind 时按 PT 站 */
|
|
export function kindLabel(kind: SiteKind | undefined): string {
|
|
return SITE_KINDS[kind ?? "pt"].label;
|
|
}
|
|
|
|
/** 站点支持某种获取方式(服务端按类型 ∩ 站点定义算好的 capabilities) */
|
|
export function siteCan(site: Pick<SiteListItem, "capabilities">, cap: SiteCapability): boolean {
|
|
return (site.capabilities ?? []).includes(cap);
|
|
}
|
|
|
|
/** 有登录与账号(凭证、账号数据、刷新登录、检测健康)的站点 */
|
|
export function hasLogin(site: Pick<SiteListItem, "capabilities">): boolean {
|
|
return siteCan(site, "login");
|
|
}
|
|
|
|
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 {
|
|
if (!hasLogin(site)) return site.enabled && site.healthStatus === "failing";
|
|
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];
|
|
// 延迟毫秒数不上屏(DEAI-2),需要时看站点详情
|
|
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: "失败",
|
|
};
|