下载内容管理

This commit is contained in:
mtvpls
2026-02-26 22:47:39 +08:00
parent e3f4b72fa8
commit b3a460e300
7 changed files with 894 additions and 54 deletions

View File

@@ -0,0 +1,95 @@
'use client';
import { AlertTriangle, X } from 'lucide-react';
import { createPortal } from 'react-dom';
interface ConfirmDialogProps {
isOpen: boolean;
title: string;
message: string;
confirmText?: string;
cancelText?: string;
onConfirm: () => void;
onCancel: () => void;
variant?: 'danger' | 'warning' | 'info';
}
export function ConfirmDialog({
isOpen,
title,
message,
confirmText = '确定',
cancelText = '取消',
onConfirm,
onCancel,
variant = 'warning',
}: ConfirmDialogProps) {
if (!isOpen) return null;
const variantStyles = {
danger: {
icon: 'text-red-500',
button: 'bg-red-500 hover:bg-red-600',
},
warning: {
icon: 'text-yellow-500',
button: 'bg-yellow-500 hover:bg-yellow-600',
},
info: {
icon: 'text-blue-500',
button: 'bg-blue-500 hover:bg-blue-600',
},
};
const styles = variantStyles[variant];
return createPortal(
<div className='fixed inset-0 z-[10000] flex items-center justify-center p-4'>
<div
className='absolute inset-0 bg-black/50'
onClick={onCancel}
/>
<div className='relative w-full max-w-md bg-white dark:bg-gray-900 rounded-lg shadow-xl'>
{/* Header */}
<div className='flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700'>
<div className='flex items-center gap-3'>
<AlertTriangle className={`w-6 h-6 ${styles.icon}`} />
<h2 className='text-lg font-semibold text-gray-800 dark:text-gray-200'>
{title}
</h2>
</div>
<button
onClick={onCancel}
className='p-1 hover:bg-gray-100 dark:hover:bg-gray-800 rounded transition-colors'
>
<X className='w-5 h-5 text-gray-600 dark:text-gray-400' />
</button>
</div>
{/* Content */}
<div className='p-4'>
<p className='text-sm text-gray-700 dark:text-gray-300 whitespace-pre-line'>
{message}
</p>
</div>
{/* Footer */}
<div className='flex items-center justify-end gap-3 p-4 border-t border-gray-200 dark:border-gray-700'>
<button
onClick={onCancel}
className='px-4 py-2 text-sm text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 rounded transition-colors'
>
{cancelText}
</button>
<button
onClick={onConfirm}
className={`px-4 py-2 text-sm text-white rounded transition-colors ${styles.button}`}
>
{confirmText}
</button>
</div>
</div>
</div>,
document.body
);
}

View File

@@ -0,0 +1,314 @@
'use client';
import { Check, Trash2, X } from 'lucide-react';
import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { downloadDB, CompletedTask } from '@/lib/download-db';
import { ConfirmDialog } from './ConfirmDialog';
interface DownloadManagementPanelProps {
isOpen: boolean;
onClose: () => void;
}
export function DownloadManagementPanel({ isOpen, onClose }: DownloadManagementPanelProps) {
const [completedTasks, setCompletedTasks] = useState<CompletedTask[]>([]);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [isDeleting, setIsDeleting] = useState(false);
const [mounted, setMounted] = useState(false);
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
if (isOpen) {
loadCompletedTasks();
}
}, [isOpen]);
const loadCompletedTasks = async () => {
try {
const tasks = await downloadDB.getCompletedTasks();
setCompletedTasks(tasks);
} catch (error) {
console.error('加载已完成任务失败:', error);
}
};
const handleSelectAll = () => {
if (selectedIds.size === completedTasks.length) {
setSelectedIds(new Set());
} else {
setSelectedIds(new Set(completedTasks.map(t => t.id)));
}
};
const handleToggleSelect = (id: string) => {
const newSet = new Set(selectedIds);
if (newSet.has(id)) {
newSet.delete(id);
} else {
newSet.add(id);
}
setSelectedIds(newSet);
};
const handleDelete = async () => {
if (selectedIds.size === 0) return;
setShowConfirmDialog(true);
};
const handleConfirmDelete = async () => {
setShowConfirmDialog(false);
setIsDeleting(true);
try {
// 获取要删除的任务
const tasksToDelete = completedTasks.filter(t => selectedIds.has(t.id));
// 删除文件系统中的文件
for (const task of tasksToDelete) {
if (task.downloadMode === 'filesystem') {
try {
// 从 IndexedDB 读取目录句柄
const dbName = 'MoonTVPlus';
const storeName = 'dirHandles';
const dirHandle = await new Promise<FileSystemDirectoryHandle | undefined>((resolve) => {
const request = indexedDB.open(dbName, 2); // 使用版本 2
request.onsuccess = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
if (!db.objectStoreNames.contains(storeName)) {
db.close();
resolve(undefined);
return;
}
const transaction = db.transaction([storeName], 'readonly');
const store = transaction.objectStore(storeName);
const getRequest = store.get('downloadDir');
getRequest.onsuccess = () => {
const handle = getRequest.result as FileSystemDirectoryHandle | undefined;
db.close();
resolve(handle);
};
getRequest.onerror = () => {
db.close();
resolve(undefined);
};
};
request.onerror = () => {
resolve(undefined);
};
});
if (dirHandle) {
// 请求写权限
const permission = await (dirHandle as any).requestPermission({ mode: 'readwrite' });
if (permission !== 'granted') {
console.error('未获得写权限,无法删除文件');
continue;
}
// 删除目录
try {
const sourceDirHandle = await dirHandle.getDirectoryHandle(task.source, { create: false });
const videoIdDirHandle = await sourceDirHandle.getDirectoryHandle(task.videoId, { create: false });
await videoIdDirHandle.removeEntry(`ep${task.episodeIndex + 1}`, { recursive: true });
console.log('已删除文件:', task.source, task.videoId, `ep${task.episodeIndex + 1}`);
} catch (deleteError) {
console.error('删除目录失败:', deleteError);
// 如果目录不存在,也算成功
if ((deleteError as Error).name !== 'NotFoundError') {
throw deleteError;
}
}
}
} catch (error) {
console.error('删除文件失败:', task.title, error);
}
}
}
// 从数据库删除记录
await downloadDB.deleteCompletedTasks(Array.from(selectedIds));
await loadCompletedTasks();
setSelectedIds(new Set());
} catch (error) {
console.error('删除任务失败:', error);
alert('删除失败,请重试');
} finally {
setIsDeleting(false);
}
};
const formatDate = (timestamp: number) => {
const date = new Date(timestamp);
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
};
const formatFileSize = (bytes?: number) => {
if (!bytes) return '未知';
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + ' KB';
if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(2) + ' MB';
return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
};
if (!mounted || !isOpen) return null;
return (
<>
{createPortal(
<div className='fixed inset-0 z-[9999] flex items-center justify-center p-4'>
<div
className='absolute inset-0 bg-black/50'
onClick={onClose}
/>
<div className='relative w-full max-w-4xl max-h-[90vh] bg-white dark:bg-gray-900 rounded-lg shadow-xl flex flex-col'>
{/* Header */}
<div className='flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700'>
<h2 className='text-xl font-semibold text-gray-800 dark:text-gray-200'>
</h2>
<button
onClick={onClose}
className='p-1 hover:bg-gray-100 dark:hover:bg-gray-800 rounded transition-colors'
>
<X className='w-5 h-5 text-gray-600 dark:text-gray-400' />
</button>
</div>
{/* Toolbar */}
<div className='flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700'>
<div className='flex items-center gap-4'>
<label className='flex items-center gap-2 cursor-pointer'>
<input
type='checkbox'
checked={selectedIds.size === completedTasks.length && completedTasks.length > 0}
onChange={handleSelectAll}
className='w-4 h-4'
/>
<span className='text-sm text-gray-700 dark:text-gray-300'>
</span>
</label>
<span className='text-sm text-gray-500 dark:text-gray-400'>
{selectedIds.size} / {completedTasks.length}
</span>
</div>
<button
onClick={handleDelete}
disabled={selectedIds.size === 0 || isDeleting}
className='px-4 py-2 text-sm bg-red-500 text-white rounded hover:bg-red-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2'
>
<Trash2 className='w-4 h-4' />
{isDeleting ? '删除中...' : '删除选中'}
</button>
</div>
{/* Content */}
<div className='flex-1 overflow-y-auto p-4'>
{completedTasks.length === 0 ? (
<div className='text-center py-12 text-gray-500 dark:text-gray-400'>
</div>
) : (
<div className='space-y-2'>
{completedTasks.map((task) => (
<div
key={task.id}
className={`p-4 border rounded-lg transition-colors cursor-pointer ${
selectedIds.has(task.id)
? 'border-green-500 bg-green-50 dark:bg-green-900/20'
: 'border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800'
}`}
onClick={() => handleToggleSelect(task.id)}
>
<div className='flex items-start gap-3'>
<div className='flex-shrink-0 mt-1'>
<div className={`w-5 h-5 rounded border-2 flex items-center justify-center ${
selectedIds.has(task.id)
? 'border-green-500 bg-green-500'
: 'border-gray-300 dark:border-gray-600'
}`}>
{selectedIds.has(task.id) && (
<Check className='w-3 h-3 text-white' />
)}
</div>
</div>
<div className='flex-1 min-w-0'>
<div className='flex items-start justify-between gap-2'>
<div className='flex-1 min-w-0'>
<h3 className='text-sm font-medium text-gray-800 dark:text-gray-200 truncate'>
{task.title}
</h3>
{task.videoTitle && (
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
{task.videoTitle}
</p>
)}
{task.episodeTitle && (
<p className='text-xs text-gray-500 dark:text-gray-400'>
{task.episodeTitle}
</p>
)}
</div>
<div className='flex-shrink-0 text-right'>
<div className='text-xs text-gray-500 dark:text-gray-400'>
{formatDate(task.completedAt)}
</div>
{task.fileSize && (
<div className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
{formatFileSize(task.fileSize)}
</div>
)}
</div>
</div>
<div className='flex items-center gap-2 mt-2 text-xs text-gray-500 dark:text-gray-400'>
<span>: {task.source}</span>
<span></span>
<span> {task.episodeIndex + 1} </span>
<span></span>
<span>{task.downloadMode === 'filesystem' ? 'File System API' : '浏览器下载'}</span>
</div>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>,
document.body
)}
<ConfirmDialog
isOpen={showConfirmDialog}
title='确认删除'
message={`确定要删除选中的 ${selectedIds.size} 个下载记录吗?\n\n注意如果是 File System API 下载的文件,将会从磁盘删除实际文件。`}
confirmText='删除'
cancelText='取消'
variant='danger'
onConfirm={handleConfirmDelete}
onCancel={() => setShowConfirmDialog(false)}
/>
</>
);
}

View File

@@ -47,6 +47,7 @@ import { NotificationPanel } from './NotificationPanel';
import { OfflineDownloadPanel } from './OfflineDownloadPanel';
import { useVersionCheck } from './VersionCheckProvider';
import { VersionPanel } from './VersionPanel';
import { DownloadManagementPanel } from './DownloadManagementPanel';
interface AuthInfo {
username?: string;
@@ -68,6 +69,7 @@ export const UserMenu: React.FC = () => {
const [isDeviceManagementOpen, setIsDeviceManagementOpen] = useState(false);
const [isEcoAppsOpen, setIsEcoAppsOpen] = useState(false);
const [isReportOpen, setIsReportOpen] = useState(false);
const [isDownloadManagementOpen, setIsDownloadManagementOpen] = useState(false);
const [authInfo, setAuthInfo] = useState<AuthInfo | null>(null);
const [storageType, setStorageType] = useState<string>('localstorage');
const [mounted, setMounted] = useState(false);
@@ -85,7 +87,7 @@ export const UserMenu: React.FC = () => {
// Body 滚动锁定 - 使用 overflow 方式避免布局问题
useEffect(() => {
if (isSettingsOpen || isChangePasswordOpen || isSubscribeOpen || isOfflineDownloadPanelOpen || isEmailSettingsOpen || isDeviceManagementOpen || isEcoAppsOpen || isReportOpen) {
if (isSettingsOpen || isChangePasswordOpen || isSubscribeOpen || isOfflineDownloadPanelOpen || isEmailSettingsOpen || isDeviceManagementOpen || isEcoAppsOpen || isReportOpen || isDownloadManagementOpen) {
const body = document.body;
const html = document.documentElement;
@@ -975,27 +977,59 @@ export const UserMenu: React.FC = () => {
// 保存目录句柄到 IndexedDB
const dbName = 'MoonTVPlus';
const storeName = 'dirHandles';
const request = indexedDB.open(dbName, 1);
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
if (!db.objectStoreNames.contains(storeName)) {
db.createObjectStore(storeName);
}
};
// 使用 Promise 包装 IndexedDB 操作
await new Promise<void>((resolve, reject) => {
const request = indexedDB.open(dbName, 2); // 使用版本 2与 download-db.ts 保持一致
request.onsuccess = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
const transaction = db.transaction([storeName], 'readwrite');
const store = transaction.objectStore(storeName);
store.put(dirHandle, 'downloadDir');
};
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
request.onerror = () => {
console.error('无法打开 IndexedDB');
};
// 创建 dirHandles 表(如果不存在)
if (!db.objectStoreNames.contains(storeName)) {
db.createObjectStore(storeName);
}
// 创建 activeTasks 表(如果不存在)
if (!db.objectStoreNames.contains('activeTasks')) {
const activeStore = db.createObjectStore('activeTasks', { keyPath: 'id' });
activeStore.createIndex('status', 'status', { unique: false });
activeStore.createIndex('createdAt', 'createdAt', { unique: false });
}
// 创建 completedTasks 表(如果不存在)
if (!db.objectStoreNames.contains('completedTasks')) {
const completedStore = db.createObjectStore('completedTasks', { keyPath: 'id' });
completedStore.createIndex('source', 'source', { unique: false });
completedStore.createIndex('videoId', 'videoId', { unique: false });
completedStore.createIndex('completedAt', 'completedAt', { unique: false });
completedStore.createIndex('sourceVideoId', ['source', 'videoId'], { unique: false });
}
};
request.onsuccess = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
const transaction = db.transaction([storeName], 'readwrite');
const store = transaction.objectStore(storeName);
const putRequest = store.put(dirHandle, 'downloadDir');
putRequest.onsuccess = () => {
db.close();
resolve();
};
putRequest.onerror = () => {
db.close();
reject(new Error('保存目录句柄失败'));
};
};
request.onerror = () => {
reject(new Error('无法打开 IndexedDB'));
};
});
} catch (err) {
console.error('用户取消选择目录', err);
console.error('选择目录失败:', err);
}
};
@@ -2329,6 +2363,17 @@ export const UserMenu: React.FC = () => {
</div>
)}
</div>
{/* 下载文件管理 */}
<div className='space-y-2'>
<button
onClick={() => setIsDownloadManagementOpen(true)}
className='w-full px-4 py-2 text-sm bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors flex items-center justify-center gap-2'
>
<Package className='w-4 h-4' />
</button>
</div>
</div>
)}
</div>
@@ -3676,6 +3721,17 @@ export const UserMenu: React.FC = () => {
document.body
)}
{/* 使用 Portal 将下载文件管理面板渲染到 document.body */}
{isDownloadManagementOpen &&
mounted &&
createPortal(
<DownloadManagementPanel
isOpen={isDownloadManagementOpen}
onClose={() => setIsDownloadManagementOpen(false)}
/>,
document.body
)}
{/* 使用 Portal 将邮件设置面板渲染到 document.body */}
{isEmailSettingsOpen &&
mounted &&