467 lines
19 KiB
TypeScript
467 lines
19 KiB
TypeScript
'use client';
|
||
|
||
import React, { useEffect, useState } from 'react';
|
||
import { createPortal } from 'react-dom';
|
||
|
||
interface OfflineDownloadTask {
|
||
id: string;
|
||
source: string;
|
||
videoId: string;
|
||
episodeIndex: number;
|
||
title: string;
|
||
m3u8Url: string;
|
||
status: 'pending' | 'downloading' | 'completed' | 'error' | 'paused';
|
||
progress: number;
|
||
totalSegments: number;
|
||
downloadedSegments: number;
|
||
errorMessage?: string;
|
||
createdAt: string;
|
||
updatedAt: string;
|
||
downloadDir: string;
|
||
metadata?: {
|
||
videoTitle?: string;
|
||
cover?: string;
|
||
description?: string;
|
||
year?: string;
|
||
rating?: number;
|
||
totalEpisodes?: number;
|
||
};
|
||
}
|
||
|
||
interface OfflineDownloadPanelProps {
|
||
isOpen: boolean;
|
||
onClose: () => void;
|
||
}
|
||
|
||
export function OfflineDownloadPanel({ isOpen, onClose }: OfflineDownloadPanelProps) {
|
||
const [tasks, setTasks] = useState<OfflineDownloadTask[]>([]);
|
||
const [loading, setLoading] = useState(false);
|
||
const [mounted, setMounted] = useState(false);
|
||
const [viewMode, setViewMode] = useState<'tasks' | 'library'>('tasks'); // 视图模式:任务列表或视频库
|
||
|
||
// 确保只在客户端渲染
|
||
useEffect(() => {
|
||
setMounted(true);
|
||
}, []);
|
||
|
||
// 获取任务列表
|
||
const fetchTasks = async () => {
|
||
try {
|
||
const response = await fetch('/api/offline-download');
|
||
if (response.ok) {
|
||
const data = await response.json();
|
||
setTasks(data.tasks || []);
|
||
}
|
||
} catch (error) {
|
||
console.error('获取离线下载任务列表失败:', error);
|
||
}
|
||
};
|
||
|
||
// 删除任务
|
||
const handleDeleteTask = async (taskId: string) => {
|
||
try {
|
||
const response = await fetch(`/api/offline-download?taskId=${taskId}`, {
|
||
method: 'DELETE',
|
||
});
|
||
|
||
if (response.ok) {
|
||
// 从列表中移除
|
||
setTasks((prev) => prev.filter((t) => t.id !== taskId));
|
||
} else {
|
||
const data = await response.json();
|
||
alert(`删除失败: ${data.error}`);
|
||
}
|
||
} catch (error) {
|
||
console.error('删除任务失败:', error);
|
||
alert('删除任务失败');
|
||
}
|
||
};
|
||
|
||
// 重试任务
|
||
const handleRetryTask = async (taskId: string) => {
|
||
try {
|
||
const response = await fetch(`/api/offline-download?taskId=${taskId}&action=retry`, {
|
||
method: 'PUT',
|
||
});
|
||
|
||
if (response.ok) {
|
||
const data = await response.json();
|
||
// 更新任务状态(保留进度,只重试失败的片段)
|
||
setTasks((prev) =>
|
||
prev.map((t) =>
|
||
t.id === taskId
|
||
? {
|
||
...t,
|
||
status: 'pending',
|
||
errorMessage: undefined,
|
||
updatedAt: new Date().toISOString(),
|
||
}
|
||
: t
|
||
)
|
||
);
|
||
// 立即刷新以获取最新状态
|
||
fetchTasks();
|
||
} else {
|
||
const data = await response.json();
|
||
alert(`重试失败: ${data.error}`);
|
||
}
|
||
} catch (error) {
|
||
console.error('重试任务失败:', error);
|
||
alert('重试任务失败');
|
||
}
|
||
};
|
||
|
||
// 定期刷新任务列表
|
||
useEffect(() => {
|
||
if (isOpen) {
|
||
fetchTasks();
|
||
const interval = setInterval(fetchTasks, 3000); // 每3秒刷新一次
|
||
return () => clearInterval(interval);
|
||
}
|
||
}, [isOpen]);
|
||
|
||
if (!isOpen || !mounted) {
|
||
return null;
|
||
}
|
||
|
||
const getStatusText = (status: OfflineDownloadTask['status']) => {
|
||
switch (status) {
|
||
case 'pending':
|
||
return '等待中';
|
||
case 'downloading':
|
||
return '下载中';
|
||
case 'paused':
|
||
return '已暂停';
|
||
case 'completed':
|
||
return '已完成';
|
||
case 'error':
|
||
return '错误';
|
||
default:
|
||
return '未知';
|
||
}
|
||
};
|
||
|
||
const getStatusColor = (status: OfflineDownloadTask['status']) => {
|
||
switch (status) {
|
||
case 'pending':
|
||
return 'text-gray-500 dark:text-gray-400';
|
||
case 'downloading':
|
||
return 'text-blue-500 dark:text-blue-400';
|
||
case 'paused':
|
||
return 'text-yellow-500 dark:text-yellow-400';
|
||
case 'completed':
|
||
return 'text-green-500 dark:text-green-400';
|
||
case 'error':
|
||
return 'text-red-500 dark:text-red-400';
|
||
default:
|
||
return 'text-gray-500 dark:text-gray-400';
|
||
}
|
||
};
|
||
|
||
// 获取视频库中的视频(按videoId分组,包含已完成和有进度的任务)
|
||
const getLibraryVideos = () => {
|
||
// 筛选已完成或有下载进度的任务
|
||
const libraryTasks = tasks.filter((t) => t.status === 'completed' || t.progress > 0);
|
||
const videoMap = new Map<string, { video: OfflineDownloadTask; episodes: OfflineDownloadTask[] }>();
|
||
|
||
libraryTasks.forEach((task) => {
|
||
const key = `${task.source}_${task.videoId}`;
|
||
if (!videoMap.has(key)) {
|
||
videoMap.set(key, { video: task, episodes: [] });
|
||
}
|
||
videoMap.get(key)!.episodes.push(task);
|
||
});
|
||
|
||
// 按集数排序
|
||
videoMap.forEach((value) => {
|
||
value.episodes.sort((a, b) => a.episodeIndex - b.episodeIndex);
|
||
});
|
||
|
||
return Array.from(videoMap.values());
|
||
};
|
||
|
||
const libraryVideos = getLibraryVideos();
|
||
|
||
const panelContent = (
|
||
<div className='fixed inset-0 z-[9999] flex items-center justify-center bg-black/50 backdrop-blur-sm p-4'>
|
||
<div className='bg-white dark:bg-gray-800 rounded-lg shadow-2xl w-full max-w-4xl max-h-[80vh] flex flex-col'>
|
||
{/* 标题栏 */}
|
||
<div className='flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700'>
|
||
<h2 className='text-xl font-bold text-gray-900 dark:text-white'>
|
||
{viewMode === 'tasks' ? '离线下载任务列表' : '视频库'}
|
||
</h2>
|
||
<div className='flex items-center gap-3'>
|
||
{/* 视图切换按钮 */}
|
||
<div className='flex gap-2'>
|
||
<button
|
||
onClick={() => setViewMode('tasks')}
|
||
className={`px-3 py-1 text-sm font-medium rounded transition-colors ${
|
||
viewMode === 'tasks'
|
||
? 'bg-blue-500 text-white'
|
||
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600'
|
||
}`}
|
||
>
|
||
任务列表
|
||
</button>
|
||
<button
|
||
onClick={() => setViewMode('library')}
|
||
className={`px-3 py-1 text-sm font-medium rounded transition-colors ${
|
||
viewMode === 'library'
|
||
? 'bg-blue-500 text-white'
|
||
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600'
|
||
}`}
|
||
>
|
||
视频库 ({libraryVideos.length})
|
||
</button>
|
||
</div>
|
||
{/* 关闭按钮 */}
|
||
<button
|
||
onClick={onClose}
|
||
className='text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 transition-colors'
|
||
>
|
||
<svg className='w-6 h-6' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
|
||
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth='2' d='M6 18L18 6M6 6l12 12' />
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 内容区域 */}
|
||
<div className='flex-1 overflow-y-auto p-4 space-y-3'>
|
||
{loading ? (
|
||
<div className='flex items-center justify-center h-full'>
|
||
<div className='animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500'></div>
|
||
</div>
|
||
) : viewMode === 'library' ? (
|
||
// 视频库视图
|
||
libraryVideos.length === 0 ? (
|
||
<div className='flex flex-col items-center justify-center h-full text-gray-500 dark:text-gray-400'>
|
||
<svg className='w-16 h-16 mb-4' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
|
||
<path
|
||
strokeLinecap='round'
|
||
strokeLinejoin='round'
|
||
strokeWidth='2'
|
||
d='M7 4v16M17 4v16M3 8h4m10 0h4M3 12h18M3 16h4m10 0h4M4 20h16a1 1 0 001-1V5a1 1 0 00-1-1H4a1 1 0 00-1 1v14a1 1 0 001 1z'
|
||
/>
|
||
</svg>
|
||
<p className='text-lg'>暂无已完成的视频</p>
|
||
</div>
|
||
) : (
|
||
libraryVideos.map(({ video, episodes }) => (
|
||
<div
|
||
key={`${video.source}_${video.videoId}`}
|
||
className='bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 border border-gray-200 dark:border-gray-600'
|
||
>
|
||
<div className='flex gap-4'>
|
||
{/* 封面图 */}
|
||
{video.metadata?.cover && (
|
||
<div className='flex-shrink-0'>
|
||
<img
|
||
src={video.metadata.cover}
|
||
alt={video.metadata.videoTitle || video.title}
|
||
className='w-32 h-48 object-cover rounded'
|
||
/>
|
||
</div>
|
||
)}
|
||
{/* 视频信息 */}
|
||
<div className='flex-1 min-w-0'>
|
||
<h3 className='text-lg font-bold text-gray-900 dark:text-white mb-2'>
|
||
{video.metadata?.videoTitle || video.title}
|
||
</h3>
|
||
{video.metadata?.year && (
|
||
<p className='text-sm text-gray-600 dark:text-gray-400 mb-1'>年份: {video.metadata.year}</p>
|
||
)}
|
||
{video.metadata?.rating && (
|
||
<p className='text-sm text-gray-600 dark:text-gray-400 mb-1'>
|
||
评分: {video.metadata.rating.toFixed(1)}
|
||
</p>
|
||
)}
|
||
{video.metadata?.description && (
|
||
<p className='text-sm text-gray-600 dark:text-gray-400 mb-3 line-clamp-2'>
|
||
{video.metadata.description}
|
||
</p>
|
||
)}
|
||
<div className='flex items-center gap-2 mb-3'>
|
||
<span className='text-sm text-gray-600 dark:text-gray-400'>
|
||
已下载 {episodes.length} 集
|
||
</span>
|
||
{video.metadata?.totalEpisodes && (
|
||
<span className='text-sm text-gray-600 dark:text-gray-400'>
|
||
/ 共 {video.metadata.totalEpisodes} 集
|
||
</span>
|
||
)}
|
||
</div>
|
||
{/* 集数列表 */}
|
||
<div className='flex flex-wrap gap-2 mb-3'>
|
||
{episodes.map((ep) => (
|
||
<div
|
||
key={ep.id}
|
||
className='flex items-center gap-1 px-2 py-1 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 text-xs rounded group'
|
||
>
|
||
<span>第{ep.episodeIndex + 1}集</span>
|
||
<button
|
||
onClick={() => {
|
||
if (confirm(`确定要删除第${ep.episodeIndex + 1}集吗?`)) {
|
||
handleDeleteTask(ep.id);
|
||
}
|
||
}}
|
||
className='ml-1 opacity-0 group-hover:opacity-100 transition-opacity text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300'
|
||
title='删除此集'
|
||
>
|
||
<svg className='w-3 h-3' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
|
||
<path
|
||
strokeLinecap='round'
|
||
strokeLinejoin='round'
|
||
strokeWidth='2'
|
||
d='M6 18L18 6M6 6l12 12'
|
||
/>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
{/* 删除全部按钮 */}
|
||
<button
|
||
onClick={() => {
|
||
if (confirm(`确定要删除《${video.metadata?.videoTitle || video.title}》的所有已下载集数吗?`)) {
|
||
episodes.forEach((ep) => handleDeleteTask(ep.id));
|
||
}
|
||
}}
|
||
className='flex items-center gap-1 px-3 py-1.5 bg-red-500 hover:bg-red-600 text-white text-xs font-medium rounded transition-colors'
|
||
>
|
||
<svg className='w-4 h-4' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
|
||
<path
|
||
strokeLinecap='round'
|
||
strokeLinejoin='round'
|
||
strokeWidth='2'
|
||
d='M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16'
|
||
/>
|
||
</svg>
|
||
删除全部集数
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))
|
||
)
|
||
) : tasks.length === 0 ? (
|
||
// 任务列表为空
|
||
<div className='flex flex-col items-center justify-center h-full text-gray-500 dark:text-gray-400'>
|
||
<svg className='w-16 h-16 mb-4' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
|
||
<path
|
||
strokeLinecap='round'
|
||
strokeLinejoin='round'
|
||
strokeWidth='2'
|
||
d='M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4'
|
||
/>
|
||
</svg>
|
||
<p className='text-lg'>暂无离线下载任务</p>
|
||
</div>
|
||
) : (
|
||
// 任务列表视图
|
||
tasks.map((task) => (
|
||
<div
|
||
key={task.id}
|
||
className='bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 border border-gray-200 dark:border-gray-600'
|
||
>
|
||
{/* 任务信息 */}
|
||
<div className='flex items-start justify-between mb-3'>
|
||
<div className='flex-1 min-w-0'>
|
||
<h3 className='text-sm font-medium text-gray-900 dark:text-white truncate mb-1'>
|
||
{task.title}
|
||
</h3>
|
||
<p className='text-xs text-gray-500 dark:text-gray-400'>
|
||
来源: {task.source} | 视频ID: {task.videoId} | 第{task.episodeIndex + 1}集
|
||
</p>
|
||
</div>
|
||
<div className='flex items-center gap-2 ml-4'>
|
||
<span className={`text-xs font-medium ${getStatusColor(task.status)}`}>
|
||
{getStatusText(task.status)}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 进度条 */}
|
||
{task.totalSegments > 0 && (
|
||
<div className='mb-3'>
|
||
<div className='flex items-center justify-between text-xs text-gray-600 dark:text-gray-300 mb-1'>
|
||
<span>
|
||
{task.downloadedSegments} / {task.totalSegments} 片段
|
||
</span>
|
||
<span>{task.progress.toFixed(1)}%</span>
|
||
</div>
|
||
<div className='w-full bg-gray-200 dark:bg-gray-600 rounded-full h-2 overflow-hidden'>
|
||
<div
|
||
className={`h-full rounded-full transition-all duration-300 ${
|
||
task.status === 'downloading'
|
||
? 'bg-gradient-to-r from-blue-500 to-purple-600 animate-pulse'
|
||
: task.status === 'completed'
|
||
? 'bg-green-500'
|
||
: task.status === 'error'
|
||
? 'bg-red-500'
|
||
: 'bg-gray-400'
|
||
}`}
|
||
style={{ width: `${task.progress}%` }}
|
||
></div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 错误信息 */}
|
||
{task.errorMessage && (
|
||
<div className='mb-3'>
|
||
<div className='text-xs text-red-500 dark:text-red-400'>{task.errorMessage}</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 时间信息 */}
|
||
<div className='flex items-center justify-between text-xs text-gray-500 dark:text-gray-400 mb-3'>
|
||
<span>创建: {new Date(task.createdAt).toLocaleString('zh-CN')}</span>
|
||
<span>更新: {new Date(task.updatedAt).toLocaleString('zh-CN')}</span>
|
||
</div>
|
||
|
||
{/* 操作按钮 */}
|
||
<div className='flex items-center gap-2'>
|
||
{/* 重试按钮 - 只在错误或暂停状态显示 */}
|
||
{(task.status === 'error' || task.status === 'paused') && (
|
||
<button
|
||
onClick={() => handleRetryTask(task.id)}
|
||
className='flex items-center gap-1 px-3 py-1.5 bg-blue-500 hover:bg-blue-600 text-white text-xs font-medium rounded transition-colors'
|
||
>
|
||
<svg className='w-4 h-4' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
|
||
<path
|
||
strokeLinecap='round'
|
||
strokeLinejoin='round'
|
||
strokeWidth='2'
|
||
d='M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15'
|
||
/>
|
||
</svg>
|
||
重试
|
||
</button>
|
||
)}
|
||
<button
|
||
onClick={() => handleDeleteTask(task.id)}
|
||
className='flex items-center gap-1 px-3 py-1.5 bg-red-500 hover:bg-red-600 text-white text-xs font-medium rounded transition-colors'
|
||
>
|
||
<svg className='w-4 h-4' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
|
||
<path
|
||
strokeLinecap='round'
|
||
strokeLinejoin='round'
|
||
strokeWidth='2'
|
||
d='M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16'
|
||
/>
|
||
</svg>
|
||
删除
|
||
</button>
|
||
</div>
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
|
||
return createPortal(panelContent, document.body);
|
||
}
|