feat(subtitles): 字幕站(SubHD、字幕库)在线搜索与下载;rar / 7z 解压;验证码与页面解析在沙箱里

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-03 02:28:29 +08:00
1 parent 92d456d123
commit 59b3680f35
32 files changed
+1704 -166

No files matched your search

@@ -6,7 +6,7 @@ import {
type SiteListItem,
type SiteLoginResult,
} from "@umt/contracts";
import { ArrowLeft, Check, Globe, LoaderCircle, Rss, Search, Server, ShieldCheck } from "lucide-react";
import { ArrowLeft, Captions, Check, Globe, LoaderCircle, Rss, Search, Server, ShieldCheck } from "lucide-react";
import { useMemo, useState } from "react";
import { useNavigate } from "react-router";
import { toast } from "sonner";
@@ -48,8 +48,8 @@ const PT_STEPS = ["选择类型", "选择站点", "填写凭证", "测试"] as c
const FEED_STEPS = ["选择类型", "选择站点"] as const;
const GROUP_ORDER = ["nexusphp", "mteam_api", "api", "generic_rss"] as const;
/** 自定义 RSS 源与索引器入口暂时不开放(已有的 RSS 源站点照常显示) */
const KIND_ORDER: SiteKind[] = ["pt", "public_bt"];
const KIND_ICON = { pt: ShieldCheck, public_bt: Globe, rss_feed: Rss, torznab: Server } as const;
const KIND_ORDER: SiteKind[] = ["pt", "public_bt", "subtitle"];
const KIND_ICON = { pt: ShieldCheck, public_bt: Globe, rss_feed: Rss, torznab: Server, subtitle: Captions } as const;
/** 馒头默认 API Key,其余默认 Cookie(验证码站点账密大概率失败) */
function defaultKind(kinds: LoginKind[]): LoginKind {
@@ -105,13 +105,14 @@ export function SiteAddDialog({ onClose, initialKind }: { onClose: () => void; i
})).filter((g) => g.items.length);
}, [items, q]);
const publicDefs = items.filter((d) => d.kind === "public_bt");
// 公开 BT 站 / 字幕站:都是内置站,一键添加
const publicDefs = items.filter((d) => d.kind === kind);
/** 内置公开 BT 站:一步建好(专用适配器、定时抓最新),进站点页 */
const addPublic = async (d: SiteDefinitionView) => {
try {
setPhase("保存中…");
const site = await create.mutateAsync({
kind: "public_bt",
kind: d.kind,
key: d.key,
adapter: d.adapter,
name: d.name,
@@ -187,7 +188,9 @@ export function SiteAddDialog({ onClose, initialKind }: { onClose: () => void; i
<LoadingState rows={2} />
) : null}
<p className="mt-3 text-meta text-muted-foreground">
不需要账号;添加后默认走代理,定时抓取最新资源,也能在资源搜索里选用。
{kind === "subtitle"
? "不需要账号;在作品页「补字幕」里搜索、下载。"
: "不需要账号;添加后默认走代理,定时抓取最新资源,也能在资源搜索里选用。"}
</p>
</div>
);
+151 -84
View File
@@ -336,7 +336,46 @@ function PublicSiteCard({ site, onDialog }: Omit<Props, "checks">) {
);
}
/** 字幕站(SubHD、字幕库):出口与状态;搜索 / 下载在作品页「补字幕」里 */
function SubtitleSiteCard({ site, checks, onDialog }: Props) {
const health = healthLine(site.lastHealth, site.lastHealthAt);
const busy = checks.isBusy(site.id, "health");
return (
<article className={cn("pg-slab flex min-w-0 flex-col rounded-xl p-4", !site.enabled && "opacity-75")}>
<SiteCardHeader site={site} />
<dl className="mt-3 grid grid-cols-2 gap-x-4 gap-y-2 rounded-lg border border-border/60 px-3 py-2.5 text-caption">
<div className="min-w-0">
<dt className="text-meta text-muted-foreground">出口</dt>
<dd className="text-foreground">{site.proxy ? "代理" : "直连"}</dd>
</div>
<div className="min-w-0">
<dt className="text-meta text-muted-foreground">状态</dt>
<dd className="truncate" title={healthMessage(site.lastHealth) ?? undefined}>
<StatusDot tone={health.tone} />
{health.text}
</dd>
</div>
</dl>
<p className="mt-2 text-meta text-muted-foreground">在作品页「补字幕」里搜索、下载。</p>
<div aria-hidden className="min-h-4 flex-1" />
<footer className="mt-auto flex flex-wrap items-center justify-end gap-2 border-t border-border/60 pt-3">
<Button
variant="outline"
size="sm"
disabled={busy || !site.enabled}
onClick={() => void checks.checkHealth(site)}
>
{busy ? <LoaderCircle className="animate-spin" /> : <Activity />}
检测健康
</Button>
<SiteMoreMenu site={site} onDialog={onDialog} />
</footer>
</article>
);
}
export function SiteCard({ site, checks, onDialog }: Props) {
if (site.kind === "subtitle") return <SubtitleSiteCard site={site} checks={checks} onDialog={onDialog} />;
if (!hasLogin(site) && siteCan(site, "list")) return <PublicSiteCard site={site} onDialog={onDialog} />;
if (!hasLogin(site)) return <FeedSiteCard site={site} onDialog={onDialog} />;
return <AccountSiteCard site={site} checks={checks} onDialog={onDialog} />;
@@ -480,8 +519,8 @@ export function SiteTable({
sites: SiteListItem[];
checks: Checks;
onDialog: (d: SiteDialog) => void;
/** pt = 带账号与流量列;public = 公开站(无账号),只列状态与资源更新 */
variant?: "pt" | "public";
/** pt = 带账号与流量列;public = 公开站(无账号),只列状态与资源更新;subtitle = 字幕站,只列出口与状态 */
variant?: "pt" | "public" | "subtitle";
}) {
const [params] = useSearchParams();
const name: DataColumn<SiteListItem> = {
@@ -530,8 +569,22 @@ export function SiteTable({
</div>
),
};
const health: DataColumn<SiteListItem> = {
key: "health",
header: "状态",
hideBelow: "sm",
cell: (s) => {
const h = healthLine(s.lastHealth, s.lastHealthAt);
return (
<span className="whitespace-nowrap text-caption" title={healthMessage(s.lastHealth) ?? h.text}>
<StatusDot tone={h.tone} />
{h.text}
</span>
);
},
};
const columns: DataColumn<SiteListItem>[] =
variant === "public"
variant === "subtitle"
? [
name,
{
@@ -540,94 +593,108 @@ export function SiteTable({
hideBelow: "sm",
cell: (s) => <span className="whitespace-nowrap text-muted-foreground">{s.proxy ? "代理" : "直连"}</span>,
},
{
key: "search",
header: "资源搜索",
hideBelow: "sm",
cell: (s) => (
<span className="whitespace-nowrap text-muted-foreground">{s.searchDefault ? "默认参与" : "手动选"}</span>
),
},
snapshot,
health,
actions,
]
: [
name,
{
key: "account",
header: "账号",
hideBelow: "xl",
cell: (s) => (
<div className="flex flex-wrap items-center gap-1">
<AccountBadges site={s} />
</div>
),
},
{
key: "login",
header: "上次登录",
hideBelow: "xl",
cell: (s) => (
<span className="whitespace-nowrap 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>
) : (
"—"
);
: variant === "public"
? [
name,
{
key: "proxy",
header: "出口",
hideBelow: "sm",
cell: (s) => <span className="whitespace-nowrap text-muted-foreground">{s.proxy ? "代理" : "直连"}</span>,
},
},
{
key: "ratio",
header: "分享率",
align: "right",
cell: (s) => (
<span className="whitespace-nowrap tabular-nums">{ratioText(dataAccount(s.accounts)?.userData)}</span>
),
},
{
key: "level",
header: "等级 · 做种",
hideBelow: "lg",
cell: (s) => {
const u = dataAccount(s.accounts)?.userData;
const t = u ? `${u.userLevel ?? "—"} · ${u.seeding}` : "—";
return (
<span className="block max-w-40 truncate whitespace-nowrap" title={t}>
{t}
{
key: "search",
header: "资源搜索",
hideBelow: "sm",
cell: (s) => (
<span className="whitespace-nowrap text-muted-foreground">
{s.searchDefault ? "默认参与" : "手动选"}
</span>
);
),
},
},
{
key: "health",
header: "状态",
hideBelow: "md",
cell: (s) => {
if (!hasLogin(s)) return <span className="text-muted-foreground">—</span>;
const h = healthLine(s.lastHealth, s.lastHealthAt);
return (
<span className="whitespace-nowrap text-caption" title={healthMessage(s.lastHealth) ?? h.text}>
<StatusDot tone={h.tone} />
{h.text}
snapshot,
actions,
]
: [
name,
{
key: "account",
header: "账号",
hideBelow: "xl",
cell: (s) => (
<div className="flex flex-wrap items-center gap-1">
<AccountBadges site={s} />
</div>
),
},
{
key: "login",
header: "上次登录",
hideBelow: "xl",
cell: (s) => (
<span className="whitespace-nowrap text-muted-foreground">
{relTime(primaryAccount(s.accounts)?.lastLoginAt)}
</span>
);
),
},
},
snapshot,
actions,
];
{
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: "分享率",
align: "right",
cell: (s) => (
<span className="whitespace-nowrap tabular-nums">{ratioText(dataAccount(s.accounts)?.userData)}</span>
),
},
{
key: "level",
header: "等级 · 做种",
hideBelow: "lg",
cell: (s) => {
const u = dataAccount(s.accounts)?.userData;
const t = u ? `${u.userLevel ?? "—"} · ${u.seeding}` : "—";
return (
<span className="block max-w-40 truncate whitespace-nowrap" title={t}>
{t}
</span>
);
},
},
{
key: "health",
header: "状态",
hideBelow: "md",
cell: (s) => {
if (!hasLogin(s)) return <span className="text-muted-foreground">—</span>;
const h = healthLine(s.lastHealth, s.lastHealthAt);
return (
<span className="whitespace-nowrap text-caption" title={healthMessage(s.lastHealth) ?? h.text}>
<StatusDot tone={h.tone} />
{h.text}
</span>
);
},
},
snapshot,
actions,
];
// 站点数量不多:分组表格不分页
return (
<DataTable
@@ -24,6 +24,7 @@ export const ADAPTER_LABEL: Record<SiteAdapter, string> = {
mteam_api: "M-Team API",
api: "站点 API",
generic_rss: "通用 RSS",
subtitle: "字幕站",
};
/** 站点类型名(IA-2);旧数据缺 kind 时按 PT 站 */