feat(subtitles): 字幕库验证码由智能体视觉接管;字幕站原站视图下载当前字幕

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-03 11:43:08 +08:00
1 parent 023063edf4
commit c30a758a74
24 files changed
+466 -41

No files matched your search

@@ -11,6 +11,7 @@ import {
} from "~/api/browser.ts";
import { LoadingState } from "~/components/console/empty-state.tsx";
import { StatusBadge, type StatusTone } from "~/components/console/status-badge.tsx";
import { SubtitleFromPage } from "~/components/subtitles/subtitle-from-page.tsx";
import { Button } from "~/components/ui/button.tsx";
import { apiData, errorMessage } from "~/lib/api.ts";
import { qk } from "~/lib/query-keys.ts";
@@ -209,7 +210,7 @@ function DesktopView({
return (
<div className="space-y-3">
<ResourceDownload session={s} />
{site.kind === "subtitle" ? <SubtitleFromPage session={s} /> : <ResourceDownload session={s} />}
<div className="flex flex-wrap items-center gap-2">
<StatusBadge tone={conn.tone}>{conn.text}</StatusBadge>
<StatusBadge tone={CONTROLLER_TONE[s.controller] ?? "neutral"}>
@@ -31,6 +31,7 @@ import {
} from "~/api/browser.ts";
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { StatusBadge, type StatusTone } from "~/components/console/status-badge.tsx";
import { SubtitleFromPage } from "~/components/subtitles/subtitle-from-page.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Input } from "~/components/ui/input.tsx";
import { errorMessage } from "~/lib/api.ts";
@@ -154,7 +155,7 @@ export function SiteOriginView({ site }: { site: SiteListItem }) {
<p className="text-caption text-muted-foreground">验证码等需本人操作在此完成。</p>
<ReloadNotice session={s} />
<ResourceDownload session={s} />
{site.kind === "subtitle" ? <SubtitleFromPage session={s} /> : <ResourceDownload session={s} />}
<div className="flex flex-wrap items-center gap-2">
<StatusBadge tone={conn.tone}>{conn.text}</StatusBadge>
@@ -6,7 +6,7 @@ import type {
SubtitleWriteView,
} from "@umt/contracts";
import { Download, ExternalLink, FileUp, LoaderCircle, Search, Undo2 } from "lucide-react";
import { useId, useMemo, useRef, useState } from "react";
import { useEffect, useId, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import {
useApplySubtitles,
@@ -68,11 +68,14 @@ export function SubtitleDialog({
title,
open,
onOpenChange,
fromSession,
}: {
entityId: string;
title: string;
open: boolean;
onOpenChange: (o: boolean) => void;
/** 原站视图会话:打开时自动下载它当前页的字幕并配对 */
fromSession?: string;
}) {
const inputId = useId();
const input = useRef<HTMLInputElement>(null);
@@ -86,7 +89,20 @@ export function SubtitleDialog({
const [dragging, setDragging] = useState(false);
const [tab, setTab] = useState<"upload" | "online">("upload");
const list = targets.data?.targets ?? [];
const busy = parse.isPending || apply.isPending;
const fetchPage = useFetchSubtitle();
const busy = parse.isPending || apply.isPending || fetchPage.isPending;
const fetchedFrom = useRef<string | null>(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;
@@ -207,6 +223,12 @@ export function SubtitleDialog({
/>
)}
{fetchPage.isPending ? (
<p className="flex items-center gap-2 text-caption text-muted-foreground">
<LoaderCircle className="size-4 animate-spin" />
正在下载原站视图当前页的字幕…
</p>
) : null}
{rows.length ? (
<ul className="space-y-2">
{rows.map((r) => (
@@ -0,0 +1,64 @@
import type { BrowserSessionView } from "@umt/contracts";
import { Download } from "lucide-react";
import { useState } from "react";
import { EntityPicker, type PickedEntity } from "~/components/library/identity-entity-picker.tsx";
import { Button } from "~/components/ui/button.tsx";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog.tsx";
import { SubtitleDialog } from "./subtitle-dialog.tsx";
/**
* 字幕站的原站视图:人在浏览器里打开一条字幕的详情页(验证码已在浏览器里过)→「下载当前字幕」→ 选作品 →
* 补字幕对话框自动下载这条字幕、按季集配对到视频,可改写到哪个视频后写入。
*/
export function SubtitleFromPage({ session }: { session: BrowserSessionView }) {
const [picking, setPicking] = useState(false);
const [entity, setEntity] = useState<PickedEntity | null>(null);
const [writing, setWriting] = useState<PickedEntity | null>(null);
return (
<>
<Button size="sm" disabled={session.controller === "closed"} onClick={() => setPicking(true)}>
<Download />
下载当前字幕
</Button>
<Dialog open={picking} onOpenChange={setPicking}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>下载当前字幕</DialogTitle>
<DialogDescription>
先在原站视图里打开一条字幕的详情页。选这条字幕属于哪部作品,下一步再选写到哪个视频。
</DialogDescription>
</DialogHeader>
<EntityPicker value={entity} onChange={setEntity} />
<DialogFooter>
<Button
disabled={!entity}
onClick={() => {
setWriting(entity);
setPicking(false);
}}
>
下载并配对
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{writing ? (
<SubtitleDialog
key={writing.id}
entityId={writing.id}
title={writing.title}
fromSession={session.id}
open
onOpenChange={(o) => !o && setWriting(null)}
/>
) : null}
</>
);
}