import type { SubtitleLanguage, SubtitleParsedItem, SubtitleSearchResult, SubtitleTarget, SubtitleWriteView, } from "@umt/contracts"; import { Download, ExternalLink, FileUp, LoaderCircle, Search, Undo2 } from "lucide-react"; import { useEffect, useId, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; import { useApplySubtitles, useFetchSubtitle, useParseSubtitles, useSearchSubtitles, useSubtitleTargets, useSubtitleWrites, useUndoSubtitle, } from "~/api/subtitles.ts"; import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx"; 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 { 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"; // 补字幕(docs/plan/subtitles.md):上传字幕 / zip → 预览(语言、写到哪一集,可改)→ 写到视频同目录;下面是历史,可撤销。 export const SUBTITLE_LANGUAGE_LABEL: Record = { "zh-Hans": "简中", "zh-Hant": "繁中", "zh-Hans&en": "简英双语", "zh-Hant&en": "繁英双语", "zh&ja": "中日双语", "zh&ko": "中韩双语", en: "英文", ja: "日文", ko: "韩文", unknown: "未知", }; const SOURCE_LABEL = { upload: "上传", subhd: "SubHD", zimuku: "字幕库" } as const; const SKIP = "__skip__"; const ACCEPT = ".srt,.ass,.ssa,.vtt,.sub,.sbv,.zip"; const targetLabel = (t: SubtitleTarget) => t.episode !== null ? `S${String(t.season ?? 1).padStart(2, "0")}E${String(t.episode).padStart(2, "0")} · ${t.fileName}` : t.fileName; type Row = SubtitleParsedItem & { target: string }; /** 同一后缀的语言(写出来是同一个文件名):双语按中文,与整理规则一致 */ const suffixGroup = (l: SubtitleLanguage) => (l.startsWith("zh-Hant") ? "zh-tw" : l.startsWith("zh") ? "zh-cn" : l); export function SubtitleDialog({ entityId, title, open, onOpenChange, fromSession, }: { entityId: string; title: string; open: boolean; onOpenChange: (o: boolean) => void; /** 原站视图会话:打开时自动下载它当前页的字幕并配对 */ fromSession?: string; }) { const inputId = useId(); const input = useRef(null); const targets = useSubtitleTargets(entityId, open); const writes = useSubtitleWrites(entityId, open); const parse = useParseSubtitles(); const apply = useApplySubtitles(); const undo = useUndoSubtitle(entityId); const [rows, setRows] = useState([]); 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 fetchPage = useFetchSubtitle(); const busy = parse.isPending || apply.isPending || fetchPage.isPending; const fetchedFrom = useRef(null); useEffect(() => { if (!open || !fromSession || fetchedFrom.current === fromSession) return; fetchedFrom.current = fromSession; fetchPage .mutateAsync({ entityId, sessionId: fromSession }) .then((r) => { setRows((prev) => [...prev, ...r.items.map((it) => ({ ...it, target: it.targetLocationId ?? SKIP }))]); setSkipped((prev) => [...prev, ...r.skipped]); }) .catch((err) => toast.error(errorMessage(err, "下载当前字幕失败"))); }, [open, fromSession, entityId, fetchPage]); const load = async (files: File[]) => { if (!files.length) return; try { const r = await parse.mutateAsync({ entityId, files }); setRows((prev) => [...prev, ...r.items.map((it) => ({ ...it, target: it.targetLocationId ?? SKIP }))]); setSkipped((prev) => [...prev, ...r.skipped]); } catch (err) { toast.error(errorMessage(err, "解析字幕失败")); } }; const patch = (id: string, p: Partial) => setRows((rs) => rs.map((r) => (r.id === id ? { ...r, ...p } : r))); const chosen = rows.filter((r) => r.target !== SKIP); // 同一个视频选了两份同语言字幕:写入时后一份会被拒绝,先提示 const dup = useMemo(() => { const seen = new Set(); const out = new Set(); for (const r of chosen) { const k = `${r.target}|${r.language}|${r.ext}`; if (seen.has(k)) out.add(r.id); seen.add(k); } return out; }, [chosen]); const submit = async () => { try { const r = await apply.mutateAsync({ entityId, source: "upload", items: chosen.map((x) => ({ name: x.name, ext: x.ext, 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} 个字幕`); if (r.failed.length) toast.error(r.failed.map((f) => `${f.name}:${f.reason}`).join("\n")); setRows((rs) => rs.filter((x) => r.failed.some((f) => f.name === x.name))); setSkipped([]); } catch (err) { toast.error(errorMessage(err, "写入失败")); } }; return ( !busy && onOpenChange(o)}> 补字幕 {title}:字幕写到视频同目录,文件名是视频名加语言后缀;同名字幕会先备份,可以撤销。 {targets.isPending ? ( ) : targets.error ? ( void targets.refetch()} /> ) : !list.length ? ( ) : (
setTab(v as "upload" | "online")}> 上传 在线搜索 {tab === "upload" ? ( ) : ( 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]); }} /> )} {fetchPage.isPending ? (

正在下载原站视图当前页的字幕…

) : null} {rows.length ? (
    {rows.map((r) => (
  • {r.name.split("/").pop()}

    {[ r.archive ? `来自 ${r.archive}` : null, r.episode !== null ? `识别为第 ${r.episode} 集` : null, r.encoding !== "utf-8" ? `${r.encoding} 已转 UTF-8` : null, ] .filter(Boolean) .join(" · ")}

    {dup.has(r.id) ?

    同一集已选了同语言的字幕

    : null} {(() => { const old = list .find((t) => t.locationId === r.target) ?.existing.find( (x) => suffixGroup(x.language) === suffixGroup(r.language) && x.fileName.endsWith(`.${r.ext}`), ); return old ? (

    会覆盖已有的 {old.fileName}(先备份)

    ) : null; })()}
  • ))}
) : null} {skipped.length ? (

跳过:{skipped.map((s) => `${s.name}(${s.reason})`).join("、")}

) : null} undo.mutate(w.id)} undoing={undo.isPending} />
)} {rows.length ? ( ) : null}
); } function History({ items, onUndo, undoing, }: { items: SubtitleWriteView[]; onUndo: (w: SubtitleWriteView) => void; undoing: boolean; }) { if (!items.length) return null; return (

补字幕记录

    {items.slice(0, 30).map((w) => (
  • {w.fileName} {SOURCE_LABEL[w.source]} · {relTime(w.createdAt)} {w.replaced && !w.undoneAt ? 覆盖了旧字幕 : null} {w.undoneAt ? ( 已撤销 ) : ( )}
  • ))}
); } /** 在线搜索:已启用的字幕站(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(seasons.length === 1 ? String(seasons[0]) : "all"); const [episode, setEpisode] = useState(""); const [fetching, setFetching] = useState(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 (
{ e.preventDefault(); run(); }} > setKeyword(e.target.value)} placeholder="默认按作品名搜,可另填关键词" aria-label="关键词" className="min-w-48 flex-1" /> {seasons.length ? ( <> setEpisode(e.target.value)} placeholder="集(可空)" aria-label="集" inputMode="numeric" className="w-24" /> ) : null}
{data?.noSites ? (

还没有启用的字幕站。到「站点 → 添加站点 → 字幕站」添加 SubHD 或字幕库。

) : null} {data?.errors.length ? (

{data.errors.map((e) => `${e.siteName}:${e.reason}`).join(";")}

) : null} {data && !data.noSites && !data.results.length ? (

没搜到(用了:{data.keywords.join("、") || "无"})。可以换个关键词,或去掉季集限制再试。

) : null} {data?.results.length ? (
    {data.results.map((r) => { const key = `${r.siteId}${r.ref}`; return (
  • {r.title}

    {[ 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(" · ")}

  • ); })}
) : null}
); }