feat(subtitles): 字幕站(SubHD、字幕库)在线搜索与下载;rar / 7z 解压;验证码与页面解析在沙箱里
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
92d456d123
commit
59b3680f35
32 files changed
+1704
-166
No files matched your search
@@ -2,7 +2,10 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
type SubtitleApplyRequest,
|
||||
SubtitleApplyResult,
|
||||
type SubtitleFetchRequest,
|
||||
SubtitleParseResponse,
|
||||
type SubtitleSearchRequest,
|
||||
SubtitleSearchResponse,
|
||||
SubtitleTargetsResponse,
|
||||
SubtitleWritesResponse,
|
||||
SubtitleWriteView,
|
||||
@@ -82,3 +85,18 @@ export function useUndoSubtitle(entityId: string) {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useSearchSubtitles() {
|
||||
return useMutation({
|
||||
mutationFn: async (body: SubtitleSearchRequest) =>
|
||||
parseOrThrow(SubtitleSearchResponse, await api.post<unknown>("/subtitles/search", body), "/subtitles/search"),
|
||||
});
|
||||
}
|
||||
|
||||
/** 下载一条在线字幕并解析(同上传的预览,不写文件) */
|
||||
export function useFetchSubtitle() {
|
||||
return useMutation({
|
||||
mutationFn: async (body: SubtitleFetchRequest) =>
|
||||
parseOrThrow(SubtitleParseResponse, await api.post<unknown>("/subtitles/fetch", body), "/subtitles/fetch"),
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 站 */
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import type { SubtitleLanguage, SubtitleParsedItem, SubtitleTarget, SubtitleWriteView } from "@umt/contracts";
|
||||
import { FileUp, LoaderCircle, Undo2 } from "lucide-react";
|
||||
import type {
|
||||
SubtitleLanguage,
|
||||
SubtitleParsedItem,
|
||||
SubtitleSearchResult,
|
||||
SubtitleTarget,
|
||||
SubtitleWriteView,
|
||||
} from "@umt/contracts";
|
||||
import { Download, ExternalLink, FileUp, LoaderCircle, Search, Undo2 } from "lucide-react";
|
||||
import { useId, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
useApplySubtitles,
|
||||
useFetchSubtitle,
|
||||
useParseSubtitles,
|
||||
useSearchSubtitles,
|
||||
useSubtitleTargets,
|
||||
useSubtitleWrites,
|
||||
useUndoSubtitle,
|
||||
@@ -20,7 +28,9 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "~/components/ui/dialog.tsx";
|
||||
import { Input } from "~/components/ui/input.tsx";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
|
||||
import { Tabs, TabsList, TabsTrigger } from "~/components/ui/tabs.tsx";
|
||||
import { relTime } from "~/components/workbench/release-fields.ts";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
@@ -74,6 +84,7 @@ export function SubtitleDialog({
|
||||
const [rows, setRows] = useState<Row[]>([]);
|
||||
const [skipped, setSkipped] = useState<{ name: string; reason: string }[]>([]);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [tab, setTab] = useState<"upload" | "online">("upload");
|
||||
const list = targets.data?.targets ?? [];
|
||||
const busy = parse.isPending || apply.isPending;
|
||||
|
||||
@@ -112,6 +123,8 @@ export function SubtitleDialog({
|
||||
language: x.language,
|
||||
content: x.content,
|
||||
targetLocationId: x.target,
|
||||
...(x.source ? { source: x.source } : {}),
|
||||
...(x.sourceRef ? { sourceRef: x.sourceRef } : {}),
|
||||
})),
|
||||
});
|
||||
if (r.written.length) toast.success(`已写入 ${r.written.length} 个字幕`);
|
||||
@@ -141,41 +154,58 @@ export function SubtitleDialog({
|
||||
<EmptyState title="媒体库里还没有这部作品的视频" description="整理入库后才能补字幕。" />
|
||||
) : (
|
||||
<div className="min-w-0 space-y-4">
|
||||
<label
|
||||
htmlFor={inputId}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setDragging(true);
|
||||
}}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setDragging(false);
|
||||
void load([...e.dataTransfer.files]);
|
||||
}}
|
||||
className={cn(
|
||||
"flex cursor-pointer flex-col items-center gap-1 rounded-lg border border-dashed border-border px-4 py-6 text-center transition-colors hover:border-foreground/40",
|
||||
dragging && "border-foreground/60 bg-accent",
|
||||
)}
|
||||
>
|
||||
{parse.isPending ? <LoaderCircle className="size-5 animate-spin" /> : <FileUp className="size-5" />}
|
||||
<span className="text-caption text-foreground">选择或拖入字幕文件</span>
|
||||
<span className="text-meta text-muted-foreground">
|
||||
支持 srt / ass / ssa / vtt / sub / sbv 和 zip,可一次选多个
|
||||
</span>
|
||||
<input
|
||||
ref={input}
|
||||
id={inputId}
|
||||
type="file"
|
||||
multiple
|
||||
accept={ACCEPT}
|
||||
className="sr-only"
|
||||
onChange={(e) => {
|
||||
void load([...(e.target.files ?? [])]);
|
||||
if (input.current) input.current.value = "";
|
||||
<Tabs value={tab} onValueChange={(v) => setTab(v as "upload" | "online")}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="upload">上传</TabsTrigger>
|
||||
<TabsTrigger value="online">在线搜索</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
{tab === "upload" ? (
|
||||
<label
|
||||
htmlFor={inputId}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setDragging(true);
|
||||
}}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setDragging(false);
|
||||
void load([...e.dataTransfer.files]);
|
||||
}}
|
||||
className={cn(
|
||||
"flex cursor-pointer flex-col items-center gap-1 rounded-lg border border-dashed border-border px-4 py-6 text-center transition-colors hover:border-foreground/40",
|
||||
dragging && "border-foreground/60 bg-accent",
|
||||
)}
|
||||
>
|
||||
{parse.isPending ? <LoaderCircle className="size-5 animate-spin" /> : <FileUp className="size-5" />}
|
||||
<span className="text-caption text-foreground">选择或拖入字幕文件</span>
|
||||
<span className="text-meta text-muted-foreground">
|
||||
支持 srt / ass / ssa / vtt / sub / sbv 和 zip,可一次选多个
|
||||
</span>
|
||||
<input
|
||||
ref={input}
|
||||
id={inputId}
|
||||
type="file"
|
||||
multiple
|
||||
accept={ACCEPT}
|
||||
className="sr-only"
|
||||
onChange={(e) => {
|
||||
void load([...(e.target.files ?? [])]);
|
||||
if (input.current) input.current.value = "";
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
) : (
|
||||
<OnlinePanel
|
||||
entityId={entityId}
|
||||
seasons={[...new Set(list.map((t) => t.season).filter((x): x is number => x !== null))]}
|
||||
onFetched={(items, skip) => {
|
||||
setRows((prev) => [...prev, ...items.map((it) => ({ ...it, target: it.targetLocationId ?? SKIP }))]);
|
||||
setSkipped((prev) => [...prev, ...skip]);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{rows.length ? (
|
||||
<ul className="space-y-2">
|
||||
@@ -318,3 +348,145 @@ function History({
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** 在线搜索:已启用的字幕站(SubHD、字幕库);下载的字幕进上面同一个预览 */
|
||||
function OnlinePanel({
|
||||
entityId,
|
||||
seasons,
|
||||
onFetched,
|
||||
}: {
|
||||
entityId: string;
|
||||
seasons: number[];
|
||||
onFetched: (items: SubtitleParsedItem[], skipped: { name: string; reason: string }[]) => void;
|
||||
}) {
|
||||
const search = useSearchSubtitles();
|
||||
const fetch = useFetchSubtitle();
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [season, setSeason] = useState<string>(seasons.length === 1 ? String(seasons[0]) : "all");
|
||||
const [episode, setEpisode] = useState("");
|
||||
const [fetching, setFetching] = useState<string | null>(null);
|
||||
const data = search.data;
|
||||
|
||||
const run = () =>
|
||||
search.mutate(
|
||||
{
|
||||
entityId,
|
||||
...(keyword.trim() ? { keyword: keyword.trim() } : {}),
|
||||
...(season !== "all" ? { season: Number(season) } : {}),
|
||||
...(episode.trim() && /^\d+$/.test(episode.trim()) ? { episode: Number(episode.trim()) } : {}),
|
||||
},
|
||||
{ onError: (err) => toast.error(errorMessage(err, "搜索失败")) },
|
||||
);
|
||||
const take = async (r: SubtitleSearchResult) => {
|
||||
setFetching(`${r.siteId}${r.ref}`);
|
||||
try {
|
||||
const p = await fetch.mutateAsync({ entityId, siteId: r.siteId, ref: r.ref, title: r.title });
|
||||
if (!p.items.length) toast.error("这个字幕包里没有能用的字幕文件");
|
||||
else toast.success(`已下载 ${p.items.length} 个字幕,检查下面的配对后写入`);
|
||||
onFetched(p.items, p.skipped);
|
||||
} catch (err) {
|
||||
toast.error(errorMessage(err, "下载失败"));
|
||||
} finally {
|
||||
setFetching(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-w-0 space-y-3">
|
||||
<form
|
||||
className="flex flex-wrap items-center gap-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
run();
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
placeholder="默认按作品名搜,可另填关键词"
|
||||
aria-label="关键词"
|
||||
className="min-w-48 flex-1"
|
||||
/>
|
||||
{seasons.length ? (
|
||||
<>
|
||||
<Select value={season} onValueChange={setSeason}>
|
||||
<SelectTrigger aria-label="季" className="w-28">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
<SelectItem value="all">全部季</SelectItem>
|
||||
{seasons.map((n) => (
|
||||
<SelectItem key={n} value={String(n)}>
|
||||
第 {n} 季
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
value={episode}
|
||||
onChange={(e) => setEpisode(e.target.value)}
|
||||
placeholder="集(可空)"
|
||||
aria-label="集"
|
||||
inputMode="numeric"
|
||||
className="w-24"
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
<Button type="submit" disabled={search.isPending}>
|
||||
{search.isPending ? <LoaderCircle className="animate-spin" /> : <Search />}
|
||||
搜索
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{data?.noSites ? (
|
||||
<p className="text-caption text-muted-foreground">
|
||||
还没有启用的字幕站。到「站点 → 添加站点 → 字幕站」添加 SubHD 或字幕库。
|
||||
</p>
|
||||
) : null}
|
||||
{data?.errors.length ? (
|
||||
<p className="text-meta text-warning">{data.errors.map((e) => `${e.siteName}:${e.reason}`).join(";")}</p>
|
||||
) : null}
|
||||
{data && !data.noSites && !data.results.length ? (
|
||||
<p className="text-caption text-muted-foreground">
|
||||
没搜到(用了:{data.keywords.join("、") || "无"})。可以换个关键词,或去掉季集限制再试。
|
||||
</p>
|
||||
) : null}
|
||||
{data?.results.length ? (
|
||||
<ul className="max-h-80 space-y-1.5 overflow-y-auto">
|
||||
{data.results.map((r) => {
|
||||
const key = `${r.siteId}${r.ref}`;
|
||||
return (
|
||||
<li key={key} className="flex min-w-0 items-center gap-2 rounded-lg border border-border/70 px-3 py-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-caption text-foreground" title={r.title}>
|
||||
{r.title}
|
||||
</p>
|
||||
<p className="truncate text-meta text-muted-foreground">
|
||||
{[
|
||||
r.siteName,
|
||||
r.languages.join(" / ") || null,
|
||||
r.format,
|
||||
r.downloads !== null ? `下载 ${r.downloads}` : null,
|
||||
r.publishedAt ? (r.publishedAt.includes("T") ? relTime(r.publishedAt) : r.publishedAt) : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</p>
|
||||
</div>
|
||||
<Button asChild variant="ghost" size="icon-sm" title="在字幕站打开">
|
||||
<a href={r.pageUrl} target="_blank" rel="noreferrer" aria-label="在字幕站打开">
|
||||
<ExternalLink />
|
||||
</a>
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" disabled={fetching !== null} onClick={() => void take(r)}>
|
||||
{fetching === key ? <LoaderCircle className="animate-spin" /> : <Download />}
|
||||
下载
|
||||
</Button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -21,11 +21,12 @@ const VIEW_KEY = "umt.sites.view";
|
||||
type View = "cards" | "list";
|
||||
type Filter = "all" | "enabled" | "attention";
|
||||
/** 分组顺序;公开 BT 站的表只列它用得到的列 */
|
||||
const GROUPS: { kind: SiteKind; title: string; variant: "pt" | "public" }[] = [
|
||||
const GROUPS: { kind: SiteKind; title: string; variant: "pt" | "public" | "subtitle" }[] = [
|
||||
{ kind: "pt", title: "PT 站", variant: "pt" },
|
||||
{ kind: "public_bt", title: "公开 BT 站", variant: "public" },
|
||||
{ kind: "rss_feed", title: "RSS 源", variant: "public" },
|
||||
{ kind: "torznab", title: "索引器", variant: "public" },
|
||||
{ kind: "subtitle", title: "字幕站", variant: "subtitle" },
|
||||
];
|
||||
|
||||
const FILTERS: { value: Filter; label: string }[] = [
|
||||
|
||||
Reference in new issue
Block a user