Files
Thing/src/modules/downloader/DownloaderModule.vue
T
2026-08-06 10:33:16 +08:00

1654 lines
64 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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/s0=不限)</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/s0=不限)</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&#10;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>