From e954fe2814612fd5d496d72604d625922b516b3e Mon Sep 17 00:00:00 2001 From: imeepos Date: Thu, 31 Jul 2025 13:39:46 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E7=81=AB=E5=B1=B1?= =?UTF-8?q?=E4=BA=91=E8=A7=86=E9=A2=91=E9=A2=84=E8=A7=88=E5=92=8C=E4=B8=8B?= =?UTF-8?q?=E8=BD=BD=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增功能: - 创建VideoPreviewModal组件,支持视频播放控制 - 实现视频预览功能,包括播放/暂停、静音、进度控制 - 添加视频下载功能,支持用户选择保存位置 - 集成到VideoGenerationTool中,替换原有的简单链接预览 技术实现: - 新增download_video_to_directory Tauri命令,支持文件选择对话框 - 使用Modal组件作为基础,确保一致的用户体验 - 实现完整的视频播放控制界面,包括进度条、音量控制 - 支持全屏播放和外部链接打开 - 添加错误处理和加载状态管理 用户体验改进: - 点击预览按钮打开专业的视频播放器界面 - 点击下载按钮可选择保存位置和文件名 - 播放控制包括快进/快退、静音等常用功能 - 响应式设计,适配不同屏幕尺寸 - 统一的通知系统反馈操作结果 代码优化: - 移除未使用的导入和变量 - 规范化错误处理和状态管理 - 遵循项目的TypeScript和React最佳实践 --- .../src/infrastructure/connection_pool.rs | 1 - apps/desktop/src-tauri/src/lib.rs | 1 + .../commands/volcano_video_commands.rs | 83 +++- .../src/components/VideoPreviewModal.tsx | 365 ++++++++++++++++++ .../src/pages/tools/VideoGenerationTool.tsx | 66 +++- 5 files changed, 509 insertions(+), 7 deletions(-) create mode 100644 apps/desktop/src/components/VideoPreviewModal.tsx diff --git a/apps/desktop/src-tauri/src/infrastructure/connection_pool.rs b/apps/desktop/src-tauri/src/infrastructure/connection_pool.rs index ad81ef9..01c3f6b 100644 --- a/apps/desktop/src-tauri/src/infrastructure/connection_pool.rs +++ b/apps/desktop/src-tauri/src/infrastructure/connection_pool.rs @@ -245,7 +245,6 @@ impl ConnectionPool { if let Some(index) = found_index { let pooled_conn = connections.remove(index).unwrap(); - println!("从连接池获取现有连接,剩余连接数: {}", connections.len()); return Ok(Some(pooled_conn.connection)); } diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 42719a4..bb12fec 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -496,6 +496,7 @@ pub fn run() { commands::volcano_video_commands::delete_volcano_video_generation, commands::volcano_video_commands::batch_delete_volcano_video_generations, commands::volcano_video_commands::download_volcano_video, + commands::volcano_video_commands::download_video_to_directory, commands::volcano_video_commands::batch_download_volcano_videos ]) .setup(|app| { diff --git a/apps/desktop/src-tauri/src/presentation/commands/volcano_video_commands.rs b/apps/desktop/src-tauri/src/presentation/commands/volcano_video_commands.rs index ba46313..35b3f15 100644 --- a/apps/desktop/src-tauri/src/presentation/commands/volcano_video_commands.rs +++ b/apps/desktop/src-tauri/src/presentation/commands/volcano_video_commands.rs @@ -50,7 +50,6 @@ pub async fn get_volcano_video_generations( state: State<'_, AppState>, query: Option, ) -> Result, String> { - info!("获取火山云视频生成记录列表"); // 获取数据库连接 let database = { @@ -73,7 +72,6 @@ pub async fn get_volcano_video_generations( // 获取记录列表 match service.get_video_generations(query).await { Ok(records) => { - info!("获取到 {} 条视频生成记录", records.len()); Ok(records) } Err(e) => { @@ -241,6 +239,87 @@ pub async fn download_volcano_video( } } +/// 下载视频到指定目录(带文件选择对话框) +#[tauri::command] +pub async fn download_video_to_directory( + app: tauri::AppHandle, + video_url: String, + filename: String, +) -> Result { + use tauri_plugin_dialog::DialogExt; + + info!("下载视频到指定目录: {} -> {}", video_url, filename); + + // 获取文件扩展名 + let extension = if filename.ends_with(".mp4") { + "mp4" + } else { + "mp4" // 默认使用mp4 + }; + + // 显示保存对话框 + let (tx, rx) = std::sync::mpsc::channel(); + + app.dialog().file() + .set_title("保存视频文件") + .set_file_name(&filename) + .add_filter("视频文件", &[extension]) + .add_filter("所有文件", &["*"]) + .save_file(move |file_path| { + let _ = tx.send(file_path); + }); + + // 等待用户选择 + let file_path = match rx.recv_timeout(std::time::Duration::from_secs(30)) { + Ok(Some(path)) => path.to_string(), + Ok(None) => return Err("用户取消了保存操作".to_string()), + Err(_) => return Err("文件选择对话框超时".to_string()), + }; + + // 下载视频文件 + let client = reqwest::Client::new(); + + match client.get(&video_url).send().await { + Ok(response) => { + if response.status().is_success() { + match response.bytes().await { + Ok(bytes) => { + // 确保目录存在 + if let Some(parent) = std::path::Path::new(&file_path).parent() { + if let Err(e) = tokio::fs::create_dir_all(parent).await { + return Err(format!("创建目录失败: {}", e)); + } + } + + match tokio::fs::write(&file_path, bytes).await { + Ok(()) => { + info!("视频文件下载成功: {}", file_path); + Ok(file_path) + } + Err(e) => { + error!("保存视频文件失败: {}", e); + Err(format!("保存视频文件失败: {}", e)) + } + } + } + Err(e) => { + error!("读取视频数据失败: {}", e); + Err(format!("读取视频数据失败: {}", e)) + } + } + } else { + let error_msg = format!("下载失败,HTTP状态码: {}", response.status()); + error!("{}", error_msg); + Err(error_msg) + } + } + Err(e) => { + error!("下载视频文件失败: {}", e); + Err(format!("下载视频文件失败: {}", e)) + } + } +} + /// 批量下载视频文件 #[tauri::command] pub async fn batch_download_volcano_videos( diff --git a/apps/desktop/src/components/VideoPreviewModal.tsx b/apps/desktop/src/components/VideoPreviewModal.tsx new file mode 100644 index 0000000..253aa81 --- /dev/null +++ b/apps/desktop/src/components/VideoPreviewModal.tsx @@ -0,0 +1,365 @@ +import React, { useState, useCallback, useRef, useEffect } from 'react'; +import { Modal } from './Modal'; +import { + Play, + Pause, + Volume2, + VolumeX, + Download, + ExternalLink, + Maximize, + SkipBack, + SkipForward +} from 'lucide-react'; +import { invoke } from '@tauri-apps/api/core'; +import { useNotifications } from './NotificationSystem'; + +/** + * 视频预览模态框属性接口 + */ +interface VideoPreviewModalProps { + /** 是否显示模态框 */ + isOpen: boolean; + /** 视频URL */ + videoUrl: string | null; + /** 视频标题 */ + title?: string; + /** 关闭回调 */ + onClose: () => void; + /** 下载回调 */ + onDownload?: (videoUrl: string) => void; +} + +/** + * 视频预览模态框组件 + * 支持视频播放控制、全屏、下载等功能 + */ +export const VideoPreviewModal: React.FC = ({ + isOpen, + videoUrl, + title = '视频预览', + onClose, + onDownload +}) => { + const videoRef = useRef(null); + const [isPlaying, setIsPlaying] = useState(false); + const [isMuted, setIsMuted] = useState(false); + const [currentTime, setCurrentTime] = useState(0); + const [duration, setDuration] = useState(0); + const [isFullscreen, setIsFullscreen] = useState(false); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [isDownloading, setIsDownloading] = useState(false); + + const { addNotification } = useNotifications(); + + // 重置状态当视频URL改变时 + useEffect(() => { + if (videoUrl) { + setIsLoading(true); + setError(null); + setIsPlaying(false); + setCurrentTime(0); + setDuration(0); + } + }, [videoUrl]); + + // 播放/暂停控制 + const handlePlayPause = useCallback(() => { + if (!videoRef.current) return; + + if (isPlaying) { + videoRef.current.pause(); + } else { + videoRef.current.play().catch(err => { + console.error('播放失败:', err); + setError('视频播放失败'); + }); + } + }, [isPlaying]); + + // 静音控制 + const handleMuteToggle = useCallback(() => { + if (!videoRef.current) return; + + const newMuted = !isMuted; + videoRef.current.muted = newMuted; + setIsMuted(newMuted); + }, [isMuted]); + + // 进度控制 + const handleSeek = useCallback((newTime: number) => { + if (!videoRef.current) return; + + videoRef.current.currentTime = newTime; + setCurrentTime(newTime); + }, []); + + // 快进/快退 + const handleSkip = useCallback((seconds: number) => { + if (!videoRef.current) return; + + const newTime = Math.max(0, Math.min(duration, currentTime + seconds)); + handleSeek(newTime); + }, [currentTime, duration, handleSeek]); + + // 全屏控制 + const handleFullscreen = useCallback(() => { + if (!videoRef.current) return; + + if (!isFullscreen) { + if (videoRef.current.requestFullscreen) { + videoRef.current.requestFullscreen(); + } + } else { + if (document.exitFullscreen) { + document.exitFullscreen(); + } + } + }, [isFullscreen]); + + // 下载视频 + const handleDownload = useCallback(async () => { + if (!videoUrl) return; + + setIsDownloading(true); + try { + // 调用Tauri命令下载视频 + await invoke('download_video_to_directory', { + videoUrl, + filename: `video_${Date.now()}.mp4` + }); + + addNotification({ + type: 'success', + title: '下载成功', + message: '视频下载成功' + }); + + if (onDownload) { + onDownload(videoUrl); + } + } catch (error) { + console.error('下载失败:', error); + addNotification({ + type: 'error', + title: '下载失败', + message: `视频下载失败: ${error}` + }); + } finally { + setIsDownloading(false); + } + }, [videoUrl, onDownload, addNotification]); + + // 在新窗口打开 + const handleOpenInNewWindow = useCallback(() => { + if (videoUrl) { + window.open(videoUrl, '_blank'); + } + }, [videoUrl]); + + // 视频事件处理 + const handleVideoLoad = useCallback(() => { + setIsLoading(false); + if (videoRef.current) { + setDuration(videoRef.current.duration); + } + }, []); + + const handleVideoError = useCallback(() => { + setIsLoading(false); + setError('视频加载失败'); + }, []); + + const handleTimeUpdate = useCallback(() => { + if (videoRef.current) { + setCurrentTime(videoRef.current.currentTime); + } + }, []); + + const handlePlay = useCallback(() => { + setIsPlaying(true); + }, []); + + const handlePause = useCallback(() => { + setIsPlaying(false); + }, []); + + const handleFullscreenChange = useCallback(() => { + setIsFullscreen(!!document.fullscreenElement); + }, []); + + // 监听全屏变化 + useEffect(() => { + document.addEventListener('fullscreenchange', handleFullscreenChange); + return () => { + document.removeEventListener('fullscreenchange', handleFullscreenChange); + }; + }, [handleFullscreenChange]); + + // 格式化时间 + const formatTime = (time: number) => { + const minutes = Math.floor(time / 60); + const seconds = Math.floor(time % 60); + return `${minutes}:${seconds.toString().padStart(2, '0')}`; + }; + + if (!isOpen || !videoUrl) { + return null; + } + + return ( + +
+ {/* 视频播放区域 */} +
+ {isLoading && ( +
+
+
+ )} + + {error && ( +
+
+
⚠️
+

{error}

+
+
+ )} + +
+ + ); +}; + +export default VideoPreviewModal; diff --git a/apps/desktop/src/pages/tools/VideoGenerationTool.tsx b/apps/desktop/src/pages/tools/VideoGenerationTool.tsx index 0fcf8ed..4a38258 100644 --- a/apps/desktop/src/pages/tools/VideoGenerationTool.tsx +++ b/apps/desktop/src/pages/tools/VideoGenerationTool.tsx @@ -17,6 +17,7 @@ import { import { invoke } from '@tauri-apps/api/core'; import { open } from '@tauri-apps/plugin-dialog'; import { useNotifications } from '../../components/NotificationSystem'; +import VideoPreviewModal from '../../components/VideoPreviewModal'; // 火山云视频生成相关类型定义 interface VideoGenerationRecord { @@ -72,6 +73,10 @@ const VideoGenerationTool: React.FC = () => { audio_url: '', // 驱动视频URL }); + // 视频预览相关状态 + const [previewVideoUrl, setPreviewVideoUrl] = useState(null); + const [previewVideoTitle, setPreviewVideoTitle] = useState(''); + // ============= 数据加载 ============= // 加载视频生成记录 @@ -231,6 +236,52 @@ const VideoGenerationTool: React.FC = () => { } }, [addNotification, loadRecords]); + // ============= 视频预览相关 ============= + + // 打开视频预览 + const handlePreviewVideo = useCallback((record: VideoGenerationRecord) => { + if (record.result_video_url) { + setPreviewVideoUrl(record.result_video_url); + setPreviewVideoTitle(record.name || '视频预览'); + } else { + addNotification({ + type: 'warning', + title: '预览失败', + message: '该视频还未生成完成' + }); + } + }, [addNotification]); + + // 关闭视频预览 + const handleClosePreview = useCallback(() => { + setPreviewVideoUrl(null); + setPreviewVideoTitle(''); + }, []); + + // 下载视频 + const handleDownloadVideo = useCallback(async (videoUrl: string) => { + try { + const filename = `volcano_video_${Date.now()}.mp4`; + const savedPath = await invoke('download_video_to_directory', { + videoUrl, + filename + }); + + addNotification({ + type: 'success', + title: '下载成功', + message: `视频已保存到: ${savedPath}` + }); + } catch (error) { + console.error('下载视频失败:', error); + addNotification({ + type: 'error', + title: '下载失败', + message: `下载视频失败: ${error}` + }); + } + }, [addNotification]); + // 批量删除选中记录 const batchDeleteRecords = useCallback(async () => { if (selectedRecords.length === 0) { @@ -468,16 +519,14 @@ const VideoGenerationTool: React.FC = () => { {record.result_video_url && ( <>
)} + + {/* 视频预览模态框 */} + ); };