From 7da09bfcfd692ac7243cce00ddeb4ddc17501709 Mon Sep 17 00:00:00 2001 From: CI Bot Date: Wed, 15 Jul 2026 09:49:24 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=89=93=E9=80=9A=E5=89=AA=E8=BE=91?= =?UTF-8?q?=E8=AE=A1=E5=88=92=E7=94=9F=E6=88=90=E9=93=BE=E8=B7=AF=20?= =?UTF-8?q?=E2=80=94=20EditingPlanner=20=E7=94=9F=E6=88=90=E6=8C=89?= =?UTF-8?q?=E9=92=AE=E5=AF=B9=E6=8E=A5=E7=9C=9F=E5=AE=9E=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 生成按钮调用 generateEditPlan(planId) 而非跳转一键生成页面 - 进度弹窗 + 轮询 getGenerationStatus(每2秒)+ 视频结果展示 - 列表页编辑按钮正确加载计划数据到编辑器(planId URL参数) - 生成历史弹窗正常工作 - 列表页状态自动同步(已有5秒 refetch) - 修复所有 TypeScript 编译错误(tsc 零错误) --- .../pages/editing-planner/EditingPlanner.tsx | 482 +++++++++++++++--- 1 file changed, 400 insertions(+), 82 deletions(-) diff --git a/apps/web/src/pages/editing-planner/EditingPlanner.tsx b/apps/web/src/pages/editing-planner/EditingPlanner.tsx index e1f6f26d0..189ec08f4 100755 --- a/apps/web/src/pages/editing-planner/EditingPlanner.tsx +++ b/apps/web/src/pages/editing-planner/EditingPlanner.tsx @@ -3,8 +3,8 @@ * 四行布局:顶栏(42px) → 模式栏(48px) → 三栏主体 → 底栏(40px) */ import React, { useState, useCallback, useEffect, useRef } from "react"; -import { useSearchParams, useNavigate } from "react-router-dom"; -import { message } from "antd"; +import { useSearchParams } from "react-router-dom"; +import { message, Modal, Progress, Button } from "antd"; import { useQuery } from "@tanstack/react-query"; import type { EditingTemplate, @@ -20,11 +20,23 @@ import { getTemplateCategories, MODE_LABELS, } from "@/api/editingPlanner"; -import type { EditPlanGeneration, MediaAsset } from "@/api/editPlans"; +import type { + EditPlanGeneration, + EditPlanConfig, + GeneratedVideo, + MediaAsset, + TransitionEffect, +} from "@/api/editPlans"; import { getMediaAssets, getEditPlanGenerations, generateCover, + getEditPlan, + createEditPlan, + updateEditPlan, + generateEditPlan, + getGenerationStatus, + getGenerationTaskResults, } from "@/api/editPlans"; import { useUndoRedo } from "./hooks/useUndoRedo"; import type { @@ -33,6 +45,7 @@ import type { TransitionConfig, SpeedConfig, TtsConfig, + TtsMode, TrimConfig, WatermarkConfig, IntroOutroConfig, @@ -107,8 +120,8 @@ const FILTER_CATEGORIES = ["全部", "种草", "知识", "日常", "推荐"]; const EditingPlanner: React.FC = () => { const [searchParams] = useSearchParams(); - const navigate = useNavigate(); const urlTemplateId = searchParams.get("templateId") || ""; + const urlPlanId = searchParams.get("planId") || ""; /* ── 模板列表 ── */ const [templates, setTemplates] = useState([]); @@ -244,6 +257,21 @@ const EditingPlanner: React.FC = () => { const [genHistory, setGenHistory] = useState([]); const [genHistoryLoading, setGenHistoryLoading] = useState(false); + /* ── 剪辑计划(从列表页编辑进入时) ── */ + const [loadedPlanId, setLoadedPlanId] = useState( + urlPlanId || null, + ); + + /* ── 生成进度 ── */ + const [generating, setGenerating] = useState(false); + const [genProgress, setGenProgress] = useState(0); + const [genTotalClips, setGenTotalClips] = useState(0); + const [genDoneClips, setGenDoneClips] = useState(0); + const [generated, setGenerated] = useState(false); + const [generatedVideos, setGeneratedVideos] = useState([]); + const [genError, setGenError] = useState(null); + const genTimerRef = useRef | null>(null); + /* ── 播放 ── */ const [isPlaying, setIsPlaying] = useState(false); const [currentTime, setCurrentTime] = useState(0); @@ -374,6 +402,85 @@ const EditingPlanner: React.FC = () => { .catch(() => message.error("加载模板详情失败")); }, [loadedTemplateId, resetClips]); + /** + * 加载已有剪辑计划数据到编辑器 + * 从列表页"编辑"按钮进入时,URL 带 planId,需要还原计划配置 + */ + useEffect(() => { + if (!loadedPlanId) return; + getEditPlan(loadedPlanId) + .then((plan) => { + // 设置关联的模板(触发模板加载 effect) + setLoadedTemplateId(plan.template_id); + + // 还原基本信息 + setDraftName(plan.name); + + // 还原 config 中的编辑器状态 + const cfg = plan.config; + if (cfg.title_config) { + setTitleSettings((prev) => ({ + ...prev, + aiAutoSelect: cfg.title_config!.ai_auto_select, + title: cfg.title_config!.content, + position: cfg.title_config!.position, + font: cfg.title_config!.font_preset, + size: cfg.title_config!.font_size, + color: cfg.title_config!.font_color || "#ffffff", + })); + } + if (cfg.subtitle_config) { + setSubtitleSettings((prev) => ({ + ...prev, + enabled: cfg.subtitle_config!.enabled, + position: + (cfg.subtitle_config!.position || "bottom") as SubtitleStyleConfig["position"], + font: cfg.subtitle_config!.font, + fontSize: cfg.subtitle_config!.size, + fontColor: cfg.subtitle_config!.color || "#ffffff", + animation: cfg.subtitle_config!.animation, + })); + } + if (cfg.bgm_config) { + setBgmSettings((prev) => ({ + ...prev, + enabled: cfg.bgm_config!.enabled, + music_id: cfg.bgm_config!.music_id || "", + })); + } + + // 还原片段 — 延迟设置,等模板加载 effect 先执行 resetClips + if (cfg.segments && cfg.segments.length > 0) { + const mapped: ClipData[] = cfg.segments.map((seg, idx) => ({ + id: `seg-${idx}`, + template_segment_id: `seg-${idx}`, + type: (seg.material_type === "voiceover" + ? "voice" + : "pip") as ClipType, + duration: (seg.duration_min + seg.duration_max) / 2, + startOffset: 0, + script_text: "", + order: seg.segment_order, + transition: seg.transition + ? { + type: seg.transition.type as TransitionEffect["type"], + duration: seg.transition.duration, + } + : undefined, + speed: seg.playback_speed + ? { rate: seg.playback_speed, pitchCorrection: true } + : undefined, + tts_config: seg.tts_config + ? { ...seg.tts_config, mode: seg.tts_config.mode as TtsMode } + : undefined, + trim_config: seg.trim_config || undefined, + })); + setTimeout(() => resetClips(mapped), 100); + } + }) + .catch(() => message.error("加载剪辑计划失败")); + }, [loadedPlanId, resetClips]); + /* ──────────── 计算 ──────────── */ const currentTemplate = templates.find((t) => t.id === loadedTemplateId); @@ -671,6 +778,65 @@ const EditingPlanner: React.FC = () => { } }; + /** 构建剪辑计划 config(编辑器状态 → API config) */ + const buildPlanConfig = (): EditPlanConfig => ({ + title_config: { + ai_auto_select: titleSettings.aiAutoSelect, + content: titleSettings.title, + position: titleSettings.position, + font_preset: titleSettings.font, + font_color: titleSettings.color, + font_size: titleSettings.size, + }, + subtitle_config: { + enabled: subtitleSettings.enabled, + position: subtitleSettings.position, + font: subtitleSettings.font, + color: subtitleSettings.fontColor, + size: subtitleSettings.fontSize, + animation: subtitleSettings.animation, + }, + bgm_config: { + enabled: bgmSettings.enabled, + music_id: bgmSettings.music_id, + }, + 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.type === "voice" ? "voiceover" : "video", + transition: c.transition + ? { type: c.transition.type, duration: c.transition.duration } + : undefined, + playback_speed: c.speed ? c.speed.rate : undefined, + tts_config: c.tts_config + ? { + mode: c.tts_config.mode, + text: c.tts_config.text, + voice_id: c.tts_config.voice_id, + speed: c.tts_config.speed, + pitch: c.tts_config.pitch, + volume: c.tts_config.volume, + subtitle_sync: c.tts_config.subtitle_sync, + } + : undefined, + trim_config: c.trim_config + ? { + start_time: c.trim_config.start_time, + end_time: c.trim_config.end_time, + } + : undefined, + })), + watermark_config: { ...watermarkSettings }, + intro_outro_config: { ...introOutroSettings }, + pip_config: { ...pipSettings }, + filter_config: { ...filterSettings }, + green_screen_config: { ...chromaKeySettings }, + sticker_config: { ...stickerSettings }, + cover_config: { ...coverSettings }, + }); + /* 保存 — 无论是否已加载模板,都打开保存弹窗;未加载时创建新模板 */ const handleOpenSaveModal = () => { setSaveModalOpen(true); @@ -763,94 +929,139 @@ const EditingPlanner: React.FC = () => { }; /** - * 跳转到一键生成页面 - * 通过 URL SearchParams 传递 edit_plan_id 和完整 planConfig(JSON 序列化) - * 一键生成页面从 params 解析配置,无需重复请求接口 + * 剪辑计划生成 + * 1. 有 planId → 更新计划配置 + 触发生成 + * 2. 无 planId(从模板库直接进入)→ 先创建计划 + 触发生成 + * 3. 触发生成后轮询状态,完成后获取视频结果 */ - const handleGoToGenerate = () => { - const planConfig = { - title_config: { - ai_auto_select: titleSettings.aiAutoSelect, - content: titleSettings.title, - position: titleSettings.position, - font_preset: titleSettings.font, - font_color: titleSettings.color, - 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, - color: subtitleSettings.fontColor, - size: subtitleSettings.fontSize, - animation: subtitleSettings.animation, - }, - bgm_config: { - enabled: bgmSettings.enabled, - music_id: bgmSettings.music_id, - }, - mode: currentMode, - total_duration: totalDuration, - segments: clips.map((c, i) => ({ - order: i, - material_type: c.type === "voice" ? "voiceover" : "video", - duration: c.duration, - template_segment_id: c.template_segment_id, - script_text: c.script_text, - voice_asset_id: c.voice_asset_id, - voice_file_url: c.voice_file_url, - transition: c.transition - ? { type: c.transition.type, duration: c.transition.duration } - : undefined, - playback_speed: c.speed ? c.speed.rate : undefined, - tts_config: c.tts_config - ? { - mode: c.tts_config.mode, - text: c.tts_config.text, - voice_id: c.tts_config.voice_id, - speed: c.tts_config.speed, - pitch: c.tts_config.pitch, - volume: c.tts_config.volume, - subtitle_sync: c.tts_config.subtitle_sync, - } - : undefined, - trim_config: c.trim_config - ? { - start_time: c.trim_config.start_time, - end_time: c.trim_config.end_time, - } - : undefined, - })), - watermark_config: { ...watermarkSettings }, - intro_outro_config: { ...introOutroSettings }, - pip_config: { ...pipSettings }, - filter_config: { ...filterSettings }, - green_screen_config: { ...chromaKeySettings }, - sticker_config: { ...stickerSettings }, - cover_config: { ...coverSettings }, - }; - const params = new URLSearchParams(); - if (loadedTemplateId) { - params.set("edit_plan_id", loadedTemplateId); + const handleGoToGenerate = async () => { + if (!loadedTemplateId) { + message.warning("请先选择一个模板"); + return; + } + if (clips.length === 0) { + message.warning("请先添加片段"); + return; + } + + setGenerating(true); + setGenerated(false); + setGeneratedVideos([]); + setGenError(null); + setGenProgress(0); + + try { + const config = buildPlanConfig(); + let planId = loadedPlanId; + + if (planId) { + // 已有计划 → 更新配置 + await updateEditPlan(planId, { + config, + total_duration: totalDuration, + status: "editing", + }); + } else { + // 无计划 → 创建新计划 + const plan = await createEditPlan({ + template_id: loadedTemplateId, + name: draftName || "未命名计划", + config, + total_duration: totalDuration, + }); + planId = plan.id; + setLoadedPlanId(planId); + // 更新 URL 参数(不刷新页面) + const params = new URLSearchParams(window.location.search); + params.set("planId", planId); + window.history.replaceState(null, "", `?${params.toString()}`); + } + + // 触发生成 + const genRes = await generateEditPlan(planId); + setGenTotalClips(genRes.clip_count); + message.info("已提交生成,等待处理..."); + + // 开始轮询 + startPolling(planId); + } catch (err) { + console.error("[生成失败]", err); + setGenError("生成提交失败,请重试"); + setGenerating(false); } - params.set("plan_config", JSON.stringify(planConfig)); - navigate(`/app/generate?${params.toString()}`); }; + /** 轮询生成状态,每 2 秒一次 */ + const startPolling = (planId: string) => { + const poll = async () => { + try { + const status = await getGenerationStatus(planId); + + // 计算进度 + const total = status.clips.length || genTotalClips; + const done = status.clips.filter( + (c) => c.status === "completed" || c.status === "failed", + ).length; + setGenDoneClips(done); + setGenTotalClips(total); + setGenProgress(total > 0 ? Math.round((done / total) * 100) : 5); + + if (status.plan_status === "completed") { + setGenProgress(100); + setGenerating(false); + setGenerated(true); + + // 获取视频结果 + if (status.generation_task_id) { + try { + const videos = await getGenerationTaskResults( + status.generation_task_id, + ); + setGeneratedVideos(videos); + } catch (e) { + console.error("[获取视频结果失败]", e); + } + } + message.success("视频生成完成!"); + return; // 停止轮询 + } + + if (status.plan_status === "failed") { + setGenerating(false); + setGenError("生成失败,请重试"); + return; // 停止轮询 + } + + // 继续轮询 + genTimerRef.current = setTimeout(poll, 2000); + } catch (err) { + console.error("[轮询状态失败]", err); + genTimerRef.current = setTimeout(poll, 5000); // 出错后 5 秒重试 + } + }; + + // 首次延迟 2 秒后开始 + genTimerRef.current = setTimeout(poll, 2000); + }; + + /** 清理轮询定时器 */ + useEffect(() => { + return () => { + if (genTimerRef.current) clearTimeout(genTimerRef.current); + }; + }, []); + /* 查看生成历史 */ const handleViewGenHistory = async () => { - if (!loadedTemplateId) { - message.warning("请先加载一个模板"); + const targetId = loadedPlanId || loadedTemplateId; + if (!targetId) { + message.warning("请先加载一个模板或计划"); return; } setGenHistoryOpen(true); setGenHistoryLoading(true); try { - const items = await getEditPlanGenerations(loadedTemplateId); + const items = await getEditPlanGenerations(targetId); setGenHistory(items); } catch { message.error("加载生成历史失败"); @@ -899,8 +1110,9 @@ const EditingPlanner: React.FC = () => { @@ -1065,6 +1277,112 @@ const EditingPlanner: React.FC = () => { onClose={() => setGenHistoryOpen(false)} /> + {/* ═══ 生成进度弹窗 ═══ */} + { + setGenerated(false); + setGenerating(false); + }} + > + 关闭 + , + generatedVideos.length > 0 && ( + + ), + ] + : null + } + closable={!generating} + maskClosable={false} + width={520} + > + {generating && ( +
+ +

+ 已处理 {genDoneClips}/{genTotalClips} 个片段 +

+

+ 请耐心等待,生成过程中请勿关闭页面 +

+
+ )} + {generated && generatedVideos.length > 0 && ( +
+
+ )} + {generated && !generatedVideos.length && ( +
+

生成完成,但暂未获取到视频结果

+

+ 请稍后在剪辑计划列表中查看 +

+
+ )} + {genError && ( +
+

{genError}

+ +
+ )} +
+ {/* ═══ BGM 选择器 Drawer ═══ */}