增加下载内容导出功能

This commit is contained in:
mtvpls
2026-06-21 16:26:06 +08:00
parent 99b58b2e6a
commit 1a24f3316f
2 changed files with 625 additions and 259 deletions
+500 -216
View File
@@ -1,11 +1,16 @@
'use client';
import { Check, ChevronDown, Trash2, X } from 'lucide-react';
import { Check, ChevronDown, Download, Trash2, X } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import { downloadDB, CompletedTask } from '@/lib/download-db';
import { deleteIndexedDBVideoCacheByEpisode } from '@/lib/indexeddb-video-cache';
import {
buildIndexedDBVideoCacheKey,
getIndexedDBVideoManifestByEpisode,
getIndexedDBVideoSegments,
deleteIndexedDBVideoCacheByEpisode,
} from '@/lib/indexeddb-video-cache';
import { ConfirmDialog } from './ConfirmDialog';
@@ -25,11 +30,17 @@ interface VideoDownloadGroup {
downloadModes: CompletedTask['downloadMode'][];
}
export function DownloadManagementPanel({ isOpen, onClose }: DownloadManagementPanelProps) {
export function DownloadManagementPanel({
isOpen,
onClose,
}: DownloadManagementPanelProps) {
const [completedTasks, setCompletedTasks] = useState<CompletedTask[]>([]);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [expandedGroupKeys, setExpandedGroupKeys] = useState<Set<string>>(new Set());
const [expandedGroupKeys, setExpandedGroupKeys] = useState<Set<string>>(
new Set()
);
const [isDeleting, setIsDeleting] = useState(false);
const [isExporting, setIsExporting] = useState(false);
const [mounted, setMounted] = useState(false);
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
@@ -56,7 +67,7 @@ export function DownloadManagementPanel({ isOpen, onClose }: DownloadManagementP
if (selectedIds.size === completedTasks.length) {
setSelectedIds(new Set());
} else {
setSelectedIds(new Set(completedTasks.map(t => t.id)));
setSelectedIds(new Set(completedTasks.map((t) => t.id)));
}
};
@@ -104,7 +115,7 @@ export function DownloadManagementPanel({ isOpen, onClose }: DownloadManagementP
setIsDeleting(true);
try {
// 获取要删除的任务
const tasksToDelete = completedTasks.filter(t => selectedIds.has(t.id));
const tasksToDelete = completedTasks.filter((t) => selectedIds.has(t.id));
//禁止SzeMeng76抄袭狗抄袭
// 删除文件系统中的文件
@@ -115,7 +126,9 @@ export function DownloadManagementPanel({ isOpen, onClose }: DownloadManagementP
const dbName = 'MoonTVPlus';
const storeName = 'dirHandles';
const dirHandle = await new Promise<FileSystemDirectoryHandle | undefined>((resolve) => {
const dirHandle = await new Promise<
FileSystemDirectoryHandle | undefined
>((resolve) => {
const request = indexedDB.open(dbName, 2); // 使用版本 2
request.onsuccess = (event) => {
@@ -132,7 +145,9 @@ export function DownloadManagementPanel({ isOpen, onClose }: DownloadManagementP
const getRequest = store.get('downloadDir');
getRequest.onsuccess = () => {
const handle = getRequest.result as FileSystemDirectoryHandle | undefined;
const handle = getRequest.result as
| FileSystemDirectoryHandle
| undefined;
db.close();
resolve(handle);
};
@@ -150,7 +165,9 @@ export function DownloadManagementPanel({ isOpen, onClose }: DownloadManagementP
if (dirHandle) {
// 请求写权限
const permission = await (dirHandle as any).requestPermission({ mode: 'readwrite' });
const permission = await (dirHandle as any).requestPermission({
mode: 'readwrite',
});
if (permission !== 'granted') {
console.error('未获得写权限,无法删除文件');
continue;
@@ -158,10 +175,24 @@ export function DownloadManagementPanel({ isOpen, onClose }: DownloadManagementP
// 删除目录
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}`);
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);
// 如果目录不存在,也算成功
@@ -180,7 +211,12 @@ export function DownloadManagementPanel({ isOpen, onClose }: DownloadManagementP
task.videoId,
task.episodeIndex
);
console.log('已删除 IndexedDB 视频缓存:', task.source, task.videoId, task.episodeIndex);
console.log(
'已删除 IndexedDB 视频缓存:',
task.source,
task.videoId,
task.episodeIndex
);
} catch (error) {
console.error('删除 IndexedDB 视频缓存失败:', task.title, error);
}
@@ -199,6 +235,214 @@ export function DownloadManagementPanel({ isOpen, onClose }: DownloadManagementP
}
};
const sanitizeFilename = (name: string) => {
return (
name
.replace(/[\\/:*?"<>|]/g, '_')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 120) || 'download'
);
};
const triggerBlobDownload = (blob: Blob, filename: string) => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.style.display = 'none';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
};
const getStoredDownloadDirHandle = async (): Promise<
FileSystemDirectoryHandle | undefined
> => {
const dbName = 'MoonTVPlus';
const storeName = 'dirHandles';
return new Promise<FileSystemDirectoryHandle | undefined>((resolve) => {
const request = indexedDB.open(dbName, 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);
});
};
const readFilesystemTaskAsBlobParts = async (
task: CompletedTask,
dirHandle: FileSystemDirectoryHandle
): Promise<BlobPart[]> => {
const sourceDirHandle = await dirHandle.getDirectoryHandle(task.source, {
create: false,
});
const videoIdDirHandle = await sourceDirHandle.getDirectoryHandle(
task.videoId,
{ create: false }
);
const epDirHandle = await videoIdDirHandle.getDirectoryHandle(
`ep${task.episodeIndex + 1}`,
{ create: false }
);
const segmentFiles: Array<{ name: string; file: File }> = [];
for await (const entry of epDirHandle.values()) {
if (entry.kind === 'file' && /^segment_\d+\.ts$/i.test(entry.name)) {
const fileHandle = entry as FileSystemFileHandle;
segmentFiles.push({
name: entry.name,
file: await fileHandle.getFile(),
});
}
}
segmentFiles.sort((a, b) =>
a.name.localeCompare(b.name, undefined, { numeric: true })
);
if (segmentFiles.length === 0) {
throw new Error('未找到可导出的分片文件');
}
return segmentFiles.map(({ file }) => file);
};
const readIndexedDBTaskAsBlobParts = async (
task: CompletedTask
): Promise<BlobPart[]> => {
const manifest = await getIndexedDBVideoManifestByEpisode(
task.source,
task.videoId,
task.episodeIndex
);
const cacheKey =
manifest?.cacheKey ||
buildIndexedDBVideoCacheKey(task.source, task.videoId, task.episodeIndex);
const segments = await getIndexedDBVideoSegments(cacheKey);
if (segments.length === 0) {
throw new Error('未找到可导出的 IndexedDB 分片');
}
return segments.map((segment) => segment.data);
};
const handleExport = async () => {
if (selectedIds.size === 0 || isExporting) return;
setIsExporting(true);
try {
const tasksToExport = completedTasks
.filter((task) => selectedIds.has(task.id))
.sort((a, b) => {
const groupCompare = `${a.source}::${a.videoId}`.localeCompare(
`${b.source}::${b.videoId}`
);
if (groupCompare !== 0) return groupCompare;
if (a.episodeIndex !== b.episodeIndex)
return a.episodeIndex - b.episodeIndex;
return a.completedAt - b.completedAt;
});
const unsupportedTasks = tasksToExport.filter(
(task) => task.downloadMode === 'browser'
);
if (unsupportedTasks.length > 0) {
alert(
'浏览器下载模式的文件未保存在本地缓存中,无法从下载管理导出。请仅选择 File System API 或 IndexedDB 缓存记录。'
);
return;
}
let dirHandle: FileSystemDirectoryHandle | undefined;
if (tasksToExport.some((task) => task.downloadMode === 'filesystem')) {
dirHandle = await getStoredDownloadDirHandle();
if (!dirHandle) {
alert('无法读取下载目录授权,请先在下载设置中重新选择保存目录。');
return;
}
const permission = await (dirHandle as any).requestPermission({
mode: 'read',
});
if (permission !== 'granted') {
alert('未获得下载目录读取权限,无法导出。');
return;
}
}
const blobParts: BlobPart[] = [];
const failedTitles: string[] = [];
for (const task of tasksToExport) {
try {
const parts =
task.downloadMode === 'filesystem'
? await readFilesystemTaskAsBlobParts(task, dirHandle!)
: await readIndexedDBTaskAsBlobParts(task);
blobParts.push(...parts);
} catch (error) {
console.error('导出任务失败:', task.title, error);
failedTitles.push(`${task.episodeIndex + 1}`);
}
}
if (blobParts.length === 0) {
alert(`导出失败:${failedTitles.join('、') || '未找到可导出的内容'}`);
return;
}
const selectedGroups = videoGroups.filter((group) =>
group.tasks.some((task) => selectedIds.has(task.id))
);
const baseName =
selectedGroups.length === 1
? selectedGroups[0].title
: `MoonTVPlus导出_${tasksToExport.length}`;
const blob = new Blob(blobParts, { type: 'video/MP2T' });
triggerBlobDownload(blob, `${sanitizeFilename(baseName)}.ts`);
if (failedTitles.length > 0) {
alert(`已导出可读取的内容,但以下条目失败:${failedTitles.join('、')}`);
}
} catch (error) {
console.error('导出失败:', error);
alert(
`导出失败:${error instanceof Error ? error.message : String(error)}`
);
} finally {
setIsExporting(false);
}
};
const formatDate = (timestamp: number) => {
const date = new Date(timestamp);
return date.toLocaleString('zh-CN', {
@@ -214,7 +458,8 @@ export function DownloadManagementPanel({ isOpen, onClose }: DownloadManagementP
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';
if (bytes < 1024 * 1024 * 1024)
return (bytes / (1024 * 1024)).toFixed(2) + ' MB';
return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
};
@@ -235,10 +480,12 @@ export function DownloadManagementPanel({ isOpen, onClose }: DownloadManagementP
if (taskWithVideoTitle?.videoTitle) return taskWithVideoTitle.videoTitle;
const firstTitle = tasks[0]?.title || '未知视频';
return firstTitle
.replace(/[_\s-]*\s*\d+\s*.*$/u, '')
.replace(/[_\s-]*EP?\s*\d+.*$/iu, '')
.trim() || firstTitle;
return (
firstTitle
.replace(/[_\s-]*\s*\d+\s*.*$/u, '')
.replace(/[_\s-]*EP?\s*\d+.*$/iu, '')
.trim() || firstTitle
);
};
const videoGroups = useMemo<VideoDownloadGroup[]>(() => {
@@ -259,7 +506,10 @@ export function DownloadManagementPanel({ isOpen, onClose }: DownloadManagementP
}
return b.completedAt - a.completedAt;
});
const totalSize = sortedTasks.reduce((sum, task) => sum + (task.fileSize || 0), 0);
const totalSize = sortedTasks.reduce(
(sum, task) => sum + (task.fileSize || 0),
0
);
const modeSet = new Set<CompletedTask['downloadMode']>(
sortedTasks.map((task) => task.downloadMode)
);
@@ -271,7 +521,9 @@ export function DownloadManagementPanel({ isOpen, onClose }: DownloadManagementP
title: getGroupTitle(sortedTasks),
tasks: sortedTasks,
totalSize: totalSize > 0 ? totalSize : undefined,
lastCompletedAt: Math.max(...sortedTasks.map((task) => task.completedAt)),
lastCompletedAt: Math.max(
...sortedTasks.map((task) => task.completedAt)
),
downloadModes: Array.from(modeSet),
};
})
@@ -283,205 +535,237 @@ export function DownloadManagementPanel({ isOpen, onClose }: DownloadManagementP
return (
<>
{createPortal(
<div className='fixed inset-0 z-[9999] flex items-end justify-center p-0 sm:items-center sm:p-4'>
<div
className='absolute inset-0 bg-black/50'
onClick={onClose}
/>
<div className='relative flex h-[92dvh] max-h-[92dvh] w-full max-w-4xl flex-col rounded-t-2xl bg-white shadow-xl dark:bg-gray-900 sm:h-auto sm:max-h-[90vh] sm:rounded-lg'>
{/* Header */}
<div className='flex items-center justify-between border-b border-gray-200 p-3 dark:border-gray-700 sm:p-4'>
<h2 className='text-lg font-semibold text-gray-800 dark:text-gray-200 sm:text-xl'>
</h2>
<button
onClick={onClose}
className='rounded p-2 transition-colors hover:bg-gray-100 dark:hover:bg-gray-800'
aria-label='关闭下载文件管理'
>
<X className='w-5 h-5 text-gray-600 dark:text-gray-400' />
</button>
</div>
{/* Toolbar */}
<div className='flex flex-col gap-3 border-b border-gray-200 p-3 dark:border-gray-700 sm:flex-row sm:items-center sm:justify-between sm:p-4'>
<div className='flex flex-col gap-2 sm:flex-row sm:items-center sm: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}
{videoGroups.length > 0 && `,共 ${videoGroups.length} 个视频`}
</span>
</div>
<button
onClick={handleDelete}
disabled={selectedIds.size === 0 || isDeleting}
className='flex w-full items-center justify-center gap-2 rounded bg-red-500 px-4 py-2.5 text-sm text-white transition-colors hover:bg-red-600 disabled:cursor-not-allowed disabled:opacity-50 sm:w-auto sm:py-2'
>
<Trash2 className='w-4 h-4' />
{isDeleting ? '删除中...' : '删除选中'}
</button>
</div>
{/* Content */}
<div className='flex-1 overflow-y-auto p-3 sm:p-4'>
{completedTasks.length === 0 ? (
<div className='text-center py-12 text-gray-500 dark:text-gray-400'>
<div className='fixed inset-0 z-[9999] flex items-end justify-center p-0 sm:items-center sm:p-4'>
<div className='absolute inset-0 bg-black/50' onClick={onClose} />
<div className='relative flex h-[92dvh] max-h-[92dvh] w-full max-w-4xl flex-col rounded-t-2xl bg-white shadow-xl dark:bg-gray-900 sm:h-auto sm:max-h-[90vh] sm:rounded-lg'>
{/* Header */}
<div className='flex items-center justify-between border-b border-gray-200 p-3 dark:border-gray-700 sm:p-4'>
<h2 className='text-lg font-semibold text-gray-800 dark:text-gray-200 sm:text-xl'>
</h2>
<button
onClick={onClose}
className='rounded p-2 transition-colors hover:bg-gray-100 dark:hover:bg-gray-800'
aria-label='关闭下载文件管理'
>
<X className='w-5 h-5 text-gray-600 dark:text-gray-400' />
</button>
</div>
) : (
<div className='space-y-3'>
{videoGroups.map((group) => {
const isExpanded = expandedGroupKeys.has(group.key);
const selectedCount = group.tasks.filter((task) => selectedIds.has(task.id)).length;
const isGroupSelected = selectedCount === group.tasks.length;
const isGroupPartiallySelected = selectedCount > 0 && !isGroupSelected;
return (
<div
key={group.key}
className={`border rounded-lg overflow-hidden transition-colors ${
isGroupSelected || isGroupPartiallySelected
? 'border-green-500 bg-green-50/70 dark:bg-green-900/10'
: 'border-gray-200 dark:border-gray-700'
}`}
{/* Toolbar */}
<div className='flex items-center justify-between gap-3 border-b border-gray-200 p-3 dark:border-gray-700 sm:p-4'>
<div className='min-w-0 flex-1 flex-col gap-2 sm:flex sm:flex-row sm:items-center sm: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}
{videoGroups.length > 0 &&
`,共 ${videoGroups.length} 个视频`}
</span>
</div>
<div className='ml-auto flex flex-shrink-0 items-center justify-end gap-2'>
<button
onClick={handleExport}
aria-label={isExporting ? '导出中' : '导出选中'}
disabled={selectedIds.size === 0 || isExporting || isDeleting}
className='flex h-10 w-10 items-center justify-center rounded bg-green-500 text-white transition-colors hover:bg-green-600 disabled:cursor-not-allowed disabled:opacity-50 sm:h-auto sm:w-auto sm:gap-2 sm:px-4 sm:py-2 sm:text-sm'
>
<div
className='flex cursor-pointer items-start gap-2 p-3 hover:bg-gray-50 dark:hover:bg-gray-800/80 sm:gap-3 sm:p-4'
onClick={() => handleToggleGroupExpand(group.key)}
>
<button
type='button'
onClick={(event) => {
event.stopPropagation();
handleToggleGroupSelect(group);
}}
className={`flex h-8 w-8 flex-shrink-0 items-center justify-center rounded border-2 transition-colors sm:mt-1 sm:h-5 sm:w-5 ${
isGroupSelected
? 'border-green-500 bg-green-500'
: isGroupPartiallySelected
? 'border-green-500 bg-green-100 dark:bg-green-900/40'
: 'border-gray-300 dark:border-gray-600'
}`}
aria-label={`选择 ${group.title}`}
>
{isGroupSelected ? (
<Check className='h-4 w-4 text-white sm:h-3 sm:w-3' />
) : isGroupPartiallySelected ? (
<span className='h-0.5 w-3 rounded bg-green-500 sm:w-2.5' />
) : null}
</button>
<div className='min-w-0 flex-1'>
<div className='flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-3'>
<div className='min-w-0 flex-1'>
<h3 className='line-clamp-2 text-sm font-semibold text-gray-800 dark:text-gray-200 sm:truncate'>
{group.title}
</h3>
<div className='mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-gray-500 dark:text-gray-400'>
<span>: {group.source}</span>
<span></span>
<span>{group.tasks.length} </span>
<span></span>
<span>{group.downloadModes.map(getDownloadModeLabel).join(' / ')}</span>
</div>
</div>
<div className='flex w-full flex-row-reverse items-center justify-between gap-3 text-left sm:w-auto sm:flex-row sm:items-start sm:text-right'>
<div className='min-w-0 sm:min-w-[150px]'>
<div className='text-xs text-gray-500 dark:text-gray-400'>
{formatDate(group.lastCompletedAt)}
</div>
<div className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
{formatFileSize(group.totalSize)}
</div>
{selectedCount > 0 && (
<div className='mt-1 text-xs text-green-600 dark:text-green-400'>
{selectedCount}
</div>
)}
</div>
<ChevronDown
className={`h-5 w-5 flex-shrink-0 text-gray-400 transition-transform sm:mt-1 ${
isExpanded ? 'rotate-180' : ''
}`}
/>
</div>
</div>
</div>
</div>
{isExpanded && (
<div className='border-t border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-900'>
{group.tasks.map((task) => (
<div
key={task.id}
className={`flex cursor-pointer items-start gap-2 px-3 py-3.5 transition-colors sm:gap-3 sm:px-4 sm:py-3 ${
selectedIds.has(task.id)
? 'bg-green-50 dark:bg-green-900/20'
: 'hover:bg-gray-50 dark:hover:bg-gray-800'
}`}
onClick={() => handleToggleSelect(task.id)}
>
<div className='flex-shrink-0 sm:mt-1'>
<div className={`flex h-8 w-8 items-center justify-center rounded border-2 sm:h-5 sm:w-5 ${
selectedIds.has(task.id)
? 'border-green-500 bg-green-500'
: 'border-gray-300 dark:border-gray-600'
}`}>
{selectedIds.has(task.id) && (
<Check className='h-4 w-4 text-white sm:h-3 sm:w-3' />
)}
</div>
</div>
<div className='min-w-0 flex-1'>
<div className='flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between'>
<div className='min-w-0 flex-1'>
<h4 className='line-clamp-2 text-sm font-medium text-gray-800 dark:text-gray-200 sm:truncate'>
{task.episodeIndex + 1}
{task.episodeTitle ? `${task.episodeTitle}` : ''}
</h4>
<p className='mt-1 truncate text-xs text-gray-500 dark:text-gray-400'>
{task.title}
</p>
</div>
<div className='flex-shrink-0 text-left sm:text-right'>
<div className='text-xs text-gray-500 dark:text-gray-400'>
{formatDate(task.completedAt)}
</div>
<div className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
{formatFileSize(task.fileSize)}
</div>
</div>
</div>
<div className='mt-2 flex flex-wrap items-center gap-2 text-xs text-gray-500 dark:text-gray-400'>
<span> {task.episodeIndex + 1} </span>
<span></span>
<span>{getDownloadModeLabel(task.downloadMode)}</span>
</div>
</div>
</div>
))}
</div>
)}
</div>
);
})}
<Download className='h-4 w-4' />
<span className='hidden sm:inline'>
{isExporting ? '导出中...' : '导出选中'}
</span>
</button>
<button
onClick={handleDelete}
aria-label={isDeleting ? '删除中' : '删除选中'}
disabled={selectedIds.size === 0 || isDeleting || isExporting}
className='flex h-10 w-10 items-center justify-center rounded bg-red-500 text-white transition-colors hover:bg-red-600 disabled:cursor-not-allowed disabled:opacity-50 sm:h-auto sm:w-auto sm:gap-2 sm:px-4 sm:py-2 sm:text-sm'
>
<Trash2 className='h-4 w-4' />
<span className='hidden sm:inline'>
{isDeleting ? '删除中...' : '删除选中'}
</span>
</button>
</div>
</div>
)}
</div>
</div>
</div>,
document.body
)}
{/* Content */}
<div className='flex-1 overflow-y-auto p-3 sm:p-4'>
{completedTasks.length === 0 ? (
<div className='text-center py-12 text-gray-500 dark:text-gray-400'>
</div>
) : (
<div className='space-y-3'>
{videoGroups.map((group) => {
const isExpanded = expandedGroupKeys.has(group.key);
const selectedCount = group.tasks.filter((task) =>
selectedIds.has(task.id)
).length;
const isGroupSelected =
selectedCount === group.tasks.length;
const isGroupPartiallySelected =
selectedCount > 0 && !isGroupSelected;
return (
<div
key={group.key}
className={`border rounded-lg overflow-hidden transition-colors ${
isGroupSelected || isGroupPartiallySelected
? 'border-green-500 bg-green-50/70 dark:bg-green-900/10'
: 'border-gray-200 dark:border-gray-700'
}`}
>
<div
className='flex cursor-pointer items-start gap-2 p-3 hover:bg-gray-50 dark:hover:bg-gray-800/80 sm:gap-3 sm:p-4'
onClick={() => handleToggleGroupExpand(group.key)}
>
<button
type='button'
onClick={(event) => {
event.stopPropagation();
handleToggleGroupSelect(group);
}}
className={`flex h-8 w-8 flex-shrink-0 items-center justify-center rounded border-2 transition-colors sm:mt-1 sm:h-5 sm:w-5 ${
isGroupSelected
? 'border-green-500 bg-green-500'
: isGroupPartiallySelected
? 'border-green-500 bg-green-100 dark:bg-green-900/40'
: 'border-gray-300 dark:border-gray-600'
}`}
aria-label={`选择 ${group.title}`}
>
{isGroupSelected ? (
<Check className='h-4 w-4 text-white sm:h-3 sm:w-3' />
) : isGroupPartiallySelected ? (
<span className='h-0.5 w-3 rounded bg-green-500 sm:w-2.5' />
) : null}
</button>
<div className='min-w-0 flex-1'>
<div className='flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-3'>
<div className='min-w-0 flex-1'>
<h3 className='line-clamp-2 text-sm font-semibold text-gray-800 dark:text-gray-200 sm:truncate'>
{group.title}
</h3>
<div className='mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-gray-500 dark:text-gray-400'>
<span>: {group.source}</span>
<span></span>
<span>{group.tasks.length} </span>
<span></span>
<span>
{group.downloadModes
.map(getDownloadModeLabel)
.join(' / ')}
</span>
</div>
</div>
<div className='flex w-full flex-row-reverse items-center justify-between gap-3 text-left sm:w-auto sm:flex-row sm:items-start sm:text-right'>
<div className='min-w-0 sm:min-w-[150px]'>
<div className='text-xs text-gray-500 dark:text-gray-400'>
{formatDate(group.lastCompletedAt)}
</div>
<div className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
{formatFileSize(group.totalSize)}
</div>
{selectedCount > 0 && (
<div className='mt-1 text-xs text-green-600 dark:text-green-400'>
{selectedCount}
</div>
)}
</div>
<ChevronDown
className={`h-5 w-5 flex-shrink-0 text-gray-400 transition-transform sm:mt-1 ${
isExpanded ? 'rotate-180' : ''
}`}
/>
</div>
</div>
</div>
</div>
{isExpanded && (
<div className='border-t border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-900'>
{group.tasks.map((task) => (
<div
key={task.id}
className={`flex cursor-pointer items-start gap-2 px-3 py-3.5 transition-colors sm:gap-3 sm:px-4 sm:py-3 ${
selectedIds.has(task.id)
? 'bg-green-50 dark:bg-green-900/20'
: 'hover:bg-gray-50 dark:hover:bg-gray-800'
}`}
onClick={() => handleToggleSelect(task.id)}
>
<div className='flex-shrink-0 sm:mt-1'>
<div
className={`flex h-8 w-8 items-center justify-center rounded border-2 sm:h-5 sm:w-5 ${
selectedIds.has(task.id)
? 'border-green-500 bg-green-500'
: 'border-gray-300 dark:border-gray-600'
}`}
>
{selectedIds.has(task.id) && (
<Check className='h-4 w-4 text-white sm:h-3 sm:w-3' />
)}
</div>
</div>
<div className='min-w-0 flex-1'>
<div className='flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between'>
<div className='min-w-0 flex-1'>
<h4 className='line-clamp-2 text-sm font-medium text-gray-800 dark:text-gray-200 sm:truncate'>
{task.episodeIndex + 1}
{task.episodeTitle
? `${task.episodeTitle}`
: ''}
</h4>
<p className='mt-1 truncate text-xs text-gray-500 dark:text-gray-400'>
{task.title}
</p>
</div>
<div className='flex-shrink-0 text-left sm:text-right'>
<div className='text-xs text-gray-500 dark:text-gray-400'>
{formatDate(task.completedAt)}
</div>
<div className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
{formatFileSize(task.fileSize)}
</div>
</div>
</div>
<div className='mt-2 flex flex-wrap items-center gap-2 text-xs text-gray-500 dark:text-gray-400'>
<span> {task.episodeIndex + 1} </span>
<span></span>
<span>
{getDownloadModeLabel(task.downloadMode)}
</span>
</div>
</div>
</div>
))}
</div>
)}
</div>
);
})}
</div>
)}
</div>
</div>
</div>,
document.body
)}
<ConfirmDialog
isOpen={showConfirmDialog}