1654 lines
64 KiB
Vue
1654 lines
64 KiB
Vue
<script setup lang="ts">
|
||
import {
|
||
Download, Play, Pause, Plus, RefreshCw, Trash2,
|
||
Check, AlertCircle, Settings as SettingsIcon, ListChecks,
|
||
Link2, Loader2, FolderOpen, Copy, Puzzle,
|
||
CheckCircle2, Clock, Zap, Eye, EyeOff, Search,
|
||
ArrowUp, ArrowDown, ChevronLeft, ChevronRight, ExternalLink, Globe,
|
||
Info
|
||
} from '@lucide/vue'
|
||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||
import { toast } from 'vue-sonner'
|
||
import { open as openDialog } from '@tauri-apps/plugin-dialog'
|
||
// Rust 端通过 tauri-specta 生成的命令绑定(bindings.ts)
|
||
import { commands } from '@/lib/bindings'
|
||
import { useDownloaderStore, type DownloadTask, type TaskStatus, type CheckUrlResult } from '@/stores/downloaderStore'
|
||
import { useModuleTabs } from '@/lib/use-module-tabs'
|
||
import { useModuleTabsStore } from '@/stores/moduleTabsStore'
|
||
import { createLogger } from '@/lib/logger'
|
||
import { pendingNewDownload } from '@/lib/trayEvents'
|
||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||
import { Button } from '@/components/ui/button'
|
||
import { Badge } from '@/components/ui/badge'
|
||
import { Switch } from '@/components/ui/switch'
|
||
import { Input } from '@/components/ui/input'
|
||
import { Textarea } from '@/components/ui/textarea'
|
||
import { Label } from '@/components/ui/label'
|
||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
|
||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||
import { Separator } from '@/components/ui/separator'
|
||
import { Progress } from '@/components/ui/progress'
|
||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||
import {
|
||
AlertDialog, AlertDialogAction, AlertDialogCancel,
|
||
AlertDialogContent, AlertDialogDescription, AlertDialogFooter,
|
||
AlertDialogHeader, AlertDialogTitle
|
||
} from '@/components/ui/alert-dialog'
|
||
import {
|
||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, DialogClose
|
||
} from '@/components/ui/dialog'
|
||
|
||
const store = useDownloaderStore()
|
||
const logger = createLogger('downloader')
|
||
|
||
// ===== 主 Tab 状态 =====
|
||
const activeTab = ref('tasks')
|
||
const tabsStore = useModuleTabsStore()
|
||
const tabsListRef = useModuleTabs(activeTab, [
|
||
{ value: 'tasks', label: '下载任务' },
|
||
{ value: 'settings', label: '设置' },
|
||
{ value: 'extension', label: '浏览器扩展' }
|
||
])
|
||
|
||
// 添加下载表单
|
||
const addUriText = ref('')
|
||
const addDir = ref('')
|
||
const addingTask = ref(false)
|
||
const addDialogOpen = ref(false)
|
||
|
||
// 扩展密钥显示
|
||
const showSecret = ref(false)
|
||
|
||
// 下载设置快捷弹窗(任务页工具栏入口)
|
||
const downloadSettingsOpen = ref(false)
|
||
|
||
// ===== 任务列表:筛选 / 排序 / 搜索 / 分页 =====
|
||
type StatusFilter = 'all' | TaskStatus
|
||
type SortField = 'id' | 'name' | 'size'
|
||
type SortOrder = 'asc' | 'desc'
|
||
|
||
const statusFilter = ref<StatusFilter>('all')
|
||
const sortField = ref<SortField>('id')
|
||
const sortOrder = ref<SortOrder>('desc')
|
||
const searchText = ref('')
|
||
const PAGE_SIZE = 20
|
||
const currentPage = ref(1)
|
||
|
||
// 状态筛选选项
|
||
const STATUS_OPTIONS: { label: string; value: StatusFilter }[] = [
|
||
{ label: '全部状态', value: 'all' },
|
||
{ label: '下载中', value: 'active' },
|
||
{ label: '等待中', value: 'queued' },
|
||
{ label: '已暂停', value: 'paused' },
|
||
{ label: '已完成', value: 'complete' },
|
||
{ label: '错误', value: 'error' }
|
||
]
|
||
|
||
// 排序字段选项
|
||
const SORT_OPTIONS: { label: string; value: SortField }[] = [
|
||
{ label: '创建时间', value: 'id' },
|
||
{ label: '文件名', value: 'name' },
|
||
{ label: '下载大小', value: 'size' }
|
||
]
|
||
|
||
const running = computed(() => store.status.running)
|
||
|
||
// ===== 工具函数 =====
|
||
const formatSize = (bytes: number): string => {
|
||
if (!bytes || isNaN(bytes)) return '0 B'
|
||
if (bytes < 1024) return `${bytes} B`
|
||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(2)} MB`
|
||
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`
|
||
}
|
||
|
||
const formatSpeed = (bytesPerSec: number): string => {
|
||
if (!bytesPerSec || isNaN(bytesPerSec)) return '0 B/s'
|
||
return `${formatSize(bytesPerSec)}/s`
|
||
}
|
||
|
||
/** 格式化剩余时间(秒)为易读形式 */
|
||
const formatEta = (seconds: number): string => {
|
||
if (!isFinite(seconds) || seconds <= 0) return ''
|
||
if (seconds < 60) return `剩余 ${Math.ceil(seconds)} 秒`
|
||
if (seconds < 3600) {
|
||
const m = Math.floor(seconds / 60)
|
||
const s = Math.ceil(seconds % 60)
|
||
return `剩余 ${m} 分 ${s} 秒`
|
||
}
|
||
const h = Math.floor(seconds / 3600)
|
||
const m = Math.ceil((seconds % 3600) / 60)
|
||
return `剩余 ${h} 小时 ${m} 分`
|
||
}
|
||
|
||
/** 计算任务剩余时间(秒) */
|
||
const getEta = (task: DownloadTask): number => {
|
||
if (task.speed <= 0) return 0
|
||
return (task.totalSize - task.completedSize) / task.speed
|
||
}
|
||
|
||
const getProgress = (task: DownloadTask): number => {
|
||
if (!task.totalSize || task.totalSize === 0) return 0
|
||
return Math.min(100, Math.round((task.completedSize / task.totalSize) * 100))
|
||
}
|
||
|
||
const getFileName = (task: DownloadTask): string => {
|
||
return task.filename || task.id
|
||
}
|
||
|
||
const getTaskStatusBadge = (task: DownloadTask) => {
|
||
switch (task.status) {
|
||
case 'active':
|
||
return { variant: 'default' as const, text: '下载中', icon: Download }
|
||
case 'queued':
|
||
return { variant: 'secondary' as const, text: '等待中', icon: Clock }
|
||
case 'paused':
|
||
return { variant: 'secondary' as const, text: '已暂停', icon: Pause }
|
||
case 'complete':
|
||
return { variant: 'default' as const, text: '已完成', icon: CheckCircle2 }
|
||
case 'error':
|
||
return { variant: 'destructive' as const, text: '错误', icon: AlertCircle }
|
||
default:
|
||
return { variant: 'outline' as const, text: task.status, icon: AlertCircle }
|
||
}
|
||
}
|
||
|
||
// 简化路径显示:只保留最后 3 段
|
||
const truncatePath = (p: string): string => {
|
||
if (!p) return ''
|
||
const parts = p.replace(/\\/g, '/').split('/').filter(Boolean)
|
||
if (parts.length <= 3) return p
|
||
return '.../' + parts.slice(-3).join('/')
|
||
}
|
||
|
||
// ===== 任务操作 =====
|
||
const handlePause = async (id: string) => {
|
||
try {
|
||
await store.pauseTask(id)
|
||
toast.success('已暂停任务')
|
||
} catch (e) {
|
||
toast.error('暂停失败: ' + e)
|
||
}
|
||
}
|
||
|
||
const handleResume = async (id: string) => {
|
||
try {
|
||
await store.resumeTask(id)
|
||
toast.success('已继续任务')
|
||
} catch (e) {
|
||
toast.error('继续失败: ' + e)
|
||
}
|
||
}
|
||
|
||
// ===== 删除任务专用对话框(带"同时删除文件"开关) =====
|
||
const removeDialogState = ref<{
|
||
open: boolean
|
||
task: DownloadTask | null
|
||
deleteFiles: boolean
|
||
}>({ open: false, task: null, deleteFiles: false })
|
||
|
||
const handleRemove = (task: DownloadTask) => {
|
||
// 从设置中读取默认值
|
||
removeDialogState.value = {
|
||
open: true,
|
||
task,
|
||
deleteFiles: store.settings?.deleteFilesOnRemove ?? false
|
||
}
|
||
}
|
||
|
||
const onRemoveConfirm = async () => {
|
||
const { task, deleteFiles } = removeDialogState.value
|
||
if (!task) return
|
||
removeDialogState.value.open = false
|
||
try {
|
||
await store.removeTask(task.id, deleteFiles)
|
||
toast.success(deleteFiles ? '已删除任务和文件' : '已删除任务')
|
||
} catch (e) {
|
||
toast.error('删除失败: ' + e)
|
||
}
|
||
}
|
||
|
||
const onRemoveCancel = () => {
|
||
removeDialogState.value.open = false
|
||
}
|
||
|
||
const onRemoveOpenChange = (open: boolean) => {
|
||
if (!open) onRemoveCancel()
|
||
removeDialogState.value.open = open
|
||
}
|
||
|
||
// ===== 任务详情弹窗 =====
|
||
// 仅存任务 id,通过 computed 实时从 store.tasks 取最新对象,
|
||
// 保证弹窗内的进度/速度/状态随下载进度事件实时刷新
|
||
const detailDialogState = ref<{ open: boolean; taskId: string | null }>({
|
||
open: false,
|
||
taskId: null
|
||
})
|
||
|
||
const detailTask = computed<DownloadTask | null>(() => {
|
||
const id = detailDialogState.value.taskId
|
||
if (!id) return null
|
||
return store.tasks.find((t) => t.id === id) ?? null
|
||
})
|
||
|
||
const handleShowDetail = (task: DownloadTask) => {
|
||
detailDialogState.value = { open: true, taskId: task.id }
|
||
}
|
||
|
||
const handleCopyText = async (text: string, label: string) => {
|
||
try {
|
||
await navigator.clipboard.writeText(text)
|
||
toast.success(`${label} 已复制`)
|
||
} catch {
|
||
toast.error('复制失败')
|
||
}
|
||
}
|
||
|
||
// 详情弹窗中的格式化时间
|
||
const formatTime = (ts: number): string => {
|
||
if (!ts) return '-'
|
||
return new Date(ts).toLocaleString('zh-CN')
|
||
}
|
||
|
||
// 分段详情
|
||
const segmentProgress = (seg: { completed: number; start: number; end: number }): number => {
|
||
const len = seg.end - seg.start + 1
|
||
if (len <= 0) return 0
|
||
return Math.min(100, Math.round((seg.completed / len) * 100))
|
||
}
|
||
|
||
const handleOpenDir = async (task: DownloadTask) => {
|
||
try {
|
||
if (task.dir) {
|
||
await store.openDir(task.dir)
|
||
}
|
||
} catch (e) {
|
||
toast.error('打开目录失败: ' + e)
|
||
}
|
||
}
|
||
|
||
// ===== 添加下载 =====
|
||
/** 重复确认对话框状态 */
|
||
const duplicateDialogState = ref<{
|
||
open: boolean
|
||
url: string
|
||
result: CheckUrlResult | null
|
||
pendingUrls: string[]
|
||
currentIndex: number
|
||
}>({ open: false, url: '', result: null, pendingUrls: [], currentIndex: 0 })
|
||
|
||
const handleAddDownload = async () => {
|
||
const text = addUriText.value.trim()
|
||
if (!text) {
|
||
toast.error('请输入下载链接')
|
||
return
|
||
}
|
||
const uris = text.split('\n').map((u) => u.trim()).filter(Boolean)
|
||
if (uris.length === 0) {
|
||
toast.error('请输入有效的下载链接')
|
||
return
|
||
}
|
||
|
||
addingTask.value = true
|
||
try {
|
||
const dir = addDir.value.trim() || undefined
|
||
// 检查是否启用重复检查
|
||
const checkEnabled = store.settings?.checkDuplicate ?? true
|
||
if (checkEnabled) {
|
||
// 逐个检查重复
|
||
await processUrlsWithCheck(uris, dir)
|
||
} else {
|
||
// 直接添加
|
||
let successCount = 0
|
||
for (const uri of uris) {
|
||
try {
|
||
await store.addTask(uri, undefined, dir, undefined, true)
|
||
successCount++
|
||
} catch (e) {
|
||
logger.error(`添加 ${uri} 失败: ` + e)
|
||
}
|
||
}
|
||
finishAdd(successCount)
|
||
}
|
||
} catch (e) {
|
||
toast.error('添加失败: ' + e)
|
||
} finally {
|
||
addingTask.value = false
|
||
}
|
||
}
|
||
|
||
/** 逐个检查 URL 重复性,发现重复时弹出确认对话框 */
|
||
async function processUrlsWithCheck(urls: string[], dir: string | undefined) {
|
||
let successCount = 0
|
||
for (let i = 0; i < urls.length; i++) {
|
||
const uri = urls[i]
|
||
try {
|
||
const result = await store.checkUrl(uri, dir)
|
||
if (!result.ok) {
|
||
// 探测失败:直接添加(后端会标记为 error)
|
||
try {
|
||
await store.addTask(uri, undefined, dir, undefined, true)
|
||
successCount++
|
||
} catch (e) {
|
||
logger.error(`添加 ${uri} 失败: ` + e)
|
||
}
|
||
continue
|
||
}
|
||
if (result.duplicate === 'none') {
|
||
// 无重复:直接添加(自动重命名以防磁盘文件冲突)
|
||
try {
|
||
await store.addTask(uri, undefined, dir, undefined, true)
|
||
successCount++
|
||
} catch (e) {
|
||
logger.error(`添加 ${uri} 失败: ` + e)
|
||
}
|
||
continue
|
||
}
|
||
// 发现重复:弹出确认对话框,暂停处理
|
||
duplicateDialogState.value = {
|
||
open: true,
|
||
url: uri,
|
||
result,
|
||
pendingUrls: urls,
|
||
currentIndex: i,
|
||
}
|
||
return // 等待用户确认后继续
|
||
} catch (e) {
|
||
logger.error(`检查 ${uri} 失败: ` + e)
|
||
// 检查失败:直接添加
|
||
try {
|
||
await store.addTask(uri, undefined, dir, undefined, true)
|
||
successCount++
|
||
} catch (e2) {
|
||
logger.error(`添加 ${uri} 失败: ` + e2)
|
||
}
|
||
}
|
||
}
|
||
finishAdd(successCount)
|
||
}
|
||
|
||
/** 完成添加:显示提示并关闭对话框 */
|
||
function finishAdd(successCount: number) {
|
||
if (successCount > 0) {
|
||
toast.success(`已添加 ${successCount} 个下载任务`)
|
||
addUriText.value = ''
|
||
addDir.value = ''
|
||
addDialogOpen.value = false
|
||
activeTab.value = 'tasks'
|
||
} else {
|
||
toast.error('添加任务失败')
|
||
}
|
||
}
|
||
|
||
/** 重复确认对话框:用户选择"仍然下载"(自动重命名) */
|
||
const onDuplicateConfirm = async () => {
|
||
const { url, pendingUrls, currentIndex } = duplicateDialogState.value
|
||
const dir = addDir.value.trim() || undefined
|
||
duplicateDialogState.value.open = false
|
||
|
||
let successCount = 0
|
||
try {
|
||
await store.addTask(url, undefined, dir, undefined, true) // autoRename=true
|
||
successCount++
|
||
} catch (e) {
|
||
logger.error(`添加 ${url} 失败: ` + e)
|
||
}
|
||
|
||
// 继续处理剩余 URL
|
||
const remaining = pendingUrls.slice(currentIndex + 1)
|
||
for (const uri of remaining) {
|
||
try {
|
||
const result = await store.checkUrl(uri, dir)
|
||
if (result.ok && result.duplicate !== 'none') {
|
||
// 又发现重复,再次弹出确认
|
||
duplicateDialogState.value = {
|
||
open: true,
|
||
url: uri,
|
||
result,
|
||
pendingUrls,
|
||
currentIndex: pendingUrls.indexOf(uri),
|
||
}
|
||
// 已添加的成功数通过闭包传递不太方便,直接在这里 finish 后再继续
|
||
// 简化处理:保存 successCount 到 state,下一次确认时累加
|
||
duplicateSuccessCount.value = successCount
|
||
return
|
||
}
|
||
await store.addTask(uri, undefined, dir, undefined, true)
|
||
successCount++
|
||
} catch (e) {
|
||
logger.error(`添加 ${uri} 失败: ` + e)
|
||
}
|
||
}
|
||
finishAdd(successCount)
|
||
}
|
||
|
||
/** 累积的成功计数(跨多次重复确认) */
|
||
const duplicateSuccessCount = ref(0)
|
||
|
||
/** 重复确认对话框:用户选择"跳过" */
|
||
const onDuplicateSkip = () => {
|
||
const { pendingUrls, currentIndex } = duplicateDialogState.value
|
||
duplicateDialogState.value.open = false
|
||
const remaining = pendingUrls.slice(currentIndex + 1)
|
||
if (remaining.length > 0) {
|
||
// 继续处理剩余 URL(不添加当前这个)
|
||
const dir = addDir.value.trim() || undefined
|
||
processUrlsWithCheck(remaining, dir).then(() => {})
|
||
} else {
|
||
finishAdd(duplicateSuccessCount.value)
|
||
duplicateSuccessCount.value = 0
|
||
}
|
||
}
|
||
|
||
/** 重复确认对话框:用户取消整个添加流程 */
|
||
const onDuplicateCancel = () => {
|
||
duplicateDialogState.value.open = false
|
||
finishAdd(duplicateSuccessCount.value)
|
||
duplicateSuccessCount.value = 0
|
||
}
|
||
|
||
/** 重复类型文本 */
|
||
const duplicateKindText = (kind: string): string => {
|
||
switch (kind) {
|
||
case 'url': return 'URL 已存在下载任务'
|
||
case 'filename': return '同名文件已在下载列表中'
|
||
case 'fileExists': return '目标目录已存在同名文件'
|
||
default: return '重复'
|
||
}
|
||
}
|
||
|
||
const handleSelectDir = async () => {
|
||
try {
|
||
const selected = await openDialog({ directory: true, multiple: false })
|
||
if (typeof selected === 'string') {
|
||
addDir.value = selected
|
||
}
|
||
} catch (e) {
|
||
logger.error('选择目录失败: ' + e)
|
||
}
|
||
}
|
||
|
||
const handleSelectSettingsDir = async () => {
|
||
try {
|
||
const selected = await openDialog({ directory: true, multiple: false })
|
||
if (typeof selected === 'string' && store.settings) {
|
||
await store.saveSettings({ ...store.settings, downloadDir: selected })
|
||
toast.success('下载目录已更新')
|
||
}
|
||
} catch (e) {
|
||
toast.error('选择目录失败: ' + e)
|
||
}
|
||
}
|
||
|
||
// ===== 设置保存 =====
|
||
const handleSaveSettings = async (): Promise<boolean> => {
|
||
if (!store.settings) return false
|
||
try {
|
||
await store.saveSettings(store.settings)
|
||
toast.success('设置已保存')
|
||
return true
|
||
} catch (e) {
|
||
toast.error('保存失败: ' + e)
|
||
return false
|
||
}
|
||
}
|
||
|
||
// 注册保存处理函数到标签栏 store(TitleBar 保存按钮调用)
|
||
tabsStore.registerSave(handleSaveSettings)
|
||
|
||
// Dialog 保存:成功后关闭弹窗
|
||
const handleDialogSave = async () => {
|
||
const ok = await handleSaveSettings()
|
||
if (ok) downloadSettingsOpen.value = false
|
||
}
|
||
|
||
// ===== 扩展 =====
|
||
const EXTENSION_STORE_URL = 'https://chromewebstore.google.com/'
|
||
const handleInstallExtensionOnline = async () => {
|
||
try {
|
||
await commands.downloaderOpenUrl(EXTENSION_STORE_URL)
|
||
} catch (e) {
|
||
try {
|
||
await navigator.clipboard.writeText(EXTENSION_STORE_URL)
|
||
toast.info('已复制扩展商店地址(请在浏览器打开)', { description: EXTENSION_STORE_URL })
|
||
} catch {
|
||
toast.error('打开链接失败: ' + e)
|
||
}
|
||
}
|
||
}
|
||
|
||
const handleCopy = async (text: string, label: string) => {
|
||
try {
|
||
await navigator.clipboard.writeText(text)
|
||
toast.success(`${label} 已复制`)
|
||
} catch (e) {
|
||
toast.error('复制失败: ' + e)
|
||
}
|
||
}
|
||
|
||
// ===== 生命周期 =====
|
||
|
||
onMounted(async () => {
|
||
await store.init()
|
||
// 消费托盘菜单"新建下载"标志位
|
||
if (pendingNewDownload.value) {
|
||
pendingNewDownload.value = false
|
||
addDialogOpen.value = true
|
||
}
|
||
})
|
||
|
||
onUnmounted(() => {
|
||
store.stopEventListeners()
|
||
})
|
||
|
||
// ===== 任务列表计算:筛选 → 搜索 → 排序 → 分页 =====
|
||
|
||
const allTasks = computed<DownloadTask[]>(() => store.tasks)
|
||
|
||
// 状态栏计数:单次遍历统计各状态任务数(替代模板内 4 次 filter 全量扫描)
|
||
const statusCounts = computed(() => {
|
||
const counts: Record<TaskStatus, number> = { queued: 0, active: 0, paused: 0, complete: 0, error: 0 }
|
||
for (const t of allTasks.value) counts[t.status]++
|
||
return counts
|
||
})
|
||
|
||
// 状态筛选
|
||
const filteredByStatus = computed<DownloadTask[]>(() => {
|
||
if (statusFilter.value === 'all') return allTasks.value
|
||
return allTasks.value.filter((t) => t.status === statusFilter.value)
|
||
})
|
||
|
||
// 名字搜索(大小写不敏感)
|
||
const filteredBySearch = computed<DownloadTask[]>(() => {
|
||
const kw = searchText.value.trim().toLowerCase()
|
||
if (!kw) return filteredByStatus.value
|
||
return filteredByStatus.value.filter((t) => {
|
||
return getFileName(t).toLowerCase().includes(kw)
|
||
})
|
||
})
|
||
|
||
// 排序
|
||
const sortedTasks = computed<DownloadTask[]>(() => {
|
||
const list = [...filteredBySearch.value]
|
||
const dir = sortOrder.value === 'asc' ? 1 : -1
|
||
list.sort((a, b) => {
|
||
let cmp = 0
|
||
if (sortField.value === 'id') {
|
||
cmp = a.id.localeCompare(b.id)
|
||
} else if (sortField.value === 'name') {
|
||
cmp = getFileName(a).localeCompare(getFileName(b))
|
||
} else if (sortField.value === 'size') {
|
||
cmp = a.totalSize - b.totalSize
|
||
}
|
||
return cmp * dir
|
||
})
|
||
return list
|
||
})
|
||
|
||
// 分页
|
||
const totalPages = computed(() => Math.max(1, Math.ceil(sortedTasks.value.length / PAGE_SIZE)))
|
||
|
||
const pagedTasks = computed<DownloadTask[]>(() => {
|
||
const start = (currentPage.value - 1) * PAGE_SIZE
|
||
return sortedTasks.value.slice(start, start + PAGE_SIZE)
|
||
})
|
||
|
||
// 筛选/搜索变化时重置到第一页
|
||
watch([statusFilter, searchText], () => {
|
||
currentPage.value = 1
|
||
})
|
||
|
||
// 排序变化时若当前页超出范围则回到第一页
|
||
watch(totalPages, (tp) => {
|
||
if (currentPage.value > tp) currentPage.value = tp
|
||
})
|
||
|
||
// 切换升降序
|
||
const toggleSortOrder = () => {
|
||
sortOrder.value = sortOrder.value === 'asc' ? 'desc' : 'asc'
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div class="h-full p-6 overflow-hidden flex flex-col">
|
||
<Tabs v-model="activeTab" class="h-full flex flex-col">
|
||
<div ref="tabsListRef">
|
||
<TabsList class="grid w-full grid-cols-3 max-w-md !bg-transparent !p-0 !shadow-none">
|
||
<TabsTrigger value="tasks" class="gap-1.5">
|
||
<ListChecks class="h-4 w-4" />
|
||
下载任务
|
||
</TabsTrigger>
|
||
<TabsTrigger value="settings" class="gap-1.5">
|
||
<SettingsIcon class="h-4 w-4" />
|
||
设置
|
||
</TabsTrigger>
|
||
<TabsTrigger value="extension" class="gap-1.5">
|
||
<Puzzle class="h-4 w-4" />
|
||
浏览器扩展
|
||
</TabsTrigger>
|
||
</TabsList>
|
||
</div>
|
||
|
||
<!-- ===== 下载任务 ===== -->
|
||
<TabsContent value="tasks" class="flex-1 mt-4 min-h-0 tab-animate">
|
||
<div class="h-full flex flex-col gap-4">
|
||
<!-- 状态栏 -->
|
||
<Card class="shrink-0 !py-0 !gap-0">
|
||
<CardContent class="pl-4 pr-4 py-3">
|
||
<div class="flex items-center justify-between flex-wrap gap-3">
|
||
<div class="flex items-center gap-3">
|
||
<div class="flex items-center gap-2">
|
||
<div
|
||
class="h-2.5 w-2.5 rounded-full"
|
||
:class="running ? 'bg-green-500 animate-pulse' : 'bg-muted-foreground'"
|
||
/>
|
||
<span class="text-sm font-medium">
|
||
下载引擎 {{ running ? '运行中' : '已停止' }}
|
||
</span>
|
||
</div>
|
||
<Separator orientation="vertical" class="h-5" />
|
||
<div v-if="running" class="flex items-center gap-2 text-xs">
|
||
<Badge variant="secondary" class="gap-1">
|
||
<Download class="h-3 w-3" />
|
||
下载中 {{ statusCounts.active }}
|
||
</Badge>
|
||
<Badge variant="secondary" class="gap-1">
|
||
<Clock class="h-3 w-3" />
|
||
等待 {{ statusCounts.queued }}
|
||
</Badge>
|
||
<Badge v-if="statusCounts.paused > 0" variant="secondary" class="gap-1">
|
||
<Pause class="h-3 w-3" />
|
||
已暂停 {{ statusCounts.paused }}
|
||
</Badge>
|
||
<Badge variant="secondary" class="gap-1">
|
||
<Check class="h-3 w-3" />
|
||
已完成 {{ statusCounts.complete }}
|
||
</Badge>
|
||
</div>
|
||
</div>
|
||
<div class="flex items-center gap-2">
|
||
<Button
|
||
size="sm"
|
||
variant="ghost"
|
||
@click="store.refreshTasks()"
|
||
>
|
||
<RefreshCw class="h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<!-- 工具栏:筛选 / 排序 / 搜索 -->
|
||
<div class="shrink-0 flex items-center gap-2 flex-wrap">
|
||
<!-- 新建下载按钮 -->
|
||
<Tooltip>
|
||
<TooltipTrigger as-child>
|
||
<Button
|
||
size="sm"
|
||
class="h-8 gap-1.5"
|
||
:disabled="addingTask"
|
||
@click="addDialogOpen = true"
|
||
>
|
||
<Plus class="h-4 w-4" />
|
||
新建下载
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent>添加下载链接</TooltipContent>
|
||
</Tooltip>
|
||
|
||
<!-- 状态筛选 -->
|
||
<Select v-model="statusFilter">
|
||
<SelectTrigger size="sm" class="w-[130px]">
|
||
<SelectValue placeholder="状态" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem
|
||
v-for="opt in STATUS_OPTIONS"
|
||
:key="opt.value"
|
||
:value="opt.value"
|
||
>
|
||
{{ opt.label }}
|
||
</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
|
||
<!-- 排序字段 -->
|
||
<Select v-model="sortField">
|
||
<SelectTrigger size="sm" class="w-[120px]">
|
||
<SelectValue placeholder="排序" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem
|
||
v-for="opt in SORT_OPTIONS"
|
||
:key="opt.value"
|
||
:value="opt.value"
|
||
>
|
||
{{ opt.label }}
|
||
</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
|
||
<!-- 升降序切换 -->
|
||
<Tooltip>
|
||
<TooltipTrigger as-child>
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
class="h-8 w-8 p-0 bg-transparent"
|
||
@click="toggleSortOrder"
|
||
>
|
||
<ArrowUp v-if="sortOrder === 'asc'" class="h-4 w-4" />
|
||
<ArrowDown v-else class="h-4 w-4" />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent>{{ sortOrder === 'asc' ? '升序(点击切换降序)' : '降序(点击切换升序)' }}</TooltipContent>
|
||
</Tooltip>
|
||
|
||
<!-- 搜索框 -->
|
||
<div class="relative flex-1 min-w-[180px] max-w-xs">
|
||
<Search class="absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||
<Input
|
||
v-model="searchText"
|
||
placeholder="搜索文件名..."
|
||
class="h-8 pl-8 text-sm"
|
||
/>
|
||
</div>
|
||
|
||
<Tooltip>
|
||
<TooltipTrigger as-child>
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
class="h-8 gap-1.5 bg-transparent"
|
||
@click="downloadSettingsOpen = true"
|
||
>
|
||
<SettingsIcon class="h-4 w-4" />
|
||
下载设置
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent>下载设置</TooltipContent>
|
||
</Tooltip>
|
||
|
||
<span class="text-xs text-muted-foreground ml-auto">
|
||
共 {{ sortedTasks.length }} 个任务
|
||
</span>
|
||
</div>
|
||
|
||
<!-- 任务列表 -->
|
||
<ScrollArea class="flex-1 min-h-0">
|
||
<div v-if="pagedTasks.length === 0" key="empty" class="h-full flex flex-col items-center justify-center text-muted-foreground gap-2 pt-10">
|
||
<CheckCircle2 class="h-16 w-16 opacity-30" />
|
||
<p>暂无符合条件的任务</p>
|
||
<Button size="sm" class="mt-2" @click="addDialogOpen = true">
|
||
<Plus class="h-4 w-4" />
|
||
新建下载
|
||
</Button>
|
||
</div>
|
||
<div v-else key="task-list" class="flex flex-col gap-3 pb-4">
|
||
<Card v-for="task in pagedTasks" :key="task.id" class="overflow-hidden !py-0 !gap-0">
|
||
<CardContent class="pl-4 pr-4 py-3">
|
||
<div class="flex items-start justify-between gap-3 mb-3">
|
||
<div class="flex-1 min-w-0">
|
||
<div class="flex items-center gap-2 mb-1">
|
||
<Tooltip>
|
||
<TooltipTrigger as-child>
|
||
<span class="font-medium truncate cursor-default">
|
||
{{ getFileName(task) }}
|
||
</span>
|
||
</TooltipTrigger>
|
||
<TooltipContent>{{ getFileName(task) }}</TooltipContent>
|
||
</Tooltip>
|
||
<Badge
|
||
:variant="getTaskStatusBadge(task).variant"
|
||
class="shrink-0"
|
||
>
|
||
<component :is="getTaskStatusBadge(task).icon" class="h-3 w-3" />
|
||
{{ getTaskStatusBadge(task).text }}
|
||
</Badge>
|
||
</div>
|
||
<div class="text-xs text-muted-foreground flex items-center gap-3 flex-wrap">
|
||
<span>
|
||
{{ formatSize(task.completedSize) }} / {{ formatSize(task.totalSize) }}
|
||
</span>
|
||
<span v-if="task.status === 'active'" class="flex items-center gap-1 text-green-600 dark:text-green-400">
|
||
<Download class="h-3 w-3" />
|
||
{{ formatSpeed(task.speed) }}
|
||
</span>
|
||
<Badge v-if="task.status === 'active' && task.segments.length > 1" variant="outline" class="gap-1 py-0 px-1.5 text-[10px]">
|
||
<Zap class="h-2.5 w-2.5" />
|
||
{{ task.segments.length }} 连接
|
||
</Badge>
|
||
<Tooltip v-if="task.dir">
|
||
<TooltipTrigger as-child>
|
||
<span class="truncate max-w-md cursor-default">
|
||
{{ truncatePath(task.dir) }}
|
||
</span>
|
||
</TooltipTrigger>
|
||
<TooltipContent class="max-w-[400px] break-all">{{ task.dir }}</TooltipContent>
|
||
</Tooltip>
|
||
<span v-if="task.error" class="text-destructive">
|
||
{{ task.error }}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div class="flex items-center gap-1 shrink-0">
|
||
<Tooltip v-if="task.status === 'active' || task.status === 'queued'">
|
||
<TooltipTrigger as-child>
|
||
<Button
|
||
size="icon"
|
||
variant="ghost"
|
||
class="h-8 w-8"
|
||
@click="handlePause(task.id)"
|
||
>
|
||
<Pause class="h-4 w-4" />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent>{{ task.status === 'queued' ? '取消排队' : '暂停' }}</TooltipContent>
|
||
</Tooltip>
|
||
<Tooltip v-if="task.status === 'paused' || task.status === 'error'">
|
||
<TooltipTrigger as-child>
|
||
<Button
|
||
size="icon"
|
||
variant="ghost"
|
||
class="h-8 w-8"
|
||
@click="handleResume(task.id)"
|
||
>
|
||
<Play class="h-4 w-4" />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent>继续</TooltipContent>
|
||
</Tooltip>
|
||
<Tooltip>
|
||
<TooltipTrigger as-child>
|
||
<Button
|
||
size="icon"
|
||
variant="ghost"
|
||
class="h-8 w-8"
|
||
@click="handleShowDetail(task)"
|
||
>
|
||
<Info class="h-4 w-4" />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent>详细信息</TooltipContent>
|
||
</Tooltip>
|
||
<Tooltip v-if="task.dir">
|
||
<TooltipTrigger as-child>
|
||
<Button
|
||
size="icon"
|
||
variant="ghost"
|
||
class="h-8 w-8"
|
||
@click="handleOpenDir(task)"
|
||
>
|
||
<FolderOpen class="h-4 w-4" />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent>打开目录</TooltipContent>
|
||
</Tooltip>
|
||
<Tooltip>
|
||
<TooltipTrigger as-child>
|
||
<Button
|
||
size="icon"
|
||
variant="ghost"
|
||
class="h-8 w-8 text-destructive hover:text-destructive"
|
||
@click="handleRemove(task)"
|
||
>
|
||
<Trash2 class="h-4 w-4" />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent>删除</TooltipContent>
|
||
</Tooltip>
|
||
</div>
|
||
</div>
|
||
<Progress :model-value="getProgress(task)" class="h-1.5" />
|
||
<div class="flex justify-between mt-1 text-xs text-muted-foreground">
|
||
<span>{{ getProgress(task) }}%</span>
|
||
<span v-if="task.status === 'active' && task.speed > 0">
|
||
{{ formatEta(getEta(task)) }}
|
||
</span>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
|
||
<!-- 分页控件 -->
|
||
<div v-if="totalPages > 1" class="flex items-center justify-center gap-2 pt-2 pb-4">
|
||
<Tooltip>
|
||
<TooltipTrigger as-child>
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
class="h-8 w-8 p-0"
|
||
:disabled="currentPage <= 1"
|
||
@click="currentPage = 1"
|
||
>
|
||
<ChevronLeft class="h-4 w-4" />
|
||
<ChevronLeft class="h-4 w-4 -ml-2" />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent>第一页</TooltipContent>
|
||
</Tooltip>
|
||
<Tooltip>
|
||
<TooltipTrigger as-child>
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
class="h-8 w-8 p-0"
|
||
:disabled="currentPage <= 1"
|
||
@click="currentPage--"
|
||
>
|
||
<ChevronLeft class="h-4 w-4" />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent>上一页</TooltipContent>
|
||
</Tooltip>
|
||
<span class="text-xs text-muted-foreground px-2">
|
||
{{ currentPage }} / {{ totalPages }}
|
||
</span>
|
||
<Tooltip>
|
||
<TooltipTrigger as-child>
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
class="h-8 w-8 p-0"
|
||
:disabled="currentPage >= totalPages"
|
||
@click="currentPage++"
|
||
>
|
||
<ChevronRight class="h-4 w-4" />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent>下一页</TooltipContent>
|
||
</Tooltip>
|
||
<Tooltip>
|
||
<TooltipTrigger as-child>
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
class="h-8 w-8 p-0"
|
||
:disabled="currentPage >= totalPages"
|
||
@click="currentPage = totalPages"
|
||
>
|
||
<ChevronRight class="h-4 w-4" />
|
||
<ChevronRight class="h-4 w-4 -ml-2" />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent>最后一页</TooltipContent>
|
||
</Tooltip>
|
||
</div>
|
||
</ScrollArea>
|
||
</div>
|
||
</TabsContent>
|
||
|
||
<!-- ===== 设置 ===== -->
|
||
<TabsContent value="settings" class="flex-1 mt-4 min-h-0 tab-animate">
|
||
<ScrollArea class="h-full pr-3">
|
||
<div class="flex flex-col gap-4 pb-4 max-w-2xl">
|
||
<!-- 下载设置 -->
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle class="flex items-center gap-2 text-base">
|
||
<FolderOpen class="h-4 w-4 text-primary" />
|
||
下载设置
|
||
</CardTitle>
|
||
</CardHeader>
|
||
<CardContent class="flex flex-col gap-3">
|
||
<div class="flex flex-col gap-1.5">
|
||
<Label class="text-xs">下载目录</Label>
|
||
<div class="flex gap-2">
|
||
<Input
|
||
:model-value="store.settings?.downloadDir ?? ''"
|
||
class="font-mono text-sm"
|
||
@update:model-value="(v: string | number) => store.settings && (store.settings.downloadDir = String(v))"
|
||
/>
|
||
<Tooltip>
|
||
<TooltipTrigger as-child>
|
||
<Button variant="outline" size="icon" @click="handleSelectSettingsDir">
|
||
<FolderOpen class="h-4 w-4" />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent>选择目录</TooltipContent>
|
||
</Tooltip>
|
||
</div>
|
||
</div>
|
||
<div class="grid grid-cols-2 gap-3">
|
||
<div class="flex flex-col gap-1.5">
|
||
<Label class="text-xs">最大同时下载数</Label>
|
||
<Input
|
||
:model-value="store.settings?.maxConcurrent ?? 5"
|
||
type="number"
|
||
min="1"
|
||
max="20"
|
||
@update:model-value="(v: string | number) => store.settings && (store.settings.maxConcurrent = parseInt(String(v)) || 5)"
|
||
/>
|
||
</div>
|
||
<div class="flex flex-col gap-1.5">
|
||
<Label class="text-xs">单任务最大连接数</Label>
|
||
<Input
|
||
:model-value="store.settings?.maxConnections ?? 8"
|
||
type="number"
|
||
min="1"
|
||
max="64"
|
||
@update:model-value="(v: string | number) => store.settings && (store.settings.maxConnections = parseInt(String(v)) || 8)"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div class="flex flex-col gap-1.5">
|
||
<Label class="text-xs">全局速度限制 KB/s(0=不限)</Label>
|
||
<Input
|
||
:model-value="store.settings?.globalSpeedLimit ?? 0"
|
||
type="number"
|
||
min="0"
|
||
@update:model-value="(v: string | number) => store.settings && (store.settings.globalSpeedLimit = parseInt(String(v)) || 0)"
|
||
/>
|
||
</div>
|
||
<div class="flex items-center justify-between">
|
||
<Label for="continue-dl" class="cursor-pointer">断点续传</Label>
|
||
<Switch
|
||
id="continue-dl"
|
||
:model-value="store.settings?.continueDownload ?? true"
|
||
@update:model-value="(v: boolean) => store.settings && (store.settings.continueDownload = v)"
|
||
/>
|
||
</div>
|
||
<div class="flex items-center justify-between">
|
||
<div class="flex flex-col gap-0.5">
|
||
<Label for="delete-files-on-remove" class="cursor-pointer">删除任务时删除文件</Label>
|
||
<span class="text-xs text-muted-foreground">开启后,删除任务时将同时删除已下载的文件</span>
|
||
</div>
|
||
<Switch
|
||
id="delete-files-on-remove"
|
||
:model-value="store.settings?.deleteFilesOnRemove ?? false"
|
||
@update:model-value="(v: boolean) => store.settings && (store.settings.deleteFilesOnRemove = v)"
|
||
/>
|
||
</div>
|
||
<div class="flex items-center justify-between">
|
||
<div class="flex flex-col gap-0.5">
|
||
<Label for="check-duplicate" class="cursor-pointer">重复下载检查</Label>
|
||
<span class="text-xs text-muted-foreground">添加下载前检查 URL 和文件名重复,发现重复时询问</span>
|
||
</div>
|
||
<Switch
|
||
id="check-duplicate"
|
||
:model-value="store.settings?.checkDuplicate ?? true"
|
||
@update:model-value="(v: boolean) => store.settings && (store.settings.checkDuplicate = v)"
|
||
/>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<!-- 扩展 API 设置 -->
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle class="flex items-center gap-2 text-base">
|
||
<Zap class="h-4 w-4 text-primary" />
|
||
扩展 API
|
||
</CardTitle>
|
||
</CardHeader>
|
||
<CardContent class="flex flex-col gap-3">
|
||
<div class="grid grid-cols-2 gap-3">
|
||
<div class="flex flex-col gap-1.5">
|
||
<Label class="text-xs">API 端口</Label>
|
||
<Input
|
||
:model-value="store.settings?.extensionPort ?? 16800"
|
||
type="number"
|
||
min="1024"
|
||
max="65535"
|
||
@update:model-value="(v: string | number) => store.settings && (store.settings.extensionPort = parseInt(String(v)) || 16800)"
|
||
/>
|
||
</div>
|
||
<div class="flex flex-col gap-1.5">
|
||
<Label class="text-xs">认证密钥(可空)</Label>
|
||
<Input
|
||
:model-value="store.settings?.extensionSecret ?? ''"
|
||
type="password"
|
||
placeholder="留空不启用鉴权"
|
||
@update:model-value="(v: string | number) => store.settings && (store.settings.extensionSecret = String(v))"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<p class="text-xs text-muted-foreground">
|
||
修改端口后需重启应用生效。密钥为空时仅允许本机访问。
|
||
</p>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
</ScrollArea>
|
||
</TabsContent>
|
||
|
||
<!-- ===== 浏览器扩展 ===== -->
|
||
<TabsContent value="extension" class="flex-1 mt-4 min-h-0 tab-animate">
|
||
<ScrollArea class="h-full pr-3">
|
||
<div class="flex flex-col gap-4 pb-4 max-w-2xl">
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle class="flex items-center gap-2 text-base">
|
||
<Puzzle class="h-4 w-4 text-primary" />
|
||
Thing Extension
|
||
</CardTitle>
|
||
</CardHeader>
|
||
<CardContent class="flex flex-col gap-4">
|
||
<p class="text-sm text-muted-foreground">
|
||
Thing Extension 是一个浏览器扩展,可接管浏览器下载,将下载任务发送到内置下载引擎。
|
||
支持 Chrome、Edge 等基于 Chromium 的浏览器。
|
||
</p>
|
||
|
||
<div v-if="store.extensionInfo" class="flex flex-col gap-3">
|
||
<Separator />
|
||
<div class="flex items-center justify-between gap-2">
|
||
<div class="flex flex-col gap-0.5 min-w-0">
|
||
<Label class="text-xs text-muted-foreground">API 地址</Label>
|
||
<Tooltip>
|
||
<TooltipTrigger as-child>
|
||
<span class="font-mono text-sm truncate cursor-default">
|
||
{{ store.extensionInfo.url }}
|
||
</span>
|
||
</TooltipTrigger>
|
||
<TooltipContent class="max-w-[400px] break-all">{{ store.extensionInfo.url }}</TooltipContent>
|
||
</Tooltip>
|
||
</div>
|
||
<Tooltip>
|
||
<TooltipTrigger as-child>
|
||
<Button
|
||
size="icon"
|
||
variant="ghost"
|
||
class="h-8 w-8 shrink-0"
|
||
@click="handleCopy(store.extensionInfo.url, 'API 地址')"
|
||
>
|
||
<Copy class="h-4 w-4" />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent>复制 API 地址</TooltipContent>
|
||
</Tooltip>
|
||
</div>
|
||
|
||
<div class="flex items-center justify-between gap-2">
|
||
<div class="flex flex-col gap-0.5 min-w-0 flex-1">
|
||
<Label class="text-xs text-muted-foreground">认证密钥</Label>
|
||
<span class="font-mono text-sm">
|
||
{{ store.extensionInfo.secret ? (showSecret ? store.extensionInfo.secret : '•'.repeat(store.extensionInfo.secret.length)) : '未设置(无认证)' }}
|
||
</span>
|
||
</div>
|
||
<div class="flex gap-1 shrink-0">
|
||
<Tooltip v-if="store.extensionInfo.secret">
|
||
<TooltipTrigger as-child>
|
||
<Button
|
||
size="icon"
|
||
variant="ghost"
|
||
class="h-8 w-8"
|
||
@click="showSecret = !showSecret"
|
||
>
|
||
<Eye v-if="!showSecret" class="h-4 w-4" />
|
||
<EyeOff v-else class="h-4 w-4" />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent>{{ showSecret ? '隐藏' : '显示' }}</TooltipContent>
|
||
</Tooltip>
|
||
<Tooltip v-if="store.extensionInfo.secret">
|
||
<TooltipTrigger as-child>
|
||
<Button
|
||
size="icon"
|
||
variant="ghost"
|
||
class="h-8 w-8"
|
||
@click="handleCopy(store.extensionInfo.secret, '密钥')"
|
||
>
|
||
<Copy class="h-4 w-4" />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent>复制密钥</TooltipContent>
|
||
</Tooltip>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<Separator />
|
||
|
||
<div class="flex flex-col gap-2">
|
||
<Label class="text-xs text-muted-foreground">安装扩展</Label>
|
||
<div class="flex flex-wrap items-center gap-2">
|
||
<Button variant="outline" @click="handleInstallExtensionOnline">
|
||
<ExternalLink class="h-4 w-4" />
|
||
在线安装
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="rounded-md bg-muted p-3 text-xs text-muted-foreground">
|
||
<p class="font-medium text-foreground mb-1.5 flex items-center gap-1.5">
|
||
<Globe class="h-3.5 w-3.5" />
|
||
配置说明
|
||
</p>
|
||
<ol class="list-decimal list-inside space-y-0.5">
|
||
<li>在扩展设置中填入 API 地址:<code class="font-mono">{{ store.extensionInfo?.url || 'http://127.0.0.1:16800/' }}</code></li>
|
||
<li>若设置了认证密钥,请在扩展中填入相同的密钥</li>
|
||
<li>浏览器访问 <code class="font-mono">chrome://extensions</code>(Edge 为 <code class="font-mono">edge://extensions</code>)</li>
|
||
<li>开启"开发者模式",加载已解压的扩展程序</li>
|
||
</ol>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
</ScrollArea>
|
||
</TabsContent>
|
||
</Tabs>
|
||
|
||
<!-- ===== 下载设置快捷弹窗 ===== -->
|
||
<Dialog v-model:open="downloadSettingsOpen">
|
||
<DialogContent class="max-w-lg">
|
||
<DialogHeader>
|
||
<DialogTitle class="flex items-center gap-2">
|
||
<SettingsIcon class="h-4 w-4 text-primary" />
|
||
下载设置
|
||
</DialogTitle>
|
||
<DialogDescription class="text-xs">
|
||
修改后点击保存即可生效。
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
|
||
<div v-if="store.settings" class="flex flex-col gap-3 py-2">
|
||
<!-- 下载目录 -->
|
||
<div class="flex flex-col gap-1.5">
|
||
<Label class="text-xs">下载目录</Label>
|
||
<div class="flex gap-2">
|
||
<Input
|
||
:model-value="store.settings.downloadDir"
|
||
class="font-mono text-sm"
|
||
@update:model-value="(v: string | number) => store.settings && (store.settings.downloadDir = String(v))"
|
||
/>
|
||
<Tooltip>
|
||
<TooltipTrigger as-child>
|
||
<Button variant="outline" size="icon" @click="handleSelectSettingsDir">
|
||
<FolderOpen class="h-4 w-4" />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent>选择目录</TooltipContent>
|
||
</Tooltip>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 并发 / 连接数 -->
|
||
<div class="grid grid-cols-2 gap-3">
|
||
<div class="flex flex-col gap-1.5">
|
||
<Label class="text-xs">最大同时下载数</Label>
|
||
<Input
|
||
:model-value="store.settings.maxConcurrent"
|
||
type="number"
|
||
min="1"
|
||
max="20"
|
||
@update:model-value="(v: string | number) => store.settings && (store.settings.maxConcurrent = parseInt(String(v)) || 5)"
|
||
/>
|
||
</div>
|
||
<div class="flex flex-col gap-1.5">
|
||
<Label class="text-xs">单任务最大连接数</Label>
|
||
<Input
|
||
:model-value="store.settings.maxConnections"
|
||
type="number"
|
||
min="1"
|
||
max="64"
|
||
@update:model-value="(v: string | number) => store.settings && (store.settings.maxConnections = parseInt(String(v)) || 8)"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 速度限制 -->
|
||
<div class="flex flex-col gap-1.5">
|
||
<Label class="text-xs">全局速度限制 KB/s(0=不限)</Label>
|
||
<Input
|
||
:model-value="store.settings.globalSpeedLimit"
|
||
type="number"
|
||
min="0"
|
||
@update:model-value="(v: string | number) => store.settings && (store.settings.globalSpeedLimit = parseInt(String(v)) || 0)"
|
||
/>
|
||
</div>
|
||
|
||
<!-- 断点续传 -->
|
||
<div class="flex items-center justify-between">
|
||
<Label for="dl-continue" class="cursor-pointer">断点续传</Label>
|
||
<Switch
|
||
id="dl-continue"
|
||
:model-value="store.settings.continueDownload"
|
||
@update:model-value="(v: boolean) => store.settings && (store.settings.continueDownload = v)"
|
||
/>
|
||
</div>
|
||
|
||
<!-- 删除任务时删除文件 -->
|
||
<div class="flex items-center justify-between">
|
||
<div class="flex flex-col gap-0.5">
|
||
<Label for="dl-delete-files" class="cursor-pointer">删除任务时删除文件</Label>
|
||
<span class="text-xs text-muted-foreground">同时删除已下载的文件</span>
|
||
</div>
|
||
<Switch
|
||
id="dl-delete-files"
|
||
:model-value="store.settings.deleteFilesOnRemove"
|
||
@update:model-value="(v: boolean) => store.settings && (store.settings.deleteFilesOnRemove = v)"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<DialogFooter>
|
||
<DialogClose as-child>
|
||
<Button variant="outline">取消</Button>
|
||
</DialogClose>
|
||
<Button @click="handleDialogSave">
|
||
<Check class="h-4 w-4" />
|
||
保存
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
<!-- ===== 新建下载弹窗 ===== -->
|
||
<Dialog v-model:open="addDialogOpen">
|
||
<DialogContent class="max-w-lg">
|
||
<DialogHeader>
|
||
<DialogTitle class="flex items-center gap-2">
|
||
<Link2 class="h-4 w-4 text-primary" />
|
||
新建下载
|
||
</DialogTitle>
|
||
<DialogDescription class="text-xs">
|
||
支持 HTTP/HTTPS 直链,每行一个 URL。留空下载目录则使用默认目录。
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
|
||
<div class="flex flex-col gap-3 py-2">
|
||
<!-- 下载链接 -->
|
||
<div class="flex flex-col gap-1.5">
|
||
<Label class="text-xs">下载链接</Label>
|
||
<Textarea
|
||
v-model="addUriText"
|
||
placeholder="https://example.com/file.zip https://example.com/file2.zip"
|
||
class="min-h-[120px] font-mono text-sm [field-sizing:fixed] break-all"
|
||
/>
|
||
</div>
|
||
|
||
<!-- 下载目录 -->
|
||
<div class="flex flex-col gap-1.5">
|
||
<Label class="text-xs">下载目录(可选)</Label>
|
||
<div class="flex gap-2">
|
||
<Input
|
||
v-model="addDir"
|
||
placeholder="留空使用默认目录"
|
||
class="font-mono text-sm"
|
||
/>
|
||
<Tooltip>
|
||
<TooltipTrigger as-child>
|
||
<Button variant="outline" size="icon" @click="handleSelectDir">
|
||
<FolderOpen class="h-4 w-4" />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent>选择目录</TooltipContent>
|
||
</Tooltip>
|
||
</div>
|
||
<p v-if="store.settings?.downloadDir" class="text-xs text-muted-foreground">
|
||
当前默认:
|
||
<span class="font-mono">{{ store.settings.downloadDir }}</span>
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<DialogFooter>
|
||
<DialogClose as-child>
|
||
<Button variant="outline">取消</Button>
|
||
</DialogClose>
|
||
<Button :disabled="addingTask || !addUriText.trim()" @click="handleAddDownload">
|
||
<Loader2 v-if="addingTask" class="h-4 w-4 animate-spin" />
|
||
<Plus v-else class="h-4 w-4" />
|
||
添加下载
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
<!-- ===== 重复下载确认弹窗 ===== -->
|
||
<AlertDialog :open="duplicateDialogState.open">
|
||
<AlertDialogContent class="max-w-md">
|
||
<AlertDialogHeader>
|
||
<AlertDialogTitle class="flex items-center gap-2">
|
||
<AlertCircle class="h-4 w-4 text-amber-500" />
|
||
下载重复提醒
|
||
</AlertDialogTitle>
|
||
<AlertDialogDescription class="text-xs">
|
||
<span class="block mb-2">
|
||
{{ duplicateKindText(duplicateDialogState.result?.duplicate ?? '') }}
|
||
</span>
|
||
<span class="block font-mono text-xs break-all">{{ duplicateDialogState.url }}</span>
|
||
<span v-if="duplicateDialogState.result?.existing" class="block mt-2 text-xs">
|
||
已存在:
|
||
<span class="font-medium">{{ duplicateDialogState.result.existing.filename }}</span>
|
||
</span>
|
||
<span class="block mt-2 text-muted-foreground">
|
||
选择"仍然下载"将自动重命名(追加序号),选择"跳过"将不下载此链接。
|
||
</span>
|
||
</AlertDialogDescription>
|
||
</AlertDialogHeader>
|
||
<AlertDialogFooter>
|
||
<AlertDialogCancel @click="onDuplicateCancel">取消全部</AlertDialogCancel>
|
||
<Button variant="outline" @click="onDuplicateSkip">跳过此链接</Button>
|
||
<AlertDialogAction @click="onDuplicateConfirm">
|
||
<Download class="h-4 w-4" />
|
||
仍然下载
|
||
</AlertDialogAction>
|
||
</AlertDialogFooter>
|
||
</AlertDialogContent>
|
||
</AlertDialog>
|
||
|
||
<!-- ===== 删除任务专用对话框(带"同时删除文件"开关) ===== -->
|
||
<AlertDialog :open="removeDialogState.open" @update:open="onRemoveOpenChange">
|
||
<AlertDialogContent>
|
||
<AlertDialogHeader>
|
||
<AlertDialogTitle>删除任务</AlertDialogTitle>
|
||
<AlertDialogDescription>
|
||
确定要删除任务 "{{ removeDialogState.task ? getFileName(removeDialogState.task) : '' }}" 吗?
|
||
</AlertDialogDescription>
|
||
</AlertDialogHeader>
|
||
<div class="flex items-center justify-between py-2 px-1">
|
||
<div class="flex flex-col gap-0.5">
|
||
<Label for="remove-delete-files" class="cursor-pointer text-sm">同时删除文件</Label>
|
||
<span class="text-xs text-muted-foreground">删除已下载的文件(含临时文件)</span>
|
||
</div>
|
||
<Switch
|
||
id="remove-delete-files"
|
||
:model-value="removeDialogState.deleteFiles"
|
||
@update:model-value="(v: boolean) => removeDialogState.deleteFiles = v"
|
||
/>
|
||
</div>
|
||
<AlertDialogFooter>
|
||
<AlertDialogCancel @click="onRemoveCancel">
|
||
取消
|
||
</AlertDialogCancel>
|
||
<AlertDialogAction
|
||
class="bg-destructive text-white hover:bg-destructive/90"
|
||
@click="onRemoveConfirm"
|
||
>
|
||
删除
|
||
</AlertDialogAction>
|
||
</AlertDialogFooter>
|
||
</AlertDialogContent>
|
||
</AlertDialog>
|
||
|
||
<!-- ===== 任务详情弹窗 ===== -->
|
||
<Dialog v-model:open="detailDialogState.open">
|
||
<DialogContent class="max-w-xl max-h-[80vh]">
|
||
<DialogHeader>
|
||
<DialogTitle class="flex items-center gap-2">
|
||
<Info class="h-4 w-4 text-primary" />
|
||
任务详情
|
||
</DialogTitle>
|
||
<DialogDescription class="text-xs">
|
||
任务 ID:{{ detailTask?.id }}
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
|
||
<ScrollArea class="max-h-[55vh] pr-3">
|
||
<div v-if="detailTask" class="flex flex-col gap-3 py-2 text-sm">
|
||
<!-- 文件名 -->
|
||
<div class="flex items-start justify-between gap-2">
|
||
<div class="flex flex-col gap-0.5 min-w-0 flex-1">
|
||
<span class="text-xs text-muted-foreground">文件名</span>
|
||
<span class="font-medium break-all">{{ detailTask.filename }}</span>
|
||
</div>
|
||
<Tooltip>
|
||
<TooltipTrigger as-child>
|
||
<Button size="icon" variant="ghost" class="h-7 w-7 shrink-0" @click="handleCopyText(detailTask.filename, '文件名')">
|
||
<Copy class="h-3.5 w-3.5" />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent>复制文件名</TooltipContent>
|
||
</Tooltip>
|
||
</div>
|
||
|
||
<Separator />
|
||
|
||
<!-- 状态 -->
|
||
<div class="flex items-center justify-between">
|
||
<span class="text-xs text-muted-foreground">状态</span>
|
||
<Badge :variant="getTaskStatusBadge(detailTask).variant" class="gap-1">
|
||
<component :is="getTaskStatusBadge(detailTask).icon" class="h-3 w-3" />
|
||
{{ getTaskStatusBadge(detailTask).text }}
|
||
</Badge>
|
||
</div>
|
||
|
||
<!-- 下载链接 -->
|
||
<div class="flex items-start justify-between gap-2">
|
||
<div class="flex flex-col gap-0.5 min-w-0 flex-1">
|
||
<span class="text-xs text-muted-foreground">下载链接</span>
|
||
<span class="font-mono text-xs break-all">{{ detailTask.url }}</span>
|
||
</div>
|
||
<Tooltip>
|
||
<TooltipTrigger as-child>
|
||
<Button size="icon" variant="ghost" class="h-7 w-7 shrink-0" @click="handleCopyText(detailTask.url, '下载链接')">
|
||
<Copy class="h-3.5 w-3.5" />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent>复制链接</TooltipContent>
|
||
</Tooltip>
|
||
</div>
|
||
|
||
<Separator />
|
||
|
||
<!-- 保存位置 -->
|
||
<div class="flex items-start justify-between gap-2">
|
||
<div class="flex flex-col gap-0.5 min-w-0 flex-1">
|
||
<span class="text-xs text-muted-foreground">保存位置</span>
|
||
<Tooltip>
|
||
<TooltipTrigger as-child>
|
||
<span class="font-mono text-xs break-all cursor-default">{{ detailTask.dir }}</span>
|
||
</TooltipTrigger>
|
||
<TooltipContent class="max-w-[400px] break-all">{{ detailTask.dir }}</TooltipContent>
|
||
</Tooltip>
|
||
<span class="font-mono text-xs text-muted-foreground break-all">{{ detailTask.filename }}</span>
|
||
</div>
|
||
<Tooltip>
|
||
<TooltipTrigger as-child>
|
||
<Button size="icon" variant="ghost" class="h-7 w-7 shrink-0" @click="handleCopyText(detailTask.dir + '\\' + detailTask.filename, '完整路径')">
|
||
<Copy class="h-3.5 w-3.5" />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent>复制完整路径</TooltipContent>
|
||
</Tooltip>
|
||
</div>
|
||
|
||
<Separator />
|
||
|
||
<!-- 文件大小信息 -->
|
||
<div class="grid grid-cols-2 gap-3">
|
||
<div class="flex flex-col gap-0.5">
|
||
<span class="text-xs text-muted-foreground">文件总大小</span>
|
||
<span>{{ formatSize(detailTask.totalSize) }}</span>
|
||
</div>
|
||
<div class="flex flex-col gap-0.5">
|
||
<span class="text-xs text-muted-foreground">已下载</span>
|
||
<span>{{ formatSize(detailTask.completedSize) }}</span>
|
||
</div>
|
||
<div class="flex flex-col gap-0.5">
|
||
<span class="text-xs text-muted-foreground">下载进度</span>
|
||
<span>{{ getProgress(detailTask) }}%</span>
|
||
</div>
|
||
<div class="flex flex-col gap-0.5">
|
||
<span class="text-xs text-muted-foreground">当前速度</span>
|
||
<span v-if="detailTask.status === 'active'">{{ formatSpeed(detailTask.speed) }}</span>
|
||
<span v-else>-</span>
|
||
</div>
|
||
</div>
|
||
|
||
<Separator />
|
||
|
||
<!-- 下载选项 -->
|
||
<div class="grid grid-cols-2 gap-3">
|
||
<div class="flex flex-col gap-0.5">
|
||
<span class="text-xs text-muted-foreground">断点续传</span>
|
||
<span>{{ detailTask.supportsResume ? '支持' : '不支持' }}</span>
|
||
</div>
|
||
<div class="flex flex-col gap-0.5">
|
||
<span class="text-xs text-muted-foreground">连接数 / 分片数</span>
|
||
<span>{{ detailTask.segments.length }}</span>
|
||
</div>
|
||
<div class="flex flex-col gap-0.5">
|
||
<span class="text-xs text-muted-foreground">创建时间</span>
|
||
<span>{{ formatTime(detailTask.createdAt) }}</span>
|
||
</div>
|
||
<div class="flex flex-col gap-0.5">
|
||
<span class="text-xs text-muted-foreground">剩余时间</span>
|
||
<span v-if="detailTask.status === 'active' && detailTask.speed > 0">
|
||
{{ formatEta(getEta(detailTask)) }}
|
||
</span>
|
||
<span v-else>-</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 错误信息 -->
|
||
<template v-if="detailTask.error">
|
||
<Separator />
|
||
<div class="flex flex-col gap-1">
|
||
<span class="text-xs text-muted-foreground">错误信息</span>
|
||
<span class="text-sm text-destructive break-all">{{ detailTask.error }}</span>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- 自定义请求头 -->
|
||
<template v-if="detailTask.headers && Object.keys(detailTask.headers).length > 0">
|
||
<Separator />
|
||
<div class="flex flex-col gap-1">
|
||
<span class="text-xs text-muted-foreground">自定义请求头</span>
|
||
<div class="rounded-md bg-muted p-2 text-xs font-mono space-y-0.5">
|
||
<div v-for="(value, key) in detailTask.headers" :key="key" class="flex gap-2">
|
||
<span class="text-muted-foreground shrink-0">{{ key }}:</span>
|
||
<span class="break-all">{{ value }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- 分段详情 -->
|
||
<template v-if="detailTask.segments.length > 1">
|
||
<Separator />
|
||
<div class="flex flex-col gap-2">
|
||
<span class="text-xs text-muted-foreground">分段详情</span>
|
||
<div class="flex flex-col gap-1.5">
|
||
<div v-for="(seg, i) in detailTask.segments" :key="i" class="flex items-center gap-2 text-xs">
|
||
<span class="w-8 text-muted-foreground shrink-0">#{{ i }}</span>
|
||
<div class="flex-1 min-w-0">
|
||
<Progress :model-value="segmentProgress(seg)" class="h-1.5" />
|
||
</div>
|
||
<span class="w-20 text-right text-muted-foreground shrink-0">
|
||
{{ formatSize(seg.completed) }} / {{ formatSize(seg.end - seg.start + 1) }}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</ScrollArea>
|
||
|
||
<DialogFooter>
|
||
<DialogClose as-child>
|
||
<Button variant="outline">关闭</Button>
|
||
</DialogClose>
|
||
<Button v-if="detailTask?.dir" variant="outline" @click="handleOpenDir(detailTask)">
|
||
<FolderOpen class="h-4 w-4" />
|
||
打开目录
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</div>
|
||
</template>
|