Files
xiaoxia-saas/apps/web/src/pages/editing-planner/EditingPlanner.tsx
T
灵应 87357c8bde
CI/CD Pipeline / Frontend Lint (push) Failing after 71h21m22s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 71h22m9s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1729h56m55s
CI/CD Pipeline / Deploy Production (push) Failing after 1729h56m57s
CI/CD Pipeline / Deploy Staging (push) Failing after 1729h56m59s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1729h56m57s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1730h28m26s
fix: 修复 CI Lint 全部 22 个 ESLint 警告
- e2e: request: any → APIRequestContext 类型替换(6个文件)
- e2e: 移除未使用变量 _sourceAssetId, _hasProgress
- src: react-refresh/only-export-components 添加 eslint-disable 注释
  (MainLayout, Input, router/index)
- src: EditingPlanner useEffect 补全 resetClips 依赖
- src: MyVoices okButtonProps 类型修正 as ButtonProps

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 13:32:17 +08:00

818 lines
26 KiB
TypeScript
Executable File
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.
/**
* 剪辑计划编辑器 — V8 原型 1:1 还原
* 四行布局:顶栏(42px) → 模式栏(48px) → 三栏主体 → 底栏(40px)
*/
import React, { useState, useCallback, useEffect } from "react";
import { useSearchParams, useNavigate } from "react-router-dom";
// 简易消息工具(UI 库未导出 message)
const msg = {
success: (s: string) => console.log(`✅ ${s}`),
error: (s: string) => console.error(`❌ ${s}`),
warning: (s: string) => console.warn(`⚠️ ${s}`),
info: (s: string) => console.info(`ℹ️ ${s}`),
};
import type {
EditingTemplate,
TemplateCategory,
TemplateMode,
SaveTemplatePayload,
} from "@/api/editingPlanner";
import {
getEditingTemplates,
getEditingTemplate,
updateEditingTemplate,
getTemplateCategories,
generateFromTemplate,
MODE_LABELS,
} from "@/api/editingPlanner";
import type {
EditPlanClip,
EditPlanGeneration,
MediaAsset,
} from "@/api/editPlans";
import {
getMediaAssets,
getEditPlanGenerations,
MATERIAL_TYPE_LABELS,
aiRecommendClips,
generateCover,
} from "@/api/editPlans";
import { useUndoRedo } from "./hooks/useUndoRedo";
import type { TaskItem } from "@/api/tasks";
import { createGenerationTask, getTask, retryTask } from "@/api/tasks";
import MediaPanel from "./components/MediaPanel";
import PreviewPlayer from "./components/PreviewPlayer";
import TimelinePanel from "./components/TimelinePanel";
import ClipPropertiesPanel from "./components/ClipPropertiesPanel";
import SaveModal from "./components/SaveModal";
import GenerationProgressModal from "./components/GenerationProgressModal";
import type { GenPhase } from "./components/GenerationProgressModal";
import GenerationHistoryModal from "./components/GenerationHistoryModal";
import "./EditingPlanner.css";
/* ──────────── 类型 ──────────── */
interface ClipData extends EditPlanClip {
name: string;
thumbnail?: string;
assetName?: string;
}
/* ──────────── 常量 ──────────── */
const MODE_LIST: { key: TemplateMode; label: string; icon: string }[] = [
{ key: "pip", label: "画中画", icon: "🖼️" },
{ key: "voice_over", label: "人物口播", icon: "🎙️" },
{ key: "one_take", label: "一镜到底", icon: "🎥" },
{ key: "voice_pip", label: "口播+画中画", icon: "🎭" },
];
const COVER_SCHEMES = [
{ key: "ai_frame", label: "AI选帧" },
{ key: "manual", label: "手动选" },
{ key: "upload", label: "上传" },
{ key: "ai_reselect", label: "AI重选" },
];
const FILTER_CATEGORIES = ["全部", "种草", "知识", "日常", "推荐"];
/* ──────────── 组件 ──────────── */
const EditingPlanner: React.FC = () => {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const urlTemplateId = searchParams.get("templateId") || "";
/* ── 模板列表 ── */
const [templates, setTemplates] = useState<EditingTemplate[]>([]);
const [categories, setCategories] = useState<TemplateCategory[]>([]);
const [loadingTemplates, setLoadingTemplates] = useState(false);
const [loadedTemplateId, setLoadedTemplateId] = useState<string | null>(
urlTemplateId || null,
);
const [currentMode, setCurrentMode] = useState<TemplateMode>("pip");
/* ── 片段(撤销/重做) ── */
const {
state: clips,
set: setClips,
undo,
redo,
canUndo,
canRedo,
reset: resetClips,
} = useUndoRedo<ClipData[]>([]);
const [selectedClipId, setSelectedClipId] = useState<string | null>(null);
/* ── AI 操作状态 ── */
const [aiRecommendLoading, setAiRecommendLoading] = useState(false);
const [aiCoverLoading, setAiCoverLoading] = useState(false);
/* ── 封面方案 ── */
const [currentCoverScheme, setCurrentCoverScheme] =
useState<string>("ai_frame");
/* ── 左栏筛选 ── */
const [currentFilter, setCurrentFilter] = useState("全部");
const [searchQuery, setSearchQuery] = useState("");
/* ── 标题/字幕/BGM 设置 ── */
const [titleSettings, setTitleSettings] = useState({
aiAutoSelect: true,
title: "",
position: "top",
font: "思源黑体",
size: 24,
bold: false,
italic: false,
stroke: true,
shadow: true,
});
const [subtitleSettings, setSubtitleSettings] = useState({
enabled: true,
position: "bottom",
font: "思源黑体",
size: 16,
animation: "none",
});
const [bgmSettings, setBgmSettings] = useState({
music: "none",
});
/* ── 保存弹窗 ── */
const [saveModalOpen, setSaveModalOpen] = useState(false);
const [draftName, setDraftName] = useState("");
const [draftCategory, setDraftCategory] = useState("");
const [draftTags, setDraftTags] = useState("");
const [saveLoading, setSaveLoading] = useState(false);
/* ── 生成弹窗 ── */
const [genModalOpen, setGenModalOpen] = useState(false);
const [genPhase, setGenPhase] = useState<GenPhase>("setup");
const [genTask, setGenTask] = useState<TaskItem | null>(null);
const [genSubmitting, setGenSubmitting] = useState(false);
const [voiceoverDuration, setVoiceoverDuration] = useState<number | null>(
null,
);
/* ── 素材库 ── */
const [mediaAssets, setMediaAssets] = useState<MediaAsset[]>([]);
const [selectedAssetIds, setSelectedAssetIds] = useState<string[]>([]);
const handleAssetSelect = (ids: string[]) => {
setSelectedAssetIds(ids);
};
/* ── 生成历史 ── */
const [genHistoryOpen, setGenHistoryOpen] = useState(false);
const [genHistory, setGenHistory] = useState<EditPlanGeneration[]>([]);
const [genHistoryLoading, setGenHistoryLoading] = useState(false);
/* ── 播放 ── */
const [isPlaying, setIsPlaying] = useState(false);
/* ──────────── 加载 ──────────── */
/**
* 并行加载模板列表、分类、素材库
* 首次挂载时调用,三个接口无依赖关系,用 Promise.all 并发
*/
const loadTemplates = useCallback(async () => {
setLoadingTemplates(true);
try {
const [tpls, cats, assets] = await Promise.all([
getEditingTemplates(),
getTemplateCategories(),
getMediaAssets(),
]);
setTemplates(tpls);
setCategories(cats);
setMediaAssets(assets);
} catch {
msg.error("加载模板失败");
} finally {
setLoadingTemplates(false);
}
}, []);
useEffect(() => {
loadTemplates();
}, [loadTemplates]);
/**
* 加载模板详情并初始化片段列表
* 将后端 segments 映射为前端 ClipData,取 duration_min/max 均值作为默认时长
* 同时还原标题/字幕/BGM 配置
*/
useEffect(() => {
if (!loadedTemplateId) return;
getEditingTemplate(loadedTemplateId)
.then((tpl) => {
if (!tpl) return;
setCurrentMode(tpl.mode);
const mapped: ClipData[] = tpl.segments.map((seg, idx) => ({
id: seg.id || `seg-${idx}`,
template_segment_id: seg.id || `seg-${idx}`,
name: `片段${idx + 1} · ${MATERIAL_TYPE_LABELS[seg.material_type || "video"] || "视频"}`,
material_type:
(seg.material_type as ClipData["material_type"]) || "video",
script_text: "",
duration: (seg.duration_min + seg.duration_max) / 2,
order: seg.segment_order,
assetName: undefined,
transition: { type: "none", duration: 0 },
}));
resetClips(mapped);
setTitleSettings((prev) => ({
...prev,
aiAutoSelect: tpl.title_config.ai_auto_select,
title: tpl.title_config.content,
position: tpl.title_config.position,
font: tpl.title_config.font_preset,
size: tpl.title_config.font_size,
}));
setSubtitleSettings({
enabled: tpl.subtitle_config.enabled,
position: tpl.subtitle_config.position,
font: tpl.subtitle_config.font,
size: tpl.subtitle_config.size,
animation: tpl.subtitle_config.animation,
});
setBgmSettings({
music: tpl.bgm_config.music_id || "none",
});
setDraftName(tpl.name);
setDraftCategory(tpl.category);
setDraftTags(tpl.tags.join(", "));
})
.catch(() => msg.error("加载模板详情失败"));
}, [loadedTemplateId, resetClips]);
/* ──────────── 计算 ──────────── */
const currentTemplate = templates.find((t) => t.id === loadedTemplateId);
const totalDuration = clips.reduce((sum, c) => sum + c.duration, 0);
const selectedClip = clips.find((c) => c.id === selectedClipId) || null;
const filteredTemplates = templates.filter((t) => {
if (currentFilter !== "全部" && t.category !== currentFilter) return false;
if (
searchQuery &&
!t.name.toLowerCase().includes(searchQuery.toLowerCase())
)
return false;
return true;
});
/* ──────────── 事件 ──────────── */
const handleLoadTemplate = (templateId: string) => {
setLoadedTemplateId(templateId);
setSelectedClipId(null);
};
const handleModeChange = (mode: TemplateMode) => {
setCurrentMode(mode);
};
const handleClipSelect = (clipId: string) => {
setSelectedClipId(clipId);
};
const handleClipReorder = (fromIdx: number, toIdx: number) => {
setClips((prev) => {
const updated = [...prev];
const [moved] = updated.splice(fromIdx, 1);
updated.splice(toIdx, 0, moved);
return updated.map((c, i) => ({ ...c, order: i }));
});
};
const handleClipRemove = (clipId: string) => {
setClips((prev) => prev.filter((c) => c.id !== clipId));
if (selectedClipId === clipId) setSelectedClipId(null);
};
const handleClipUpdate = (clipId: string, data: Partial<ClipData>) => {
setClips((prev) =>
prev.map((c) => (c.id === clipId ? { ...c, ...data } : c)),
);
};
const handleAssetDropToClip = (clipId: string, asset: MediaAsset) => {
handleClipUpdate(clipId, {
media_asset_id: asset.id,
assetName: asset.name,
thumbnail: asset.thumbnail_url,
});
};
const handleAssetDropToTimeline = (asset: MediaAsset) => {
const newClip: ClipData = {
id: `clip-${Date.now()}`,
template_segment_id: "",
name: `片段${clips.length + 1} · ${MATERIAL_TYPE_LABELS[asset.type]}`,
material_type:
asset.type === "audio"
? "voiceover"
: asset.type === "image"
? "image"
: "video",
script_text: "",
duration: asset.duration || 5,
order: clips.length,
media_asset_id: asset.id,
assetName: asset.name,
thumbnail: asset.thumbnail_url,
transition: { type: "none", duration: 0 },
};
setClips((prev) => [...prev, newClip]);
};
/**
* AI 推荐片段方案
* 将当前已绑定素材的片段 ID 列表 + 编辑模式 + 目标时长发送给后端
* 返回的推荐结果直接替换当前片段列表(不保留原手动编排)
*/
const handleAiRecommend = async () => {
if (!loadedTemplateId) {
msg.warning("请先加载模板");
return;
}
const assetIds = clips
.map((c) => c.media_asset_id)
.filter((v): v is string => !!v);
if (assetIds.length === 0) {
msg.warning("请先添加素材到片段");
return;
}
setAiRecommendLoading(true);
try {
const res = await aiRecommendClips(loadedTemplateId, {
asset_ids: assetIds,
editing_mode: currentMode,
target_duration: totalDuration || 30,
});
const mapped: ClipData[] = res.clips.map((c, i) => ({
id: `clip-ai-${i}-${Date.now()}`,
template_segment_id: "",
name: `片段${i + 1} · ${c.clip_type}`,
material_type: (c.clip_type as ClipData["material_type"]) || "video",
script_text: c.text_content || "",
duration: c.duration || 5,
order: c.order ?? i,
media_asset_id: c.asset_id || undefined,
assetName: undefined,
transition: { type: "none", duration: 0 },
}));
setClips(mapped);
msg.success(`AI 推荐了 ${mapped.length} 个片段`);
} catch {
msg.error("AI 推荐失败");
} finally {
setAiRecommendLoading(false);
}
};
/* AI 封面生成 */
const handleAiGenerateCover = async (
coverType: "ai_frame" | "ai_regenerate",
) => {
if (!loadedTemplateId) return;
const assetIds = clips
.map((c) => c.media_asset_id)
.filter((v): v is string => !!v);
if (assetIds.length === 0) {
msg.warning("请先添加素材");
return;
}
setAiCoverLoading(true);
try {
await generateCover(loadedTemplateId, {
asset_ids: assetIds,
cover_type: coverType,
});
setCurrentCoverScheme(
coverType === "ai_frame" ? "ai_frame" : "ai_reselect",
);
msg.success("AI 封面生成成功");
} catch {
msg.error("AI 封面生成失败");
} finally {
setAiCoverLoading(false);
}
};
/* 保存 */
const handleOpenSaveModal = () => {
if (!loadedTemplateId) {
msg.warning("请先加载一个模板");
return;
}
setSaveModalOpen(true);
};
const handleSave = async () => {
if (!draftName.trim()) {
msg.warning("请输入模板名称");
return;
}
if (!loadedTemplateId) return;
setSaveLoading(true);
try {
const payload: SaveTemplatePayload = {
name: draftName,
mode: currentMode,
category: draftCategory,
tags: draftTags
.split(",")
.map((t) => t.trim())
.filter(Boolean),
title_config: {
ai_auto_select: titleSettings.aiAutoSelect,
content: titleSettings.title,
font_preset: titleSettings.font,
font_color: "#ffffff",
font_size: titleSettings.size,
position: titleSettings.position,
},
subtitle_config: {
enabled: subtitleSettings.enabled,
position: subtitleSettings.position,
font: subtitleSettings.font,
color: "#ffffff",
size: subtitleSettings.size,
animation: subtitleSettings.animation,
},
bgm_config: {
enabled: bgmSettings.music !== "none",
music_id: bgmSettings.music,
},
estimated_duration: totalDuration,
segments: clips.map((c, i) => ({
segment_order: i,
duration_min: Math.max(1, c.duration - 2),
duration_max: c.duration + 2,
material_type: c.material_type,
})),
};
await updateEditingTemplate(loadedTemplateId, payload);
msg.success("模板保存成功");
setSaveModalOpen(false);
loadTemplates();
} catch {
msg.error("保存失败");
} finally {
setSaveLoading(false);
}
};
/**
* 跳转到一键生成页面
* 通过 URL SearchParams 传递 edit_plan_id 和完整 planConfig(JSON 序列化)
* 一键生成页面从 params 解析配置,无需重复请求接口
*/
const handleGoToGenerate = () => {
if (!loadedTemplateId) {
msg.warning("请先加载一个模板");
return;
}
const planConfig = {
title_config: {
ai_auto_select: titleSettings.aiAutoSelect,
content: titleSettings.title,
position: titleSettings.position,
font_preset: titleSettings.font,
font_size: titleSettings.size,
bold: titleSettings.bold,
italic: titleSettings.italic,
stroke: titleSettings.stroke,
shadow: titleSettings.shadow,
},
subtitle_config: {
enabled: subtitleSettings.enabled,
position: subtitleSettings.position,
font: subtitleSettings.font,
size: subtitleSettings.size,
animation: subtitleSettings.animation,
},
bgm_config: {
enabled: bgmSettings.music !== "none",
music_id: bgmSettings.music,
},
mode: currentMode,
total_duration: totalDuration,
segments: clips.map((c, i) => ({
order: i,
name: c.name,
material_type: c.material_type,
duration: c.duration,
media_asset_id: c.media_asset_id,
template_segment_id: c.template_segment_id,
script_text: c.script_text,
})),
};
const params = new URLSearchParams();
params.set("edit_plan_id", loadedTemplateId);
params.set("plan_config", JSON.stringify(planConfig));
navigate(`/app/generate?${params.toString()}`);
};
/**
* 创建生成任务(两步)
* 1. generateFromTemplate — 通知后端基于模板生成视频
* 2. createGenerationTask — 创建任务记录,返回精简响应
* 再用 getTask 查询完整 TaskItem 供轮询使用
*/
const handleGenerate = async () => {
if (!loadedTemplateId) return;
setGenSubmitting(true);
try {
await generateFromTemplate(loadedTemplateId, {
voiceover_duration: voiceoverDuration || totalDuration,
});
const res = await createGenerationTask({
template_id: loadedTemplateId,
asset_ids: clips
.map((c) => c.media_asset_id)
.filter((v): v is string => !!v),
title_ids: [],
voice_ids: [],
});
/* 创建接口返回的是精简响应,需查询完整 TaskItem 用于轮询 */
const task = await getTask(res.id);
setGenTask(task);
setGenPhase("progress");
msg.info("生成任务已创建");
} catch {
msg.error("创建生成任务失败");
} finally {
setGenSubmitting(false);
}
};
/**
* 轮询生成任务状态(每 3 秒)
* 仅在 genPhase === "progress" 且有任务 ID 时启动
* 任务完成/失败时自动停止轮询
*/
useEffect(() => {
if (genPhase !== "progress" || !genTask?.id) return;
const timer = setInterval(async () => {
try {
const t = await getTask(genTask.id);
setGenTask(t);
if (t.status === "completed") {
setGenPhase("completed");
clearInterval(timer);
} else if (t.status === "failed") {
setGenPhase("failed");
clearInterval(timer);
}
} catch {
/* ignore */
}
}, 3000);
return () => clearInterval(timer);
}, [genPhase, genTask?.id]);
const handleRetry = async () => {
if (!genTask?.id) return;
setGenSubmitting(true);
try {
const t = await retryTask(genTask.id);
setGenTask(t);
setGenPhase("progress");
} catch {
msg.error("重试失败");
} finally {
setGenSubmitting(false);
}
};
const handleCancelGen = () => {
setGenModalOpen(false);
setGenPhase("setup");
setGenTask(null);
setVoiceoverDuration(null);
};
/* 查看生成历史 */
const handleViewGenHistory = async () => {
if (!loadedTemplateId) {
msg.warning("请先加载一个模板");
return;
}
setGenHistoryOpen(true);
setGenHistoryLoading(true);
try {
const items = await getEditPlanGenerations(loadedTemplateId);
setGenHistory(items);
} catch {
msg.error("加载生成历史失败");
} finally {
setGenHistoryLoading(false);
}
};
/* ──────────── 渲染 ──────────── */
return (
<div className="ep-v8-root">
{/* ═══ 第1行:顶栏 42px ═══ */}
<div className="ep-top-bar">
<div className="ep-top-bar-left">
<span className="ep-logo">✂️</span>
<span className="ep-app-title">小虾剪辑编排器</span>
<span className="ep-divider">|</span>
<span className="ep-template-name">
{currentTemplate?.name || "未选择模板"}
</span>
</div>
<div className="ep-top-bar-right">
<button
className="ep-btn ep-btn-secondary"
onClick={undo}
disabled={!canUndo}
title="撤销 (Ctrl+Z)"
>
⬅️ 撤销
</button>
<button
className="ep-btn ep-btn-secondary"
onClick={redo}
disabled={!canRedo}
title="重做 (Ctrl+Shift+Z)"
>
➡️ 重做
</button>
<button
className="ep-btn ep-btn-secondary"
onClick={handleOpenSaveModal}
>
💾 保存模板
</button>
<button
className="ep-btn ep-btn-primary"
onClick={handleGoToGenerate}
>
🎬 使用此模板生成
</button>
</div>
</div>
{/* ═══ 第2行:模式栏 56px(V21风格)═══ */}
<div className="ep-mode-bar">
<span className="ep-mode-bar-label">剪辑模式:</span>
{MODE_LIST.map((m) => (
<button
key={m.key}
className={`ep-mode-btn ${currentMode === m.key ? "active" : ""}`}
onClick={() => handleModeChange(m.key)}
>
<span className="ep-mode-icon">{m.icon}</span>
<span className="ep-mode-label">{m.label}</span>
</button>
))}
</div>
{/* ═══ 第3行:三栏主体 ═══ */}
<div className="ep-main-body">
{/* 左栏 220px:模板列表 */}
<MediaPanel
templates={filteredTemplates}
loading={loadingTemplates}
searchQuery={searchQuery}
currentFilter={currentFilter}
filterCategories={FILTER_CATEGORIES}
loadedTemplateId={loadedTemplateId}
onLoadTemplate={handleLoadTemplate}
onSearchChange={setSearchQuery}
onFilterChange={setCurrentFilter}
mediaAssets={mediaAssets}
onAssetSelect={handleAssetSelect}
selectedAssetIds={selectedAssetIds}
/>
{/* 中栏 flex-1 */}
<div className="ep-center-col">
{/* 上半部:视频预览 + 封面预览 */}
<PreviewPlayer
clips={clips}
selectedClipId={selectedClipId}
isPlaying={isPlaying}
currentCoverScheme={currentCoverScheme}
coverSchemes={COVER_SCHEMES}
aiCoverLoading={aiCoverLoading}
titleSettings={titleSettings}
subtitleSettings={subtitleSettings}
onClipSelect={handleClipSelect}
onCoverSchemeChange={setCurrentCoverScheme}
onPlayPause={() => setIsPlaying(!isPlaying)}
onAiGenerateCover={handleAiGenerateCover}
/>
{/* 下半部:水平时间线 */}
<TimelinePanel
clips={clips}
selectedClipId={selectedClipId}
currentMode={currentMode}
onClipSelect={handleClipSelect}
onClipReorder={handleClipReorder}
onClipRemove={handleClipRemove}
onAssetDropToTimeline={handleAssetDropToTimeline}
onAssetDropToClip={handleAssetDropToClip}
mediaAssets={mediaAssets}
/>
</div>
{/* 右栏 260px:设置面板 */}
<ClipPropertiesPanel
selectedClip={selectedClip}
titleSettings={titleSettings}
subtitleSettings={subtitleSettings}
bgmSettings={bgmSettings}
clipsCount={clips.length}
totalDuration={totalDuration}
currentMode={currentMode}
aiRecommendLoading={aiRecommendLoading}
onTitleSettingsChange={(partial) =>
setTitleSettings((prev) => ({ ...prev, ...partial }))
}
onSubtitleSettingsChange={(partial) =>
setSubtitleSettings((prev) => ({ ...prev, ...partial }))
}
onBgmSettingsChange={(partial) =>
setBgmSettings((prev) => ({ ...prev, ...partial }))
}
onClipUpdate={handleClipUpdate}
onAiRecommend={handleAiRecommend}
/>
</div>
{/* ═══ 第4行:底栏 40px ═══ */}
<div className="ep-status-bar">
<div className="ep-status-left">
<span>📋 片段: {clips.length}</span>
<span className="ep-status-sep">|</span>
<span>⏱️ 总时长: {totalDuration.toFixed(1)}s</span>
</div>
<div className="ep-status-right">
<span>🎬 {MODE_LABELS[currentMode]}</span>
<span className="ep-status-sep">|</span>
<span>📐 模板片段: {currentTemplate?.segments.length || 0}</span>
<span className="ep-status-sep">|</span>
<button className="ep-status-link" onClick={handleViewGenHistory}>
📋 生成历史
</button>
</div>
</div>
{/* ═══ 弹窗 ═══ */}
<SaveModal
open={saveModalOpen}
loading={saveLoading}
isUpdate={!!loadedTemplateId}
draftName={draftName}
draftCategory={draftCategory}
draftTags={draftTags}
categories={categories}
estimatedDuration={totalDuration}
onNameChange={setDraftName}
onCategoryChange={setDraftCategory}
onTagsChange={setDraftTags}
onSave={handleSave}
onCancel={() => setSaveModalOpen(false)}
/>
<GenerationProgressModal
open={genModalOpen}
phase={genPhase}
voiceoverDuration={voiceoverDuration}
estimatedDuration={totalDuration}
onDurationChange={setVoiceoverDuration}
onGenerate={handleGenerate}
task={genTask}
submitting={genSubmitting}
onCancel={handleCancelGen}
onRetry={handleRetry}
onClose={handleCancelGen}
/>
{/* ═══ 生成历史弹窗 ═══ */}
<GenerationHistoryModal
open={genHistoryOpen}
loading={genHistoryLoading}
history={genHistory}
onClose={() => setGenHistoryOpen(false)}
/>
</div>
);
};
export default EditingPlanner;