feat: 实现火山云视频预览和下载功能

新增功能:
- 创建VideoPreviewModal组件,支持视频播放控制
- 实现视频预览功能,包括播放/暂停、静音、进度控制
- 添加视频下载功能,支持用户选择保存位置
- 集成到VideoGenerationTool中,替换原有的简单链接预览

技术实现:
- 新增download_video_to_directory Tauri命令,支持文件选择对话框
- 使用Modal组件作为基础,确保一致的用户体验
- 实现完整的视频播放控制界面,包括进度条、音量控制
- 支持全屏播放和外部链接打开
- 添加错误处理和加载状态管理

用户体验改进:
- 点击预览按钮打开专业的视频播放器界面
- 点击下载按钮可选择保存位置和文件名
- 播放控制包括快进/快退、静音等常用功能
- 响应式设计,适配不同屏幕尺寸
- 统一的通知系统反馈操作结果

代码优化:
- 移除未使用的导入和变量
- 规范化错误处理和状态管理
- 遵循项目的TypeScript和React最佳实践
This commit is contained in:
imeepos 2025-07-31 13:39:46 +08:00
parent a8f720eba2
commit e954fe2814
5 changed files with 509 additions and 7 deletions

View File

@ -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));
}

View File

@ -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| {

View File

@ -50,7 +50,6 @@ pub async fn get_volcano_video_generations(
state: State<'_, AppState>,
query: Option<VideoGenerationQuery>,
) -> Result<Vec<VideoGenerationRecord>, 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<String, String> {
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(

View File

@ -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<VideoPreviewModalProps> = ({
isOpen,
videoUrl,
title = '视频预览',
onClose,
onDownload
}) => {
const videoRef = useRef<HTMLVideoElement>(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<string | null>(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 (
<Modal
isOpen={isOpen}
onClose={onClose}
title={title}
size="xl"
className="video-preview-modal"
>
<div className="space-y-4">
{/* 视频播放区域 */}
<div className="relative bg-black rounded-lg overflow-hidden aspect-video">
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-gray-900">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div>
</div>
)}
{error && (
<div className="absolute inset-0 flex items-center justify-center bg-gray-900 text-white">
<div className="text-center">
<div className="text-red-400 mb-2"></div>
<p>{error}</p>
</div>
</div>
)}
<video
ref={videoRef}
src={videoUrl}
className="w-full h-full object-contain"
onLoadedData={handleVideoLoad}
onError={handleVideoError}
onTimeUpdate={handleTimeUpdate}
onPlay={handlePlay}
onPause={handlePause}
controls={false}
/>
{/* 视频控制覆盖层 */}
{!isLoading && !error && (
<div className="absolute inset-0 bg-gradient-to-t from-black/50 via-transparent to-transparent opacity-0 hover:opacity-100 transition-opacity duration-300">
{/* 中央播放按钮 */}
<div className="absolute inset-0 flex items-center justify-center">
<button
onClick={handlePlayPause}
className="bg-white/20 hover:bg-white/30 rounded-full p-4 transition-colors duration-200"
>
{isPlaying ? (
<Pause className="w-8 h-8 text-white" />
) : (
<Play className="w-8 h-8 text-white ml-1" />
)}
</button>
</div>
{/* 底部控制栏 */}
<div className="absolute bottom-0 left-0 right-0 p-4">
<div className="space-y-2">
{/* 进度条 */}
<div className="flex items-center space-x-2 text-white text-sm">
<span>{formatTime(currentTime)}</span>
<div className="flex-1 bg-white/20 rounded-full h-1">
<div
className="bg-blue-500 h-1 rounded-full transition-all duration-100"
style={{ width: `${duration > 0 ? (currentTime / duration) * 100 : 0}%` }}
/>
</div>
<span>{formatTime(duration)}</span>
</div>
{/* 控制按钮 */}
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<button
onClick={() => handleSkip(-10)}
className="text-white hover:text-blue-400 transition-colors duration-200"
title="后退10秒"
>
<SkipBack className="w-5 h-5" />
</button>
<button
onClick={handlePlayPause}
className="text-white hover:text-blue-400 transition-colors duration-200"
>
{isPlaying ? <Pause className="w-5 h-5" /> : <Play className="w-5 h-5" />}
</button>
<button
onClick={() => handleSkip(10)}
className="text-white hover:text-blue-400 transition-colors duration-200"
title="前进10秒"
>
<SkipForward className="w-5 h-5" />
</button>
<button
onClick={handleMuteToggle}
className="text-white hover:text-blue-400 transition-colors duration-200"
>
{isMuted ? <VolumeX className="w-5 h-5" /> : <Volume2 className="w-5 h-5" />}
</button>
</div>
<div className="flex items-center space-x-2">
<button
onClick={handleFullscreen}
className="text-white hover:text-blue-400 transition-colors duration-200"
title="全屏"
>
<Maximize className="w-5 h-5" />
</button>
</div>
</div>
</div>
</div>
</div>
)}
</div>
{/* 操作按钮 */}
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<button
onClick={handleDownload}
disabled={isDownloading}
className="flex items-center space-x-2 px-4 py-2 bg-green-600 hover:bg-green-700 disabled:bg-gray-400 text-white rounded-lg transition-colors duration-200"
>
<Download className="w-4 h-4" />
<span>{isDownloading ? '下载中...' : '下载视频'}</span>
</button>
<button
onClick={handleOpenInNewWindow}
className="flex items-center space-x-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors duration-200"
>
<ExternalLink className="w-4 h-4" />
<span></span>
</button>
</div>
<div className="text-sm text-gray-500">
{videoUrl && (
<span className="truncate max-w-xs" title={videoUrl}>
{videoUrl}
</span>
)}
</div>
</div>
</div>
</Modal>
);
};
export default VideoPreviewModal;

View File

@ -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<string | null>(null);
const [previewVideoTitle, setPreviewVideoTitle] = useState<string>('');
// ============= 数据加载 =============
// 加载视频生成记录
@ -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 && (
<>
<button
onClick={() => window.open(record.result_video_url, '_blank')}
onClick={() => handlePreviewVideo(record)}
className="text-blue-600 hover:text-blue-900 transition-colors duration-200"
title="预览视频"
>
<Eye className="w-4 h-4" />
</button>
<button
onClick={() => {
// TODO: 实现下载功能
}}
onClick={() => handleDownloadVideo(record.result_video_url!)}
className="text-green-600 hover:text-green-900 transition-colors duration-200"
title="下载视频"
>
@ -623,6 +672,15 @@ const VideoGenerationTool: React.FC = () => {
</div>
)}
</div>
{/* 视频预览模态框 */}
<VideoPreviewModal
isOpen={!!previewVideoUrl}
videoUrl={previewVideoUrl}
title={previewVideoTitle}
onClose={handleClosePreview}
onDownload={handleDownloadVideo}
/>
</div>
);
};