Files
ttstd-infinite-canvas/web/src/components/canvas/canvas-subtitle-dialog.tsx

600 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useRef, useState } from "react";
import { App, Button, ColorPicker, Input, InputNumber, Progress, Segmented } from "antd";
import { AppModal } from "@/components/ui/product/app-modal";
import { Switch } from "@/components/ui/base/switch";
import { Captions, FileDown, FileUp, ListPlus, LoaderCircle, Plus, Scissors, Sparkles, Trash2 } from "lucide-react";
import { saveAs } from "file-saver";
import { canvasThemes } from "@/lib/canvas-theme";
import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store";
import { useConfigStore, type AiConfig } from "@/stores/use-config-store";
import { resolveMediaUrl } from "@/services/file-storage";
import { parseSrt, serializeSrtEntries } from "@/lib/timeline/srt-parser";
import { DEFAULT_MAX_CHARS_PER_ENTRY, MAX_CHARS_PER_ENTRY_LIMIT, MIN_CHARS_PER_ENTRY, resegmentSrtEntries, splitLongEntry } from "@/lib/timeline/srt-resegment";
import { buildFallbackHighlights, remapHighlightsAfterResegment } from "@/lib/timeline/subtitle-highlights";
import { generateSubtitleHighlights, type SubtitleHighlightProgress } from "@/lib/timeline/subtitle-highlight-runner";
import { createDefaultSubtitleStyle, type SrtEntry, type SubtitleHighlight, type SubtitlePosition, type SubtitleStyle } from "@/types/timeline";
import type { CanvasNodeData, CanvasNodeMetadata } from "@/types/canvas";
import { SubtitleHighlightedText } from "./canvas-subtitle-text";
type CanvasSubtitleDialogProps = {
node: CanvasNodeData;
open: boolean;
projectId: string;
config: AiConfig;
onClose: () => void;
onSave: (nodeId: string, patch: Partial<CanvasNodeMetadata>) => void;
};
export function CanvasSubtitleDialog({ node, open, projectId, config, onClose, onSave }: CanvasSubtitleDialogProps) {
const { message, modal } = App.useApp();
const theme = canvasThemes[useActiveTheme()];
const isAiConfigReady = useConfigStore((state) => state.isAiConfigReady);
const [entries, setEntries] = useState<SrtEntry[]>([]);
const [highlights, setHighlights] = useState<SubtitleHighlight[]>([]);
const [style, setStyle] = useState<SubtitleStyle>(createDefaultSubtitleStyle);
const [running, setRunning] = useState(false);
const [progress, setProgress] = useState<SubtitleHighlightProgress | null>(null);
const [videoUrl, setVideoUrl] = useState("");
const [currentTimeMs, setCurrentTimeMs] = useState(0);
const [videoSize, setVideoSize] = useState<{ width: number; height: number } | null>(null);
const [previewBoxWidth, setPreviewBoxWidth] = useState(0);
const [viewportHeight, setViewportHeight] = useState(0);
const [videoError, setVideoError] = useState(false);
const abortRef = useRef<AbortController | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const textInputRef = useRef<HTMLInputElement>(null);
const previewBoxRef = useRef<HTMLDivElement>(null);
const videoRef = useRef<HTMLVideoElement>(null);
// 打开弹窗时从节点 metadata 同步字幕数据;关闭后卸载,重开重新读取。
useEffect(() => {
if (!open) return;
setEntries(node.metadata?.subtitleEntries || []);
setHighlights(node.metadata?.subtitleHighlights || []);
setStyle(node.metadata?.subtitleStyle || createDefaultSubtitleStyle());
setRunning(false);
setProgress(null);
}, [open, node]);
useEffect(() => {
return () => abortRef.current?.abort();
}, []);
// 打开弹窗时解析视频地址,用于字幕叠加预览;远端资源统一走稳定的云端地址。
useEffect(() => {
if (!open) return;
let cancelled = false;
setCurrentTimeMs(0);
setVideoSize(null);
setVideoError(false);
const storageKey = node.metadata?.storageKey || "";
const fallback = node.metadata?.content || "";
void resolveMediaUrl(storageKey, fallback).then((url) => {
if (!cancelled) setVideoUrl(url);
});
return () => {
cancelled = true;
};
}, [open, node]);
// 监听预览容器与视口尺寸,视频按分辨率等比缩放,不撑满也不变形。
useEffect(() => {
if (!open || !videoUrl) return;
const measure = () => {
setPreviewBoxWidth(previewBoxRef.current?.clientWidth || 0);
setViewportHeight(window.innerHeight);
};
measure();
const observer = new ResizeObserver(measure);
if (previewBoxRef.current) observer.observe(previewBoxRef.current);
window.addEventListener("resize", measure);
return () => {
observer.disconnect();
window.removeEventListener("resize", measure);
};
}, [open, videoUrl]);
const previewDisplay = useMemo(() => {
if (!videoSize || videoSize.height <= 0) return null;
const maxHeight = Math.round((viewportHeight || window.innerHeight) * 0.45);
const maxWidth = Math.max(1, previewBoxWidth || 800);
const ratio = videoSize.width / videoSize.height;
let width = Math.min(maxWidth, videoSize.width);
let height = width / ratio;
if (height > maxHeight) {
height = maxHeight;
width = Math.round(height * ratio);
}
return { width: Math.round(width), height: Math.round(height) };
}, [previewBoxWidth, videoSize, viewportHeight]);
const highlightByEntry = new Map(highlights.map((item) => [item.entryIndex, item]));
const activeEntry = entries.find((entry) => currentTimeMs >= entry.startMs && currentTimeMs < entry.endMs);
const activeHighlight = activeEntry ? highlightByEntry.get(activeEntry.index) : undefined;
const activePositionClass = style.position === "top" ? "top-3" : style.position === "center" ? "top-1/2 -translate-y-1/2" : "bottom-4";
const updateEntry = (position: number, patch: Partial<SrtEntry>) => {
setEntries((current) => current.map((entry, idx) => (idx === position ? { ...entry, ...patch } : entry)));
};
const renumber = (list: SrtEntry[]) => list.map((entry, idx) => ({ ...entry, index: idx + 1 }));
const normalizeEntries = (list: SrtEntry[]) =>
renumber(
list
.filter((entry) => entry.text.trim())
.map((entry) => ({ ...entry, startMs: Math.max(0, entry.startMs), endMs: Math.max(0, entry.endMs) }))
.sort((a, b) => a.startMs - b.startMs || a.index - b.index),
);
const addEntry = () => {
setEntries((current) => {
const last = current[current.length - 1];
const startMs = last ? last.endMs : 0;
return [...current, { index: current.length + 1, startMs, endMs: startMs + 2_000, text: "" }];
});
};
const deleteEntry = (position: number) => {
const removed = entries[position];
if (!removed) return;
const next = normalizeEntries(entries.filter((_, idx) => idx !== position));
const { remapped } = remapHighlightsAfterResegment(highlights, next);
// 互通加固:逐条删除立即持久化到节点并同步时间线,与「清空全部」行为一致,
// 避免删除后直接关闭弹窗(未点保存)导致重开视频节点旧字幕复活。
setEntries(next);
setHighlights(next.length ? remapped : []);
onSave(node.id, {
subtitleEntries: next,
subtitleHighlights: next.length ? remapped : [],
subtitleStyle: style,
subtitleUpdatedAt: new Date().toISOString(),
});
message.success(next.length ? "已删除该条字幕并同步到视频节点与时间线" : "字幕已清空并同步到视频节点与时间线");
};
const splitEntry = (position: number) => {
const entry = entries[position];
if (!entry || !entry.text.trim()) {
message.warning("该条字幕没有可切分的内容");
return;
}
const segments = splitLongEntry(entry, Math.max(2, Math.ceil(entry.text.length / 2)));
const next = renumber([...entries.slice(0, position), ...segments, ...entries.slice(position + 1)]);
setEntries(next);
const { remapped } = remapHighlightsAfterResegment(highlights, next);
setHighlights(remapped);
};
const seekToEntry = (entry: SrtEntry) => {
const video = videoRef.current;
if (video) {
video.currentTime = Math.max(0, entry.startMs / 1000);
setCurrentTimeMs(entry.startMs);
}
};
const resegmentAll = () => {
if (!entries.length) return;
const maxChars = style.maxCharsPerEntry || DEFAULT_MAX_CHARS_PER_ENTRY;
const next = resegmentSrtEntries(entries, maxChars);
if (next.length === entries.length) {
message.info(`所有字幕均未超过单条上限 ${maxChars} 字,无需切分;可在“字幕样式”中调低上限后重试`);
return;
}
setEntries(next);
const { remapped, dropped } = remapHighlightsAfterResegment(highlights, next);
setHighlights(remapped);
message.success(`自动切分完成:${entries.length} 条 → ${next.length} 条`);
if (dropped.length) message.info(`已移除 ${dropped.length} 条未能匹配的旧高亮`);
};
const importSrt = (file: File) => {
const reader = new FileReader();
reader.onload = () => {
const parsed = parseSrt(String(reader.result || ""));
if (!parsed.length) {
message.warning("未解析到有效字幕:文件需为 SRT 格式(序号 + 时间码 + 正文),纯文本请用“导入文本”");
return;
}
const autoSegmented = style.autoResegment ? resegmentSrtEntries(parsed, style.maxCharsPerEntry || DEFAULT_MAX_CHARS_PER_ENTRY) : parsed;
setEntries(renumber(autoSegmented));
setHighlights([]);
message.success(autoSegmented.length > parsed.length ? `已导入 ${parsed.length} 条字幕,按单条上限自动切分为 ${autoSegmented.length} 条` : `已导入 ${parsed.length} 条字幕`);
};
reader.readAsText(file);
};
// 纯文本导入:一行一条字幕,按视频时长平均分配起止时间;视频时长未知时按每条 4 秒估算。
const importPlainText = (file: File) => {
const reader = new FileReader();
reader.onload = () => {
const lines = String(reader.result || "")
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
if (!lines.length) {
message.warning("文本中没有可导入的内容");
return;
}
const durationMs = node.metadata?.durationMs;
const stepMs = durationMs && durationMs > 0 ? durationMs / lines.length : 4_000;
const entries: SrtEntry[] = lines.map((text, idx) => ({
index: idx + 1,
startMs: Math.round(idx * stepMs),
endMs: Math.round((idx + 1) * stepMs),
text,
}));
setEntries(entries);
setHighlights([]);
message.success(durationMs && durationMs > 0 ? `已导入 ${entries.length} 条字幕,按视频时长 ${formatDurationMs(durationMs)} 平均分配` : `已导入 ${entries.length} 条字幕(视频时长未知,按每条 4 秒估算)`);
};
reader.readAsText(file);
};
const exportSrt = () => {
const content = serializeSrtEntries(entries);
if (!content) {
message.warning("暂无字幕可导出");
return;
}
saveAs(new Blob([content], { type: "text/plain;charset=utf-8" }), `${node.title || "subtitle"}.srt`);
};
const runAiHighlight = async () => {
if (!entries.length) {
message.warning("请先添加字幕内容");
return;
}
if (!isAiConfigReady(config, config.textModel)) {
// 未配置文本模型时本地回退:按终止标点取首段作为高亮,保证功能不中断。
setHighlights(buildFallbackHighlights(entries));
message.info("未配置可用的文本模型,已使用本地标点高亮");
return;
}
const controller = new AbortController();
abortRef.current = controller;
setRunning(true);
setProgress({ batchIndex: 0, batchTotal: 1, processedEntries: 0, totalEntries: entries.length, percent: 0 });
try {
const nextHighlights = await generateSubtitleHighlights(entries, {
projectId,
nodeId: node.id,
config,
signal: controller.signal,
onProgress: setProgress,
});
setHighlights(nextHighlights);
message.success(`已生成 ${nextHighlights.length} 条关键词高亮`);
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") return;
message.error(error instanceof Error ? error.message : "关键词高亮生成失败");
} finally {
setRunning(false);
setProgress(null);
abortRef.current = null;
}
};
const cancelAiHighlight = () => {
abortRef.current?.abort();
};
const handleSave = () => {
const normalized = normalizeEntries(entries);
const { remapped } = remapHighlightsAfterResegment(highlights, normalized);
onSave(node.id, {
subtitleEntries: normalized,
subtitleHighlights: normalized.length ? remapped : [],
subtitleStyle: style,
subtitleUpdatedAt: new Date().toISOString(),
});
message.success(normalized.length ? "字幕已保存" : "字幕已清空并保存");
onClose();
};
const formatDurationMs = (ms: number) => {
const seconds = Math.round(ms / 1000);
return seconds >= 60 ? `${Math.floor(seconds / 60)}分${seconds % 60}秒` : `${seconds}秒`;
};
const previewBlock = (
<div className="flex min-h-0 flex-col gap-2">
<div ref={previewBoxRef} className="flex min-h-0 flex-1 items-center justify-center overflow-hidden rounded-xl border" style={{ borderColor: theme.toolbar.border, background: "#000" }}>
{videoUrl ? (
<div className="relative" style={previewDisplay ? { width: previewDisplay.width, height: previewDisplay.height } : { width: "100%", maxWidth: 640, aspectRatio: "16 / 9" }}>
<video
ref={videoRef}
className="block h-full w-full"
src={videoUrl}
controls
playsInline
preload="metadata"
onLoadedMetadata={(event) => {
const video = event.currentTarget;
if (video.videoWidth > 0 && video.videoHeight > 0) {
setVideoSize({ width: video.videoWidth, height: video.videoHeight });
}
}}
onError={() => setVideoError(true)}
onTimeUpdate={(event) => setCurrentTimeMs(Math.round(event.currentTarget.currentTime * 1000))}
/>
{videoError ? <div className="absolute inset-0 grid place-items-center text-xs opacity-70">视频预览加载失败,请检查素材是否仍然可用</div> : null}
{activeEntry ? (
<div className={`pointer-events-none absolute inset-x-0 flex justify-center px-4 ${activePositionClass}`}>
<span className="rounded-lg px-2 py-0.5 text-center leading-7" style={{ background: "rgba(0,0,0,.62)", color: style.color, fontSize: style.fontSize, borderRadius: style.highlightRadius, maxWidth: "90%" }}>
<SubtitleHighlightedText text={activeEntry.text} highlight={activeHighlight} style={style} />
</span>
</div>
) : null}
</div>
) : (
<div className="grid h-40 w-full max-w-[640px] place-items-center px-4 text-center text-xs opacity-60">{videoError ? "视频预览加载失败,请检查素材是否仍然可用" : "暂无视频素材可预览,可先在节点上传或生成视频"}</div>
)}
</div>
<div className="shrink-0 text-center text-xs opacity-40">点击字幕条目可跳转预览</div>
</div>
);
const styleControls = (
<>
<label className="flex items-center justify-between gap-2 text-xs opacity-70">
<span>字号</span>
<InputNumber size="small" min={12} max={40} value={style.fontSize} onChange={(fontSize) => setStyle((current) => ({ ...current, fontSize: fontSize ?? current.fontSize }))} className="w-20" />
</label>
<label className="flex items-center justify-between gap-2 text-xs opacity-70">
<span>颜色</span>
<ColorPicker size="small" value={style.color} onChange={(color) => setStyle((current) => ({ ...current, color: color.toHexString() }))} />
</label>
<div className="text-xs opacity-70">
<div className="mb-1">位置</div>
<Segmented
block
size="small"
value={style.position}
options={[
{ label: "顶部", value: "top" },
{ label: "居中", value: "center" },
{ label: "底部", value: "bottom" },
]}
onChange={(position) => setStyle((current) => ({ ...current, position: position as SubtitlePosition }))}
/>
</div>
<label className="flex items-center justify-between gap-2 text-xs opacity-70">
<span>单条上限</span>
<span className="flex items-center gap-1">
<InputNumber
size="small"
min={MIN_CHARS_PER_ENTRY}
max={MAX_CHARS_PER_ENTRY_LIMIT}
value={style.maxCharsPerEntry}
onChange={(maxCharsPerEntry) => setStyle((current) => ({ ...current, maxCharsPerEntry: maxCharsPerEntry ?? DEFAULT_MAX_CHARS_PER_ENTRY }))}
className="w-20"
/>
<span className="opacity-40">字</span>
</span>
</label>
<div className="border-t pt-3" style={{ borderColor: theme.toolbar.border }}>
<div className="mb-2 flex items-center justify-between text-xs font-medium opacity-55">
<span>关键词高亮</span>
<Switch size="sm" checked={style.highlightEnabled} onChange={(highlightEnabled) => setStyle((current) => ({ ...current, highlightEnabled }))} />
</div>
<div className="space-y-2.5">
<label className="flex items-center justify-between gap-2 text-xs opacity-70">
<span>背景色</span>
<ColorPicker size="small" value={style.highlightBackgroundColor} onChange={(color) => setStyle((current) => ({ ...current, highlightBackgroundColor: color.toHexString() }))} />
</label>
<label className="flex items-center justify-between gap-2 text-xs opacity-70">
<span>文字色</span>
<ColorPicker size="small" value={style.highlightTextColor} onChange={(color) => setStyle((current) => ({ ...current, highlightTextColor: color.toHexString() }))} />
</label>
<label className="flex items-center justify-between gap-2 text-xs opacity-70">
<span>内边距 X</span>
<InputNumber size="small" min={0} max={24} value={style.highlightPaddingX} onChange={(highlightPaddingX) => setStyle((current) => ({ ...current, highlightPaddingX: highlightPaddingX ?? 0 }))} className="w-20" />
</label>
<label className="flex items-center justify-between gap-2 text-xs opacity-70">
<span>内边距 Y</span>
<InputNumber size="small" min={0} max={24} value={style.highlightPaddingY} onChange={(highlightPaddingY) => setStyle((current) => ({ ...current, highlightPaddingY: highlightPaddingY ?? 0 }))} className="w-20" />
</label>
<label className="flex items-center justify-between gap-2 text-xs opacity-70">
<span>圆角</span>
<InputNumber size="small" min={0} max={24} value={style.highlightRadius} onChange={(highlightRadius) => setStyle((current) => ({ ...current, highlightRadius: highlightRadius ?? 0 }))} className="w-20" />
</label>
</div>
</div>
</>
);
const entriesBlock = (
<div className="thin-scrollbar min-h-0 space-y-2 overflow-y-auto pr-1">
{entries.length ? (
entries.map((entry, idx) => {
const highlight = highlightByEntry.get(entry.index);
return (
<div
key={`${entry.index}-${idx}`}
className="cursor-pointer rounded-xl border p-2.5 transition-colors"
title="点击跳转到该条字幕时间"
style={{ background: theme.node.fill, borderColor: activeEntry?.index === entry.index ? theme.accent.primary : theme.node.stroke }}
onClick={(event) => {
const target = event.target as HTMLElement;
if (target.closest("input,button,textarea")) return;
seekToEntry(entry);
}}
>
<div className="flex items-center gap-1.5">
<span className="grid size-6 shrink-0 place-items-center rounded-md text-xs font-semibold" style={{ background: theme.accent.primarySoft, color: theme.accent.primary }}>
{idx + 1}
</span>
<InputNumber size="small" min={0} step={100} value={entry.startMs} onChange={(startMs) => updateEntry(idx, { startMs: startMs ?? 0 })} className="w-32" aria-label={`第 ${idx + 1} 条开始时间(毫秒)`} />
<span className="text-xs opacity-40">→</span>
<InputNumber size="small" min={0} step={100} value={entry.endMs} onChange={(endMs) => updateEntry(idx, { endMs: endMs ?? 0 })} className="w-32" aria-label={`第 ${idx + 1} 条结束时间(毫秒)`} />
<button
type="button"
title="切分这条字幕"
className="ml-auto grid size-7 place-items-center rounded-lg border transition-colors hover:opacity-75"
style={{ borderColor: theme.toolbar.border }}
disabled={running}
onClick={() => splitEntry(idx)}
>
<Scissors className="size-3.5" />
</button>
<button
type="button"
title="删除这条字幕"
className="grid size-7 place-items-center rounded-lg border text-red-500 transition-colors hover:opacity-75"
style={{ borderColor: theme.toolbar.border }}
disabled={running}
onClick={() => deleteEntry(idx)}
>
<Trash2 className="size-3.5" />
</button>
</div>
<Input.TextArea value={entry.text} autoSize={{ minRows: 1, maxRows: 3 }} placeholder="字幕文本" className="mt-2" onChange={(event) => updateEntry(idx, { text: event.target.value })} />
{highlight ? (
<div className="mt-1.5 text-xs" style={{ color: theme.accent.primary }}>
重点:{highlight.highlightText}
</div>
) : null}
</div>
);
})
) : (
<div className="grid h-40 place-items-center text-center">
<div className="text-xs opacity-45">
<ListPlus className="mx-auto mb-2 size-8" />
还没有字幕:导入 SRT 或文本文件,或点击工具栏“新增字幕”。
</div>
</div>
)}
</div>
);
const title = (
<div className="flex min-w-0 items-center gap-2.5 pr-10">
<span className="grid size-8 shrink-0 place-items-center rounded-lg" style={{ background: theme.accent.primarySoft, color: theme.accent.primary }}>
<Captions className="size-4" />
</span>
<div className="min-w-0">
<div className="truncate text-[var(--fs-heading-lg)] font-semibold leading-6 tracking-[-0.02em]">字幕编辑</div>
<div className="truncate text-xs opacity-45">{node.title || "视频节点"}</div>
</div>
</div>
);
return (
<AppModal className="canvas-subtitle-dialog" title={title} open={open} centered footer={null} width={1120} destroyOnHidden onCancel={onClose} flush>
<div className="flex h-[min(72vh,680px)] min-h-[420px] flex-col text-sm" style={{ color: theme.node.text }}>
<div className="flex flex-wrap items-center gap-2 border-b px-4 py-3" style={{ borderColor: theme.toolbar.border, background: theme.toolbar.panel }}>
<input
ref={fileInputRef}
type="file"
accept=".srt"
className="hidden"
onChange={(event) => {
const file = event.target.files?.[0];
if (file) importSrt(file);
event.target.value = "";
}}
/>
<input
ref={textInputRef}
type="file"
accept=".txt,text/plain"
className="hidden"
onChange={(event) => {
const file = event.target.files?.[0];
if (file) importPlainText(file);
event.target.value = "";
}}
/>
<Button size="small" icon={<FileUp className="size-3.5" />} onClick={() => fileInputRef.current?.click()}>
导入 SRT
</Button>
<Button size="small" icon={<FileUp className="size-3.5" />} onClick={() => textInputRef.current?.click()}>
导入文本
</Button>
<Button size="small" icon={<FileDown className="size-3.5" />} disabled={!entries.length} onClick={exportSrt}>
导出 SRT
</Button>
<Button size="small" icon={<Scissors className="size-3.5" />} disabled={!entries.length || running} onClick={resegmentAll}>
自动切分
</Button>
<Button size="small" icon={<Plus className="size-3.5" />} disabled={running} onClick={addEntry}>
新增字幕
</Button>
<Button
size="small"
danger
icon={<Trash2 className="size-3.5" />}
disabled={!entries.length || running}
onClick={() =>
modal.confirm({
title: "清空全部字幕",
content: `将删除全部 ${entries.length} 条字幕及关键词高亮,此操作不可撤销。`,
okText: "清空",
okButtonProps: { danger: true },
cancelText: "取消",
onOk: () => {
// 互通加固:清空全部立即持久化到节点并同步时间线,避免清空后直接关闭弹窗(未点保存)导致重开视频节点旧字幕复活。
onSave(node.id, {
subtitleEntries: [],
subtitleHighlights: [],
subtitleStyle: style,
subtitleUpdatedAt: new Date().toISOString(),
});
setEntries([]);
setHighlights([]);
message.success("已清空全部字幕并同步到视频节点与时间线");
},
})
}
>
清空全部
</Button>
<Button
size="small"
type={running ? "default" : "primary"}
icon={running ? <LoaderCircle className="size-3.5 animate-spin" /> : <Sparkles className="size-3.5" />}
disabled={!entries.length}
onClick={running ? cancelAiHighlight : () => void runAiHighlight()}
>
{running ? "取消高亮" : "AI 关键词高亮"}
</Button>
</div>
{progress && running ? (
<div className="border-b px-4 py-2" style={{ borderColor: theme.toolbar.border }}>
<Progress percent={progress.percent} size="small" format={() => `${progress.processedEntries}/${progress.totalEntries} 条 · 批次 ${progress.batchIndex}/${progress.batchTotal}`} />
</div>
) : null}
<div className="min-h-0 flex-1 px-4 py-3">
<div className="grid h-full grid-cols-[minmax(250px,300px)_minmax(0,1fr)_minmax(230px,270px)] gap-3">
{entriesBlock}
{previewBlock}
<div className="thin-scrollbar min-h-0 space-y-3 overflow-y-auto pr-1">
<div className="text-xs font-medium opacity-55">字幕样式</div>
{styleControls}
</div>
</div>
</div>
<div className="flex items-center justify-between gap-3 border-t px-4 py-3" style={{ borderColor: theme.toolbar.border, background: theme.toolbar.panel }}>
<div className="text-xs opacity-45">
{entries.length} 条字幕 · 单条上限 {style.maxCharsPerEntry || DEFAULT_MAX_CHARS_PER_ENTRY} 字
</div>
<div className="flex items-center gap-2">
<Button disabled={running} onClick={onClose}>
取消
</Button>
<Button type="primary" disabled={running} onClick={handleSave}>
保存
</Button>
</div>
</div>
</div>
</AppModal>
);
}