优化调整、BT下载(有bug)

This commit is contained in:
zhongluofeng
2026-08-27 18:25:45 +08:00
parent 4dd60f42a1
commit d21649c60e
34 changed files with 4991 additions and 198 deletions
+495
View File
@@ -0,0 +1,495 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { getCurrentWindow, Effect, EffectState } from '@tauri-apps/api/window'
import { LogicalSize } from '@tauri-apps/api/dpi'
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
import { Pause, Play, X, Minus, FolderOpen, AlertCircle, Loader2, XCircle } from '@lucide/vue'
import { commands, type DownloadTask, type Segment, type TaskStatus } from '@/lib/bindings'
import { STORAGE_KEYS } from '@/lib/constants'
import { createLogger } from '@/lib/logger'
const logger = createLogger('download-window')
// ===== 从 URL hash 解析任务 id#download-window?task=<id> =====
const hash = window.location.hash
const taskId = new URLSearchParams(hash.split('?')[1] ?? '').get('task') ?? ''
if (!taskId) logger.warn('下载窗口缺少 task 参数')
const win = getCurrentWindow()
const task = ref<DownloadTask | null>(null)
const loading = ref(true)
const actionError = ref('')
// ===== 进度事件载荷(与 Rust 端 ProgressPayload 对应) =====
interface ProgressPayload {
id: string
completedSize: number
totalSize: number
speed: number
status: TaskStatus
segments: number[]
}
const loadTask = async () => {
try {
const all = await commands.downloaderGetTasks()
task.value = all.find(t => t.id === taskId) ?? null
} catch (e) {
logger.error('获取任务失败: ' + e)
} finally {
loading.value = false
}
// 任务已不存在(被模块/扩展删除):本窗口无存在意义,直接关闭
if (!task.value) {
await win.close()
return
}
// 文件名显示到窗口标题(无边框窗口下仍影响任务栏悬浮标题)
try { await win.setTitle(task.value.filename) } catch { /* 忽略 */ }
}
// ===== 状态派生 =====
const totalSize = computed(() => task.value?.totalSize ?? 0)
const completedSize = computed(() => task.value?.completedSize ?? 0)
const speed = computed(() => task.value?.speed ?? 0)
const status = computed<TaskStatus>(() => task.value?.status ?? 'active')
const progress = computed(() => {
if (!totalSize.value || totalSize.value === 0) return 0
return Math.min(100, Math.round((completedSize.value / totalSize.value) * 100))
})
const segments = computed(() => task.value?.segments ?? [])
const showSegments = computed(() => segments.value.length > 1)
const statusText = computed(() => {
switch (status.value) {
case 'active': return '下载中'
case 'queued': return '等待中'
case 'paused': return '已暂停'
case 'complete': return '已完成'
case 'error': return '出错'
case 'cancelled': return '已取消'
}
})
const statusTone = computed(() => {
if (status.value === 'complete') return 'success'
if (status.value === 'error') return 'destructive'
if (status.value === 'paused' || status.value === 'cancelled') return 'muted'
return 'active'
})
// ===== 格式化 =====
const formatByte = (b: number) => {
if (!b || isNaN(b)) return '0 B'
if (b < 1024) return `${b} B`
if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)} KB`
if (b < 1024 * 1024 * 1024) return `${(b / 1024 / 1024).toFixed(2)} MB`
return `${(b / 1024 / 1024 / 1024).toFixed(2)} GB`
}
const formatTotal = (b: number) => (!b || b === 0 ? '未知' : formatByte(b))
// ===== 分段进度(横向分段条:每段宽度按其长度占整个文件的比例) =====
const segLen = (s: Segment) => s.end - s.start + 1
const totalSpan = computed(() => segments.value.reduce((a, s) => a + segLen(s), 0))
const segPercent = (s: Segment) => {
if (segLen(s) <= 0) return 0
return Math.min(100, Math.round((s.completed / segLen(s)) * 100))
}
// ===== 操作 =====
const errorTip = (e: unknown) => {
actionError.value = typeof e === 'string' ? e : '操作失败'
}
const doPause = async () => {
try { await commands.downloaderPauseTask(taskId) } catch (e) { return errorTip(e) }
await loadTask().catch(() => {})
}
const doResume = async () => {
try { await commands.downloaderResumeTask(taskId) } catch (e) { return errorTip(e) }
await loadTask().catch(() => {})
}
// 取消:二次确认(3 秒内再点一次才真正删除)
const armedCancel = ref(false)
let cancelArmTimer = 0
const doCancel = async () => {
if (!armedCancel.value) {
armedCancel.value = true
window.clearTimeout(cancelArmTimer)
cancelArmTimer = window.setTimeout(() => (armedCancel.value = false), 3000)
return
}
window.clearTimeout(cancelArmTimer)
armedCancel.value = false
try { await commands.downloaderCancelTask(taskId) } catch (e) { return errorTip(e) }
await win.close()
}
const doOpenFolder = async () => {
if (!task.value?.dir) return
try { await commands.downloaderOpenDir(task.value.dir) } catch (e) { errorTip(e) }
}
const doMinimize = () => win.minimize()
const doClose = () => win.close()
// 标题文字按下拖动窗口:data-tauri-drag-region 对文字子元素可能不生效
// (文字层拦截 pointer 事件),改用显式 startDragging(仅左键)
const startDrag = () => {
win.startDragging().catch(() => {})
}
// 只读提示:错误等瞬态信息 3 秒后清除
const clearErrorSoon = () => {
if (!actionError.value) return
window.setTimeout(() => { if (!actionError.value) return; actionError.value = '' }, 3000)
}
// ===== 主题与窗口效果(与主应用同步:主题 + mica/acrylic 效果) =====
function readMainTheme(): { theme: string; effect: string } {
try {
const raw = localStorage.getItem(STORAGE_KEYS.appSettings)
if (raw) {
const s = JSON.parse(raw)
return { theme: s.theme ?? 'system', effect: s.effect ?? 'mica' }
}
} catch { /* 忽略 */ }
return { theme: 'system', effect: 'mica' }
}
/** 判断当前是否应为深色主题(弹窗独立窗口,system 模式用 matchMedia 可靠) */
function resolveIsDark(theme: string): boolean {
if (theme === 'dark') return true
if (theme === 'light') return false
return window.matchMedia('(prefers-color-scheme: dark)').matches
}
async function applyTheme() {
const root = document.documentElement
const { theme, effect } = readMainTheme()
// 1. 设置窗口原生主题(system → null 跟随系统)
try {
if (theme === 'system') await win.setTheme(null)
else await win.setTheme(theme as 'dark' | 'light')
} catch { /* 忽略 */ }
// 2. 用 matchMedia 判断深浅(不依赖主应用状态)
const isDark = resolveIsDark(theme)
// 3. 设置 DOM classeffect 类供 style.css 变量联动)
root.classList.remove('dark', 'effect-mica', 'effect-acrylic', 'effect-normal')
root.classList.add(`effect-${effect}`)
if (isDark) root.classList.add('dark')
// 4. 设置窗口效果:mica/acrylic 下卡片透明,普通模式用不透明背景
try {
await win.clearEffects()
if (effect === 'mica') {
await win.setEffects({
effects: [Effect.Mica],
state: EffectState.FollowsWindowActiveState,
color: isDark ? [30, 41, 59, 0] : [248, 250, 252, 0],
})
await win.setBackgroundColor('#00000000')
root.style.setProperty('--popup-bg', 'transparent')
} else if (effect === 'acrylic') {
await win.setEffects({
effects: [Effect.Acrylic],
state: EffectState.FollowsWindowActiveState,
color: isDark ? [30, 41, 59, 0] : [248, 250, 252, 0],
})
await win.setBackgroundColor('#00000000')
root.style.setProperty('--popup-bg', 'transparent')
} else {
await win.setBackgroundColor(isDark ? '#0f172a' : '#ffffff')
root.style.setProperty('--popup-bg', isDark ? '#0f172a' : '#ffffff')
}
} catch { /* 忽略 */ }
}
// ===== 自动贴合内容高度:消除卡片下方留白 =====
const rootRef = ref<HTMLElement | null>(null)
let lastHeight = 0
let autoResizeObserver: ResizeObserver | null = null
// 仅兜底防窗口过小;正常状态下窗口严格贴合内容高度(既不高出导致底部露白,
// 也不低于 titlebar/loading 所需),由 getBoundingClientRect().height 决定
const MIN_H = 52
const WINDOW_W = 420
const applyAutoHeight = async () => {
if (!rootRef.value) return
try {
if (await win.isMinimized()) return
} catch { /* 忽略 */ }
const h = Math.max(MIN_H, Math.ceil(rootRef.value.getBoundingClientRect().height))
if (Math.abs(h - lastHeight) > 4) {
lastHeight = h
win.setSize(new LogicalSize(WINDOW_W, h)).catch(() => {})
}
}
// ===== 事件监听 =====
let progressUnlisten: UnlistenFn | null = null
let completeUnlisten: UnlistenFn | null = null
let onThemeChange: (() => void) | null = null
// 完成时置前提醒:恢复最小化 + 显示 + 聚焦(窗口从任务栏/后台唤出到最前)。
// 用 Rust 端 downloaderFocusWindow 强制置前,绕过 Windows 前台锁定(纯 setFocus 会被忽略)
const handleComplete = async () => {
await loadTask().catch(() => {})
if (status.value === 'complete') {
try { await commands.downloaderFocusWindow(win.label) } catch { /* 忽略 */ }
}
}
onMounted(async () => {
await applyTheme()
await loadTask()
progressUnlisten = await listen<ProgressPayload>('download-progress', (e) => {
if (e.payload.id !== taskId || !task.value) return
const t = task.value
t.completedSize = e.payload.completedSize
t.totalSize = e.payload.totalSize
t.speed = e.payload.speed
t.status = e.payload.status
if (Array.isArray(e.payload.segments) && t.segments) {
t.segments.forEach((seg, i) => {
const v = e.payload.segments[i]
if (v !== undefined) seg.completed = v
})
}
})
completeUnlisten = await listen<{ id: string }>('download-complete', (e) => {
if (e.payload?.id !== taskId) return
handleComplete()
})
const mq = window.matchMedia('(prefers-color-scheme: dark)')
onThemeChange = () => applyTheme()
mq.addEventListener('change', onThemeChange)
// 内容变化时自动贴合窗口高度(始终保留,覆盖后续加载→数据等状态下的高度变化)
lastHeight = 0
autoResizeObserver = new ResizeObserver(applyAutoHeight)
if (rootRef.value) autoResizeObserver.observe(rootRef.value)
// 首次显示:窗口由 App.vue 隐藏创建,此处先在隐藏态贴合到内容高度,
// 再延迟一小段等待 setSize 生效后一次性 show + 置前,
// 避免"先以初始高度显示、再 resize"造成的尺寸跳变闪烁。
void applyAutoHeight()
window.setTimeout(async () => {
// 隐藏态下多贴合一次,确保尺寸已对内容就位
await applyAutoHeight()
try { await win.show() } catch { /* 忽略 */ }
try { await win.unminimize() } catch { /* 忽略 */ }
void commands.downloaderFocusWindow(win.label).catch(() => {})
}, 120)
})
onUnmounted(() => {
progressUnlisten?.()
completeUnlisten?.()
autoResizeObserver?.disconnect()
if (onThemeChange) window.matchMedia('(prefers-color-scheme: dark)').removeEventListener('change', onThemeChange)
window.clearTimeout(cancelArmTimer)
})
</script>
<template>
<div ref="rootRef" class="dlw-root">
<!-- 顶部标题栏可拖动文件名作为标题右侧放操作按钮 + 最小化 + 关闭 -->
<div class="dlw-titlebar" data-tauri-drag-region>
<div class="dlw-title-left" data-tauri-drag-region>
<Loader2 v-if="status === 'active'" class="size-3 shrink-0 animate-spin text-primary" />
<XCircle v-else-if="status === 'error'" class="size-3 shrink-0 text-red-500" />
<Pause v-else class="size-3 shrink-0 text-muted-foreground" />
<span v-if="loading" class="text-xs font-medium text-muted-foreground">下载</span>
<span v-else-if="task" class="dlw-title-name" :title="task.filename"
@mousedown.left.prevent="startDrag">{{ task.filename }}</span>
</div>
<div class="dlw-title-right">
<button v-if="status === 'active'" class="dlw-btn" @click="doPause">
<Pause class="size-3" /> 暂停
</button>
<button v-else-if="status === 'paused'" class="dlw-btn" @click="doResume">
<Play class="size-3" /> 继续
</button>
<button v-if="status !== 'complete' && status !== 'error'" class="dlw-btn dlw-btn-danger" @click="doCancel">
{{ armedCancel ? '再点一次取消' : '取消' }}
</button>
<button v-if="status === 'complete'" class="dlw-btn dlw-btn-primary" @click="doOpenFolder">
<FolderOpen class="size-3" /> 打开文件夹
</button>
<button v-else-if="status === 'error' && task?.dir" class="dlw-btn" @click="doOpenFolder">
<FolderOpen class="size-3" /> 打开目录
</button>
<button class="dlw-icon-btn" title="最小化" @click="doMinimize">
<Minus class="size-3.5" />
</button>
<button class="dlw-icon-btn" title="关闭(下载在后台继续)" @click="doClose">
<X class="size-3.5" />
</button>
</div>
</div>
<!-- 主体状态 + 总进度 + 分段条 -->
<div class="dlw-body">
<div v-if="loading" class="dlw-loading">
<Loader2 class="size-5 animate-spin text-muted-foreground" />
</div>
<template v-else-if="task">
<div class="flex items-center justify-between gap-2 text-xs">
<span class="dlw-status-badge" :class="`tone-${statusTone}`">{{ statusText }}</span>
<span class="truncate text-muted-foreground">
{{ formatByte(completedSize) }} / {{ formatTotal(totalSize) }} · {{ progress }}%
<template v-if="status === 'active' && speed > 0"> · {{ formatByte(speed) }}/s</template>
</span>
</div>
<div class="dlw-bar mt-1.5">
<div class="dlw-bar-fill" :style="{ width: progress + '%' }" />
</div>
<!-- 分段进度横向分段条 -->
<div v-if="showSegments" class="mt-2">
<span class="text-[10px] text-muted-foreground">分段 ({{ segments.length }} 线程)</span>
<div class="dlw-seg-strip mt-1">
<div
v-for="(seg, i) in segments"
:key="i"
class="dlw-seg"
:style="{ width: ((segLen(seg) / totalSpan) * 100).toFixed(2) + '%' }"
:title="`#${i + 1} ${segPercent(seg)}%`"
>
<div class="dlw-seg-fill" :style="{ width: segPercent(seg) + '%' }" />
</div>
</div>
</div>
<div v-if="actionError" class="mt-1.5 truncate text-right text-[10px] text-red-500" @click="clearErrorSoon">
{{ actionError }}
</div>
</template>
<div v-else class="dlw-loading">
<span class="flex items-center text-xs text-muted-foreground">
<AlertCircle class="size-4 mr-1.5" /> 任务已不存在
</span>
</div>
</div>
</div>
</template>
<style scoped>
.dlw-root {
display: flex;
flex-direction: column;
gap: 4px;
padding: 4px 6px 6px;
overflow: hidden;
}
/* 标题栏 */
.dlw-titlebar {
display: flex;
align-items: center;
justify-content: space-between;
height: 34px;
padding: 0 2px 0 8px;
gap: 6px;
user-select: none;
flex-shrink: 0;
}
.dlw-title-left {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.dlw-title-name {
font-size: 12px;
font-weight: 600;
line-height: 1.3;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.dlw-title-right {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
min-width: 0;
}
/* 图标按钮(最小化/关闭) */
.dlw-icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 5px;
color: var(--muted-foreground);
}
.dlw-icon-btn:hover { background: var(--accent); color: var(--accent-foreground); }
/* 主体卡片:背景色由 --popup-bg 控制(mica/acrylic 下透明,普通模式不透明)。
flex:1 撑满 titlebar 下方的剩余高度,避免内容(尤其无分段时)比窗口矮导致底部露出
白色/透明的窗口背景区空白。 */
.dlw-body {
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-height: 0;
border: 1px solid var(--border);
border-radius: 9px;
background: var(--popup-bg, transparent);
padding: 9px 11px 9px;
box-shadow: 0 8px 30px rgb(0 0 0 / 0.18);
}
/* 加载/任务缺失等仅有一行的场景,内容垂直居中于撑满的卡片内 */
.dlw-body > .dlw-loading { flex: 1 1 auto; margin: auto; }
.dlw-loading {
display: flex;
align-items: center;
justify-content: center;
min-height: 56px;
}
.dlw-status-badge {
flex-shrink: 0;
font-size: 10px;
line-height: 1.6;
padding: 0 8px;
border-radius: 999px;
white-space: nowrap;
}
.dlw-status-badge.tone-active { background: var(--primary); color: var(--primary-foreground); }
.dlw-status-badge.tone-success { background: #10b981; color: #fff; }
.dlw-status-badge.tone-destructive { background: #ef4444; color: #fff; }
.dlw-status-badge.tone-muted { background: var(--muted); color: var(--muted-foreground); }
.dlw-bar { height: 7px; border-radius: 999px; background: var(--muted); overflow: hidden; }
.dlw-bar-fill { height: 100%; border-radius: 999px; background: var(--primary); transition: width 0.2s linear; }
.dlw-seg-strip { display: flex; gap: 2px; height: 7px; }
.dlw-seg { height: 100%; border-radius: 3px; background: var(--muted); overflow: hidden; }
.dlw-seg-fill { height: 100%; background: var(--primary); opacity: 0.75; transition: width 0.2s linear; }
/* 操作按钮(紧凑) */
.dlw-btn {
display: inline-flex;
align-items: center;
gap: 4px;
height: 24px;
padding: 0 8px;
font-size: 11px;
border-radius: 6px;
border: 1px solid var(--border);
background: transparent;
color: var(--foreground);
cursor: pointer;
white-space: nowrap;
}
.dlw-btn:hover { background: var(--accent); color: var(--accent-foreground); }
.dlw-btn-danger { color: #ef4444; }
.dlw-btn-danger:hover { background: rgb(239 68 68 / 0.12); color: #dc2626; }
.dlw-btn-primary { background: var(--primary); color: var(--primary-foreground); border-color: transparent; }
/* hover 时显式保持前景色,避免被上面的 .dlw-btn:hover 覆盖成深色导致黑字黑底不可见 */
.dlw-btn-primary:hover { background: var(--primary); color: var(--primary-foreground); filter: brightness(1.05); }
</style>
+448 -32
View File
@@ -5,7 +5,7 @@ import {
Link2, Loader2, FolderOpen, Copy, Puzzle,
CheckCircle2, Clock, Zap, Eye, EyeOff, Search,
ArrowUp, ArrowDown, ChevronLeft, ChevronRight, ExternalLink, Globe,
Info
Info, XCircle, Square
} from '@lucide/vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { toast } from 'vue-sonner'
@@ -57,6 +57,118 @@ const addDir = ref('')
const addingTask = ref(false)
const addDialogOpen = ref(false)
// ===== BT 种子文件勾选对话框(异步添加:元数据解析成功后由事件驱动弹出) =====
interface BtSelectEntry {
taskId: string
/** 已勾选的文件索引(默认全选) */
selected: number[]
}
/** 待勾选文件的任务队列(可能多个磁力同时解析完成) */
const btSelectQueue = ref<BtSelectEntry[]>([])
const btSelectOpen = ref(false)
/** 当前展示的勾选入口 */
const btSelectEntry = computed<BtSelectEntry | null>(() => btSelectQueue.value[0] ?? null)
/** 当前勾选入口对应的任务(含文件列表) */
const btSelectTask = computed<DownloadTask | null>(() => {
const e = btSelectEntry.value
if (!e) return null
return store.tasks.find((t) => t.id === e.taskId) ?? null
})
/** 判断单个输入是否为 BT 链接 */
const isBtInput = (input: string): boolean => {
const s = input.trim()
return s.toLowerCase().startsWith('magnet:') || /\.torrent($|\?)/i.test(s)
}
/** 切换某个文件勾选 */
const btnToggleFile = (idx: number) => {
const e = btSelectEntry.value
if (!e) return
const i = e.selected.indexOf(idx)
if (i >= 0) e.selected.splice(i, 1)
else e.selected.push(idx)
}
/** 全选 / 全不选 */
const btnSetAll = (all: boolean) => {
const e = btSelectEntry.value
const task = btSelectTask.value
if (!e || !task) return
e.selected = all ? task.btFiles.map((f) => f.index) : []
}
/** 当前任务是否已全选 */
const btnIsAll = (): boolean => {
const task = btSelectTask.value
const e = btSelectEntry.value
if (!task || !e) return false
return e.selected.length === task.btFiles.length
}
/** 当前已勾选文件总大小 */
const btnSelectedSize = (): number => {
const task = btSelectTask.value
const e = btSelectEntry.value
if (!task || !e) return 0
return task.btFiles
.filter((f) => e.selected.includes(f.index))
.reduce((sum, f) => sum + f.size, 0)
}
/** 收起当前入口,展示队列中的下一个(或关闭) */
const nextBtSelect = () => {
btSelectQueue.value.shift()
btSelectOpen.value = btSelectQueue.value.length > 0
}
/** 磁力元数据就绪回调:刷新任务后把该任务加入勾选队列并弹出 */
const onBtInspectReady = (taskId: string) => {
const task = store.tasks.find((t) => t.id === taskId)
if (!task) return
// 单文件种子无勾选必要:直接全选并开始下载,跳过勾选对话框
if (task.btFiles.length === 1) {
store.selectBtFiles(taskId, [task.btFiles[0].index]).catch((err) => {
toast.error('开始下载失败: ' + err)
})
return
}
btSelectQueue.value.push({
taskId,
selected: task.btFiles.map((f) => f.index) // 默认全选
})
btSelectOpen.value = true
}
/** 确认:设置勾选文件并开始下载,然后处理下一个 */
const onBtFileConfirm = async () => {
const e = btSelectEntry.value
if (!e) return
try {
await store.selectBtFiles(e.taskId, e.selected)
} catch (err) {
toast.error('设置下载文件失败: ' + err)
return
}
nextBtSelect()
}
/** 取消:不下载该任务的勾选,跳过到下一个 */
const onBtFileCancel = async () => {
const e = btSelectEntry.value
if (e) {
// 元数据已就绪且任务仍为 Queued,若不改状态会被下一次 schedule()
// 以"全选默认"自动开始下载;置为 Paused 才真正保持冻结,
// 用户想继续下载时点"继续"即可(恢复为全部文件)。
try {
await store.pauseTask(e.taskId)
} catch (err) {
logger.error('暂停未确认的磁力任务失败: ' + err)
}
}
nextBtSelect()
}
// 扩展密钥显示
const showSecret = ref(false)
@@ -94,6 +206,7 @@ const STATUS_OPTIONS: { label: string; value: StatusFilter }[] = [
{ label: '等待中', value: 'queued' },
{ label: '已暂停', value: 'paused' },
{ label: '已完成', value: 'complete' },
{ label: '已取消', value: 'cancelled' },
{ label: '错误', value: 'error' }
]
@@ -107,6 +220,17 @@ const SORT_OPTIONS: { label: string; value: SortField }[] = [
const running = computed(() => store.status.running)
// ===== 工具函数 =====
/** BT 第 i 个文件已下载字节(progress 事件把每文件进度写入 segments[i].completed */
const btFileDownloaded = (task: DownloadTask, i: number): number =>
task.segments?.[i]?.completed ?? 0
/** BT 第 i 个文件下载百分比 */
const btFileProgress = (task: DownloadTask, i: number): number => {
const f = task.btFiles?.[i]
if (!f || !f.size) return 0
return Math.min(100, Math.round((btFileDownloaded(task, i) / f.size) * 100))
}
const formatSize = (bytes: number): string => {
if (!bytes || isNaN(bytes)) return '0 B'
if (bytes < 1024) return `${bytes} B`
@@ -167,6 +291,8 @@ const getTaskStatusBadge = (task: DownloadTask) => {
return { variant: 'default' as const, text: '已完成', icon: CheckCircle2 }
case 'error':
return { variant: 'destructive' as const, text: '错误', icon: AlertCircle }
case 'cancelled':
return { variant: 'secondary' as const, text: '已取消', icon: XCircle }
default:
return { variant: 'outline' as const, text: task.status, icon: AlertCircle }
}
@@ -199,6 +325,45 @@ const handleResume = async (id: string) => {
}
}
// ===== 取消下载(参考下载窗口:二次点击确认,3 秒内未二次点击则取消)=====
const cancelArmed = ref<Record<string, boolean>>({})
let cancelArmTimers: Record<string, number> = {}
const handleCancel = (id: string) => {
if (cancelArmed.value[id]) {
// 二次点击:执行取消
window.clearTimeout(cancelArmTimers[id])
delete cancelArmTimers[id]
cancelArmed.value[id] = false
doCancel(id)
} else {
// 首次点击:进入待确认状态
cancelArmed.value[id] = true
window.clearTimeout(cancelArmTimers[id])
cancelArmTimers[id] = window.setTimeout(() => {
cancelArmed.value[id] = false
delete cancelArmTimers[id]
}, 3000)
}
}
const doCancel = async (id: string) => {
try {
await store.cancelTask(id)
toast.success('已取消,下载文件已删除')
} catch (e) {
toast.error('取消失败: ' + e)
}
}
// ===== 重新下载(已取消/出错任务) =====
const handleRedownload = async (id: string) => {
try {
await store.redownload(id)
toast.success('已重新下载')
} catch (e) {
toast.error('重新下载失败: ' + e)
}
}
// ===== 删除任务专用对话框(带"同时删除文件"开关) =====
const removeDialogState = ref<{
open: boolean
@@ -310,27 +475,54 @@ const handleAddDownload = async () => {
return
}
// 分离 BT 链接(磁力/种子)与 HTTP 链接
const httpUris: string[] = []
const btUris: string[] = []
for (const uri of uris) {
if (isBtInput(uri)) btUris.push(uri)
else httpUris.push(uri)
}
const dir = addDir.value.trim()
addingTask.value = true
try {
const dir = addDir.value.trim() || undefined
// 检查是否启用重复检查
const checkEnabled = store.settings?.checkDuplicate ?? true
if (checkEnabled) {
// 逐个检查重复
duplicateSuccessCount.value = 0
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)
// HTTP 链接走原流程(含重复检查)
if (httpUris.length > 0) {
const checkEnabled = store.settings?.checkDuplicate ?? true
if (checkEnabled) {
duplicateSuccessCount.value = 0
processUrlsWithCheck(httpUris, dir || undefined)
} else {
let successCount = 0
for (const uri of httpUris) {
try {
await store.addTask(uri, undefined, dir || undefined, undefined, true)
successCount++
} catch (e) {
logger.error(`添加 ${uri} 失败: ` + e)
}
}
if (successCount > 0) finishAdd(successCount)
}
finishAdd(successCount)
}
// BT 链接:异步添加(立即返回,不卡在元数据解析)。后台解析,成功后弹文件勾选。
let btCount = 0
for (const uri of btUris) {
try {
await store.addTask(uri, undefined, dir || undefined, undefined, true)
btCount++
} catch (e) {
toast.error(`添加磁力任务失败:${e}`)
}
}
if (btCount > 0) {
toast.info(`已添加 ${btCount} 个磁力任务,正在后台解析元数据…`)
// 关闭新建下载对话框(任务已出现在列表,解析成功后自动弹文件勾选)
addDialogOpen.value = false
addUriText.value = ''
addDir.value = ''
activeTab.value = 'tasks'
}
} catch (e) {
toast.error('添加失败: ' + e)
@@ -498,6 +690,23 @@ const handleSelectDir = async () => {
}
}
/** 选择本地 .torrent 种子文件,追加到下载链接输入框(支持一次选多个,每行一个) */
const handleSelectTorrentFile = async () => {
try {
const selected = await openDialog({
multiple: true,
filters: [{ name: '种子文件', extensions: ['torrent'] }]
})
const paths = Array.isArray(selected) ? selected : selected ? [selected] : []
if (paths.length === 0) return
const existing = addUriText.value.trim()
const next = existing ? existing.trimEnd() + '\n' + paths.join('\n') : paths.join('\n')
addUriText.value = next
} catch (e) {
logger.error('选择种子文件失败: ' + e)
}
}
const handleSelectSettingsDir = async () => {
try {
const selected = await openDialog({ directory: true, multiple: false })
@@ -588,6 +797,8 @@ watch(pendingShowDownloadTasks, (v) => {
onMounted(async () => {
await store.init()
// 注册磁力元数据就绪回调:解析成功后弹文件勾选对话框
store.setBtInspectReadyHandler(onBtInspectReady)
// 消费托盘菜单"新建下载"标志位(挂载前设置的场景,watcher 尚未生效)
if (pendingNewDownload.value) {
pendingNewDownload.value = false
@@ -601,6 +812,7 @@ onMounted(async () => {
})
onUnmounted(() => {
store.setBtInspectReadyHandler(null)
store.stopEventListeners()
})
@@ -610,7 +822,7 @@ 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 }
const counts: Record<TaskStatus, number> = { queued: 0, active: 0, paused: 0, complete: 0, error: 0, cancelled: 0 }
for (const t of allTasks.value) counts[t.status]++
return counts
})
@@ -693,8 +905,8 @@ const toggleSortOrder = () => {
</div>
<!-- ===== 下载任务 ===== -->
<TabsContent value="tasks" class="flex-1 mt-4 min-h-0 tab-animate">
<div class="h-full flex flex-col gap-4">
<TabsContent value="tasks" class="flex-1 mt-4 min-h-0 min-w-0 tab-animate">
<div class="h-full flex flex-col gap-4 min-w-0">
<!-- 状态栏 -->
<Card class="shrink-0 !py-0 !gap-0">
<CardContent class="pl-4 pr-4 py-3">
@@ -727,6 +939,10 @@ const toggleSortOrder = () => {
<Check class="size-3" />
已完成 {{ statusCounts.complete }}
</Badge>
<Badge v-if="statusCounts.cancelled > 0" variant="secondary" class="gap-1">
<XCircle class="size-3" />
已取消 {{ statusCounts.cancelled }}
</Badge>
</div>
</div>
<div class="flex items-center gap-2">
@@ -849,7 +1065,7 @@ const toggleSortOrder = () => {
</div>
<!-- 任务列表 -->
<ScrollArea class="flex-1 min-h-0">
<ScrollArea class="flex-1 min-h-0 min-w-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="size-16 opacity-30" />
<p>暂无符合条件的任务</p>
@@ -863,10 +1079,15 @@ const toggleSortOrder = () => {
<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">
<span class="font-medium truncate cursor-default">
<div class="flex items-center gap-2 mb-1 min-w-0">
<!-- flex-1 + min-w-0 + truncateflex 子项默认 min-width:auto 不收缩
长文件名会把整行撑宽flex-1 让名字占满可用宽度并省略号截断 -->
<span class="min-w-0 flex-1 font-medium truncate cursor-default" :title="getFileName(task)">
{{ getFileName(task) }}
</span>
<Badge v-if="task.protocol === 'bittorrent'" variant="outline" class="shrink-0 gap-1 py-0 px-1.5 text-[10px]">
BT
</Badge>
<Badge
:variant="getTaskStatusBadge(task).variant"
class="shrink-0"
@@ -895,7 +1116,7 @@ const toggleSortOrder = () => {
</TooltipTrigger>
<TooltipContent class="max-w-[480px] break-words">{{ task.dir }}</TooltipContent>
</Tooltip>
<span v-if="task.error" class="text-destructive">
<span v-if="task.error" class="text-destructive break-all">
{{ task.error }}
</span>
</div>
@@ -927,6 +1148,35 @@ const toggleSortOrder = () => {
</TooltipTrigger>
<TooltipContent>继续</TooltipContent>
</Tooltip>
<!-- 已取消任务只能再次下载 -->
<Tooltip v-if="task.status === 'cancelled'">
<TooltipTrigger as-child>
<Button
size="icon"
variant="outline"
class="size-8 text-primary"
@click="handleRedownload(task.id)"
>
<RefreshCw class="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>再次下载</TooltipContent>
</Tooltip>
<!-- 取消下载二次点击确认 -->
<Tooltip v-if="task.status !== 'complete' && task.status !== 'cancelled'">
<TooltipTrigger as-child>
<Button
size="icon"
variant="ghost"
class="size-8 text-destructive hover:text-destructive"
:class="{ 'font-bold': cancelArmed[task.id] }"
@click="handleCancel(task.id)"
>
<XCircle class="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ cancelArmed[task.id] ? '再点一次确认取消' : '取消(再点一次确认)' }}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<Button
@@ -940,7 +1190,7 @@ const toggleSortOrder = () => {
</TooltipTrigger>
<TooltipContent>详细信息</TooltipContent>
</Tooltip>
<Tooltip v-if="task.dir">
<Tooltip v-if="task.dir && task.status !== 'cancelled'">
<TooltipTrigger as-child>
<Button
size="icon"
@@ -968,8 +1218,9 @@ const toggleSortOrder = () => {
</Tooltip>
</div>
</div>
<Progress :model-value="getProgress(task)" class="h-1.5" />
<div class="flex justify-between mt-1 text-xs text-muted-foreground">
<!-- 已取消任务不展示进度条与进度进度与文件均已清除 -->
<Progress v-if="task.status !== 'cancelled'" :model-value="getProgress(task)" class="h-1.5" />
<div v-if="task.status !== 'cancelled'" 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)) }}
@@ -1153,6 +1404,60 @@ const toggleSortOrder = () => {
</CardContent>
</Card>
<!-- BT 专属设置 -->
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2 text-base">
<Zap class="size-4 text-primary" />
BitTorrent 设置
</CardTitle>
</CardHeader>
<CardContent class="flex flex-col gap-3">
<div class="flex flex-col gap-1.5">
<Label class="text-xs">上传限速 KB/s0=不限)</Label>
<Input
:model-value="settingsDraft?.btUploadLimitKb ?? 0"
type="number"
min="0"
@update:model-value="(v: string | number) => settingsDraft && (settingsDraft.btUploadLimitKb = parseInt(String(v)) || 0)"
/>
</div>
<div class="flex flex-col gap-1.5">
<Label class="text-xs">监听端口(0=自动选择)</Label>
<Input
:model-value="settingsDraft?.btListenPort ?? 0"
type="number"
min="0"
max="65535"
@update:model-value="(v: string | number) => settingsDraft && (settingsDraft.btListenPort = parseInt(String(v)) || 0)"
/>
<p class="text-xs text-muted-foreground">下载完成后继续上传做种需要对外开放连接端口。修改端口后重启应用生效。</p>
</div>
<div class="flex items-center justify-between">
<div class="flex flex-col gap-0.5">
<Label for="bt-proxy" class="cursor-pointer">使用代理下载</Label>
<span class="text-xs text-muted-foreground">开启后自动使用代理模块(mihomo)的 SOCKS5 端口;代理不可用时自动降级为直连</span>
</div>
<Switch
id="bt-proxy"
:model-value="settingsDraft?.btUseProxy ?? false"
@update:model-value="(v: boolean) => settingsDraft && (settingsDraft.btUseProxy = v)"
/>
</div>
<div class="flex items-center justify-between">
<div class="flex flex-col gap-0.5">
<Label for="bt-seed" class="cursor-pointer">下载完成后继续做种上传</Label>
<span class="text-xs text-muted-foreground">关闭则下载完成后立即停止上传(节省流量,推荐)</span>
</div>
<Switch
id="bt-seed"
:model-value="settingsDraft?.btSeedAfterDownload ?? false"
@update:model-value="(v: boolean) => settingsDraft && (settingsDraft.btSeedAfterDownload = v)"
/>
</div>
</CardContent>
</Card>
<!-- 扩展 API 设置 -->
<Card>
<CardHeader>
@@ -1452,6 +1757,68 @@ const toggleSortOrder = () => {
</DialogContent>
</Dialog>
<!-- ===== BT 种子文件勾选弹窗(元数据解析成功后弹出) ===== -->
<Dialog v-model:open="btSelectOpen">
<DialogContent class="max-w-xl max-h-[80vh] flex flex-col">
<DialogHeader>
<DialogTitle class="flex items-center gap-2">
<ListChecks class="size-4 text-primary" />
选择要下载的文件
</DialogTitle>
<DialogDescription class="text-xs">
<span class="block break-all font-medium">"{{ btSelectTask?.filename }}"</span>
<span class="block mt-0.5">已解析完成,默认全选,可取消不需要的文件。</span>
<span v-if="btSelectQueue.length > 1">(还有 {{ btSelectQueue.length - 1 }} 个任务待选择)</span>
</DialogDescription>
</DialogHeader>
<ScrollArea class="flex-1 min-h-0 pr-3">
<div v-if="btSelectTask && btSelectTask.btFiles.length" class="flex flex-col gap-2 py-2">
<div class="flex items-center justify-between gap-2">
<span class="text-xs text-muted-foreground truncate">{{ btSelectTask.btFiles.length }} 个文件</span>
<Button
size="sm"
variant="outline"
class="h-7 px-2 text-xs shrink-0"
@click="btnSetAll(!btnIsAll())"
>
<Check v-if="btnIsAll()" class="size-3" />
<Square v-else class="size-3" />
{{ btnIsAll() ? '全不选' : '全选' }}
</Button>
</div>
<div class="flex flex-col gap-1 max-h-64 overflow-y-auto">
<label
v-for="f in btSelectTask.btFiles"
:key="f.index"
class="flex items-center gap-2 text-xs rounded px-1.5 py-1 hover:bg-muted cursor-pointer"
>
<input
type="checkbox"
class="accent-primary size-3.5"
:checked="btSelectEntry?.selected.includes(f.index)"
@change="btnToggleFile(f.index)"
/>
<span class="truncate flex-1">{{ f.path }}</span>
<span class="text-muted-foreground shrink-0">{{ formatSize(f.size) }}</span>
</label>
</div>
<div class="text-xs text-muted-foreground flex justify-end">
已选 {{ btSelectEntry?.selected.length ?? 0 }}/{{ btSelectTask.btFiles.length }} 文件 · {{ formatSize(btnSelectedSize()) }}
</div>
</div>
</ScrollArea>
<DialogFooter>
<Button variant="outline" @click="onBtFileCancel">取消</Button>
<Button @click="onBtFileConfirm">
<Download class="size-4" />
开始下载
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<!-- ===== 新建下载弹窗 ===== -->
<Dialog v-model:open="addDialogOpen">
<DialogContent class="max-w-lg">
@@ -1461,7 +1828,7 @@ const toggleSortOrder = () => {
新建下载
</DialogTitle>
<DialogDescription class="text-xs">
支持 HTTP/HTTPS 直链,每行一个 URL。留空下载目录则使用默认目录。
支持 HTTP/HTTPS 直链、磁力链接(magnet:)与 .torrent 种子文件(本地文件或 http(s) 链接),每行一个。留空下载目录则使用默认目录。
</DialogDescription>
</DialogHeader>
@@ -1471,9 +1838,18 @@ const toggleSortOrder = () => {
<Label class="text-xs">下载链接</Label>
<Textarea
v-model="addUriText"
placeholder="https://example.com/file.zip&#10;https://example.com/file2.zip"
placeholder="https://example.com/file.zip&#10;magnet:?xt=urn:btih:...&#10;C:\path\to\file.torrent"
class="min-h-[120px] font-mono text-sm [field-sizing:fixed] break-all"
/>
<Button
size="sm"
variant="outline"
class="h-7 gap-1 px-2 text-xs bg-transparent self-start"
@click="handleSelectTorrentFile"
>
<FolderOpen class="size-3" />
选择本地种子文件
</Button>
</div>
<!-- 下载目录 -->
@@ -1529,7 +1905,7 @@ const toggleSortOrder = () => {
<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 class="font-medium break-all">{{ duplicateDialogState.result.existing.filename }}</span>
</span>
<span class="block mt-2 text-muted-foreground">
选择"仍然下载"将自动重命名追加序号选择"跳过"将不下载此链接
@@ -1553,7 +1929,9 @@ const toggleSortOrder = () => {
<AlertDialogHeader>
<AlertDialogTitle>删除任务</AlertDialogTitle>
<AlertDialogDescription>
确定要删除任务 "{{ removeDialogState.task ? getFileName(removeDialogState.task) : '' }}"
确定要删除任务
<span class="block font-mono text-xs break-all mt-1">{{ removeDialogState.task ? getFileName(removeDialogState.task) : '' }}</span>
</AlertDialogDescription>
</AlertDialogHeader>
<div class="flex items-center justify-between py-2 px-1">
@@ -1665,6 +2043,44 @@ const toggleSortOrder = () => {
<Separator />
<!-- BT 种子信息(磁力/种子任务) -->
<template v-if="detailTask.protocol === 'bittorrent'">
<div class="flex flex-col gap-2">
<span class="text-xs text-muted-foreground">BT 种子信息</span>
<div class="flex flex-col gap-1 text-xs">
<div class="flex items-start justify-between gap-2">
<span class="text-muted-foreground shrink-0">Infohash</span>
<span class="font-mono break-all">{{ detailTask.infoHash }}</span>
</div>
<div class="flex justify-between">
<span class="text-muted-foreground">文件数</span>
<span>{{ detailTask.btFiles.length }}</span>
</div>
</div>
<!-- 文件列表 + 单文件进度(progress 事件经 segments 下发每文件已下载字节) -->
<div v-if="detailTask.btFiles.length > 0" class="flex flex-col gap-1.5">
<div
v-for="(f, i) in detailTask.btFiles"
:key="f.index"
class="flex flex-col gap-0.5"
>
<div class="flex items-center justify-between gap-2 text-xs">
<span class="truncate">{{ f.path }}</span>
<span class="text-muted-foreground shrink-0">{{ btFileProgress(detailTask, i) }}%</span>
</div>
<div class="flex items-center gap-2">
<Progress :model-value="btFileProgress(detailTask, i)" class="h-1" />
<span class="text-muted-foreground text-[10px] shrink-0">
{{ formatSize(btFileDownloaded(detailTask, i)) }} / {{ formatSize(f.size) }}
</span>
</div>
</div>
</div>
</div>
</template>
<Separator />
<!-- 文件大小信息 -->
<div class="grid grid-cols-2 gap-3">
<div class="flex flex-col gap-0.5">
+43
View File
@@ -1109,8 +1109,29 @@ function removeOsdItem(key: string) {
}
}
/** 开启 OSD 但监控内核未运行时的提示对话框开关 */
const osdNeedsKernelOpen = ref(false)
/** 从对话框启动内核:先直接开启 OSD(立即显示),内核在后台自行启动 */
async function startKernelForOsd() {
osdNeedsKernelOpen.value = false
// 直接开启 OSD(绕过内联内核检查),store 的 overlayEnabled watch 立即创建/显示悬浮窗
osdConfig.value.overlayEnabled = true
saveOsdConfigDebounced(osdConfig.value)
// 后台启动内核(不 await,不阻塞 OSD 显示;内核就绪后 OSD 自动填充数据)
store.start().catch(() => {})
}
/** OSD 配置项变更时自动保存(防抖:滑块拖动期间不逐帧写盘) */
function updateOsdConfig(field: keyof OsdConfig, value: unknown) {
// 开启 OSD 时,若监控内核未运行,弹出提示并取消本次开启(OSD 依赖内核提供数据)
if (field === 'overlayEnabled' && value === true) {
const kernelRunning = store.status?.running === true
if (!kernelRunning) {
osdNeedsKernelOpen.value = true
return
}
}
;(osdConfig.value as unknown as Record<string, unknown>)[field] = value
saveOsdConfigDebounced(osdConfig.value)
}
@@ -2404,6 +2425,28 @@ watch(() => store.status?.ready, (ready, prev) => {
</DialogContent>
</Dialog>
<!-- 开启 OSD 但监控内核未运行提示 -->
<Dialog v-model:open="osdNeedsKernelOpen">
<DialogContent class="max-w-md">
<DialogHeader>
<DialogTitle class="flex items-center gap-2">
<TriangleAlert class="size-4 text-amber-500" />
需先启动监控内核
</DialogTitle>
<DialogDescription>
OSD 悬浮窗依赖监控内核提供数据。当前内核未运行,请先启动内核后再开启 OSD 显示。
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" @click="osdNeedsKernelOpen = false">取消</Button>
<Button @click="startKernelForOsd">
<Play class="size-3.5" />
启动内核
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<!-- OSD 显示项选择 Dialog -->
<Dialog v-model:open="osdPickDialogOpen">
<DialogContent class="max-w-lg">
+20
View File
@@ -444,6 +444,23 @@ function scheduleMeasure() {
}, 50)
}
/** 内容尺寸监视器:内核未启动时显示占位符(如 '--' 很短),数据就绪后实际值更长,
* 导致 osd-bar 变宽。仅靠配置变化触发测量不够——需监听 osd-bar 尺寸变化,
* 任何内容变宽/变高(数据加载、配置变更)都自动重测上报,驱动主窗口放大。 */
let resizeObserver: ResizeObserver | null = null
function observeBarSize() {
resizeObserver?.disconnect()
resizeObserver = null
const root = osdRootEl.value
const bar = root?.querySelector<HTMLElement>('.osd-bar')
if (!bar) return
resizeObserver = new ResizeObserver(() => scheduleMeasure())
resizeObserver.observe(bar)
}
// 根容器就绪后(config 到达、v-if 挂载)开始监视 osd-bar 尺寸变化
watch(osdRootEl, () => { void nextTick(observeBarSize) })
// ===== 应用鼠标穿透 =====
// 同时调用 Tauri setIgnoreCursorEvents(处理 webview2 子窗口)和 Rust WS_EX_TRANSPARENT(处理原生窗口)
// 仅靠原生 WS_EX_TRANSPARENT 不足:Tauri 窗口包含 webview2 子窗口,需两者都设置才能完全穿透
@@ -537,6 +554,9 @@ onUnmounted(() => {
unlistenFns.forEach(fn => fn())
// 停止监视线程
invoke('osd_stop_watch').catch(() => {})
// 断开内容尺寸监视器
resizeObserver?.disconnect()
resizeObserver = null
})
</script>
+357 -3
View File
@@ -2,7 +2,8 @@
import {
Globe, Play, Square, RotateCw, Power, Zap, Plus, RefreshCw, Trash2,
Check, AlertCircle, Server, Settings as SettingsIcon, ListChecks,
Upload, Link2, Loader2, Download, Timer, Target, FolderOpen, Copy, DownloadCloud
Upload, Link2, Loader2, Download, Timer, Target, FolderOpen, Copy, DownloadCloud,
Waypoints, ArrowDown, ArrowUp, Activity, X
} from '@lucide/vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { toast } from 'vue-sonner'
@@ -10,7 +11,7 @@ import { invoke } from '@tauri-apps/api/core'
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
import { appDataDir } from '@tauri-apps/api/path'
import { revealItemInDir } from '@tauri-apps/plugin-opener'
import { useProxyStore, type ProxyNode } from '@/stores/proxyStore'
import { useProxyStore, type ProxyNode, type ProxyConnection } from '@/stores/proxyStore'
import { useModuleTabs } from '@/lib/use-module-tabs'
import { useModuleTabsStore } from '@/stores/moduleTabsStore'
import { createLogger } from '@/lib/logger'
@@ -84,6 +85,7 @@ const activeTab = ref('overview')
const tabsStore = useModuleTabsStore()
const tabsListRef = useModuleTabs('proxy', activeTab, [
{ value: 'overview', label: '概览' },
{ value: 'connections', label: '连接' },
{ value: 'proxies', label: '节点' },
{ value: 'profiles', label: '订阅' },
{ value: 'settings', label: '设置' }
@@ -141,9 +143,179 @@ const accordionValue = ref<string>('')
// 进程状态轮询
let statusTimer: ReturnType<typeof setInterval> | null = null
let trafficTimer: ReturnType<typeof setInterval> | null = null
let connTimer: ReturnType<typeof setInterval> | null = null
/** 字节 → 人类可读大小(B / KB / MB / GB / TB */
function fmtBytes(v: number): string {
if (!v && v !== 0) return '--'
if (v < 1024) return v + ' B'
const units = ['KB', 'MB', 'GB', 'TB']
let n = v / 1024
let u = 0
while (n >= 1024 && u < units.length - 1) {
n /= 1024
u++
}
return (n >= 100 ? n.toFixed(0) : n >= 10 ? n.toFixed(1) : n.toFixed(2)) + ' ' + units[u]
}
/** 速率显示(字节/秒 → /s) */
function fmtSpeed(v: number): string {
return fmtBytes(v) + '/s'
}
const running = computed(() => store.status.running)
// ===== 连接页签 =====
/** 当前连接列表(store.connections 可能为 null → 视为空) */
const connList = computed(() => store.connections ?? [])
const connFilter = ref('')
/** 内网/国内/国外 一键过滤('all' = 全部) */
const connScopeFilter = ref<'all' | ConnScope>('all')
/** 一键过滤选项 */
const scopeFilterOptions: { value: 'all' | ConnScope; label: string }[] = [
{ value: 'all', label: '全部' },
{ value: 'direct', label: '国内' },
{ value: 'proxy', label: '国外' }
]
/** 顶部下载/上传速率(复用实时流量快照的整体速率) */
const connTotalDownloadSec = computed(() => store.traffic?.downloadSpeed ?? 0)
const connTotalUploadSec = computed(() => store.traffic?.uploadSpeed ?? 0)
/** 命中规则总数 = 活跃连接数(每条连接命中一条规则) */
const ruleHitCount = computed(() => connList.value.length)
/** 按规则聚合当前连接,便于观察哪些规则被频繁命中 */
const ruleHits = computed(() => {
const map = new Map<string, number>()
for (const c of connList.value) {
const r = c.rule || 'DIRECT'
map.set(r, (map.get(r) ?? 0) + 1)
}
return [...map.entries()]
.map(([rule, count]) => ({ rule, count }))
.sort((a, b) => b.count - a.count)
})
/** 按内网/国内/国外、通用关键词过滤后的连接 */
const filteredConnections = computed(() => {
const q = connFilter.value.trim().toLowerCase()
return connList.value.filter((c) => {
if (connScopeFilter.value !== 'all' && connScopeOf(c) !== connScopeFilter.value) return false
if (!q) return true
const process = (c.metadata?.process ?? '').toLowerCase()
const host = (c.metadata?.host ?? '').toLowerCase()
const rule = (c.rule ?? '').toLowerCase()
return process.includes(q) || host.includes(q) || rule.includes(q)
})
})
/** 连接进程显示名 */
const connProcess = (c: ProxyConnection) => c.metadata?.process || '未知'
/** 连接源地址显示(IP:端口) */
const connSource = (c: ProxyConnection) => {
const ip = c.metadata?.sourceIP
const port = c.metadata?.sourcePort
return ip ? `${ip}${port ? ':' + port : ''}` : '--'
}
/** 连接目标显示:优先 host,否则用 IP:端口 */
const connHost = (c: ProxyConnection) => {
const h = c.metadata?.host
if (h) return h
const ip = c.metadata?.destinationIP
const port = c.metadata?.destinationPort
return ip ? `${ip}${port ? ':' + port : ''}` : '--'
}
// ===== 规则中文名 / 内外网判断 =====
/** 规则类型归一化:忽略大小写与 "-" "_" 空格(订阅里可能写成 DomainSuffix / DOMAIN-SUFFIX */
const normRuleType = (s: string) => s.trim().replace(/[-_\s]/g, '').toLowerCase()
const RULE_CN: Record<string, string> = {
// 匹配/动作
match: '兜底', final: '兜底', ruleset: '规则集', direct: '直连', reject: '拒绝',
// 域名
domain: '域名', domainsuffix: '域名后缀', domainkeyword: '域名关键字', domainregex: '域名正则',
// 地理 / 站点
geoip: '地区', geosite: '域名组', ipasn: 'ASN',
// 地址网段
ipcidr: 'IP段', ipcidr6: 'IP段(v6)', srcipcidr: '源IP段', srcipcidr6: '源IP段(v6)',
dstnet: '目标地址', srcnet: '源地址', network: '网络类型',
// 端口
srcport: '源端口', dstport: '目标端口', srcportrange: '源端口范围', dstportrange: '目标端口范围',
// 进程 / 用户
process: '进程', processname: '进程名', processpath: '进程路径', processpathregex: '进程路径正则', uid: '用户ID',
// 入站
intype: '入站类型', inuser: '入站用户', inname: '入站名称', inport: '入站端口',
// 规则集衍生
rulesetipcidr: '规则集IP', rulesetipcidr6: '规则集IP(v6)', rulesetdomainsuffix: '规则集域名后缀',
rulesetdomainkeyword: '规则集域名关键字', rulesetdomainregex: '规则集域名正则', rulesetgeoip: '规则集地区',
// 逻辑
and: '与', not: '非', or: '或', subrule: '子规则'
}
/** 将 mihomo 规则翻译为中文类型名(仅替换类型关键字,保留匹配内容) */
const translateRule = (rule: string): string => {
const parts = rule.split(',')
const mapped = RULE_CN[normRuleType(parts[0])]
if (!mapped) return rule
return [mapped, ...parts.slice(1)].join(',')
}
/**
* 连接走向分类:只区分国内/国外。
* - direct 国内(未走代理;内网/局域网因代理过滤也已直连,归入国内)
* - proxy 国外(已走代理节点;代理多用于访问境外,故视为国外)
* 判定依据:链路最后一跳是否为 DIRECT。
*/
type ConnScope = 'direct' | 'proxy'
const connScopeOf = (c: ProxyConnection): ConnScope => {
const chain = c.chains
if (chain && chain.length) {
return chain[chain.length - 1] === 'DIRECT' ? 'direct' : 'proxy'
}
// 退化:无链路信息时按国内直连兜底
return 'direct'
}
/** 各规则的简短释义(供「规则命中」展示),仅为便于理解,非精确语义 */
const RULE_DESC: Record<string, string> = {
// 匹配/动作
match: '未匹配任何规则时的兜底', final: '未匹配任何规则时的兜底', direct: '直连', reject: '拒绝访问',
// 域名
domain: '完全匹配该域名', domainsuffix: '匹配该域名及其子域名', domainkeyword: '域名包含该关键词', domainregex: '域名按正则匹配',
// 地理 / 站点
geoip: '按 IP 所属国家/地区', geosite: '按域名所属站点类别', ipasn: '按 IP 所属 ASN 自治域',
// 地址网段
ipcidr: '匹配该 IP 网段', ipcidr6: '匹配该 IPv6 网段', srcipcidr: '按源 IP 网段', srcipcidr6: '按源 IPv6 网段',
dstnet: '按目标 IP/域名', srcnet: '按源 IP/域名', network: '按网络类型(TCP/UDP',
// 端口
srcport: '按源端口', dstport: '按目标端口', srcportrange: '按源端口范围', dstportrange: '按目标端口范围',
// 进程 / 用户
process: '按进程', processname: '按进程名', processpath: '按进程可执行路径', processpathregex: '按进程路径正则',
uid: '按 Linux 用户 ID',
// 入站
intype: '按入站类型', inuser: '按入站用户', inname: '按入站名称', inport: '按入站端口',
// 规则集衍生
ruleset: '按规则集内容匹配',
rulesetipcidr: '匹配规则集中任一 IP 网段', rulesetipcidr6: '匹配规则集中任一 IPv6 网段',
rulesetdomainsuffix: '匹配规则集中任一域名后缀', rulesetdomainkeyword: '匹配规则集中任一域名关键字',
rulesetdomainregex: '匹配规则集正则', rulesetgeoip: '匹配规则集中任一地区',
// 逻辑
and: '多个条件同时满足(与)', or: '任一条件满足(或)', not: '取反(非)', subrule: '子规则分发'
}
/** 取了某条规则的类型释义;未知类型返回空串 */
const ruleDesc = (rule: string): string => {
return RULE_DESC[normRuleType(rule.split(',')[0])] ?? ''
}
/** 断开全部连接 */
const closeAllConnections = async () => {
const ok = await showConfirm({
title: '断开全部连接',
description: `确定断开当前 ${connList.value.length} 条活跃连接吗?`,
confirmText: '断开',
destructive: true
})
if (!ok) return
for (const c of [...connList.value]) {
await store.closeConnection(c.id).catch(() => {})
}
}
// 伪节点关键词:DIRECT/REJECT/流量/套餐等非具体代理节点
const PSEUDO_NODE_KEYWORDS = [
'DIRECT', 'REJECT', 'PASS', 'COMPATIBLE',
@@ -353,6 +525,16 @@ onMounted(() => {
// 同步系统代理真实状态(注册表可能被外部改动,3s 周期足够感知)
await store.refreshSystemProxy()
}, 3000)
// 流量采样:运行中每秒拉取一次实时速率/累计流量
trafficTimer = setInterval(async () => {
if (document.hidden) return
if (running.value) await store.refreshTraffic()
}, 1000)
// 连接列表:仅「连接」页签激活且运行时低频拉取
connTimer = setInterval(async () => {
if (document.hidden) return
if (activeTab.value === 'connections' && running.value) await store.refreshConnections()
}, 3000)
// 页面重新可见时立即刷新一次系统代理状态(切回标签页/从托盘返回主窗口)
document.addEventListener('visibilitychange', onVisibilityChange)
// 监听后端自动切换节点完成事件(后台执行,不依赖模块激活)
@@ -363,6 +545,8 @@ onMounted(() => {
onUnmounted(() => {
if (statusTimer) clearInterval(statusTimer)
if (trafficTimer) clearInterval(trafficTimer)
if (connTimer) clearInterval(connTimer)
document.removeEventListener('visibilitychange', onVisibilityChange)
autoSwitchUnlisten.forEach(fn => fn())
autoSwitchUnlisten = []
@@ -960,8 +1144,9 @@ tabsStore.registerSave(saveSettingsForm)
<div class="h-full p-6">
<Tabs v-model="activeTab" class="h-full flex flex-col">
<div ref="tabsListRef">
<TabsList class="grid w-full grid-cols-4 max-w-md !bg-transparent !p-0 !shadow-none">
<TabsList class="grid w-full grid-cols-5 max-w-md !bg-transparent !p-0 !shadow-none">
<TabsTrigger value="overview" class="gap-1.5"><Globe class="size-3.5" />概览</TabsTrigger>
<TabsTrigger value="connections" class="gap-1.5"><Waypoints class="size-3.5" />连接</TabsTrigger>
<TabsTrigger value="proxies" class="gap-1.5"><Server class="size-3.5" />节点</TabsTrigger>
<TabsTrigger value="profiles" class="gap-1.5"><ListChecks class="size-3.5" />订阅</TabsTrigger>
<TabsTrigger value="settings" class="gap-1.5"><SettingsIcon class="size-3.5" />设置</TabsTrigger>
@@ -972,6 +1157,42 @@ tabsStore.registerSave(saveSettingsForm)
<TabsContent value="overview" class="flex-1 mt-4 tab-animate">
<ScrollArea class="h-full pr-3">
<div class="columns-1 md:columns-2 gap-4 [&>*]:mb-4 [&>*]:break-inside-avoid">
<!-- 实时流量 -->
<Card>
<CardHeader>
<CardTitle class="flex items-center justify-between text-base">
<span class="flex items-center gap-2"><Activity class="size-4 text-primary" />实时流量</span>
<Badge v-if="running" variant="outline" class="gap-1 text-xs">
<span class="size-1.5 rounded-full bg-emerald-500" />实时更新
</Badge>
</CardTitle>
</CardHeader>
<CardContent class="space-y-4 text-sm">
<div class="grid grid-cols-2 gap-4">
<div>
<div class="flex items-center gap-1 text-muted-foreground text-xs mb-1">
<ArrowDown class="size-3.5 text-emerald-500" />下载
</div>
<p class="text-lg font-semibold tabular-nums">{{ fmtSpeed(store.traffic?.downloadSpeed ?? 0) }}</p>
<p class="text-xs text-muted-foreground tabular-nums">累计 {{ store.traffic ? fmtBytes(store.traffic.downloadTotal) : '--' }}</p>
</div>
<div>
<div class="flex items-center gap-1 text-muted-foreground text-xs mb-1">
<ArrowUp class="size-3.5 text-rose-500" />上传
</div>
<p class="text-lg font-semibold tabular-nums">{{ fmtSpeed(store.traffic?.uploadSpeed ?? 0) }}</p>
<p class="text-xs text-muted-foreground tabular-nums">累计 {{ store.traffic ? fmtBytes(store.traffic.uploadTotal) : '--' }}</p>
</div>
</div>
<Separator />
<div class="flex items-center justify-between">
<span class="flex items-center gap-1.5 text-muted-foreground">
<Waypoints class="size-3.5" />活跃连接
</span>
<span class="font-semibold tabular-nums">{{ store.traffic?.activeConnections ?? '--' }}</span>
</div>
</CardContent>
</Card>
<!-- 内核状态 -->
<Card>
<CardHeader>
@@ -1436,6 +1657,139 @@ tabsStore.registerSave(saveSettingsForm)
</ScrollArea>
</TabsContent>
<!-- 连接 -->
<TabsContent value="connections" class="flex-1 mt-4 tab-animate">
<div v-if="!running" key="conn-not-running" class="h-full flex flex-col items-center justify-center text-muted-foreground gap-2 pt-10 pr-10">
<Waypoints class="size-12 opacity-30" />
<p class="text-sm">mihomo 未运行,请先在概览页启动</p>
</div>
<div v-else class="h-full flex flex-col gap-4 pr-3">
<!-- 顶部统计 -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-2">
<Card>
<CardContent class="py-3">
<div class="flex items-center gap-1.5 text-muted-foreground text-xs mb-1"><Waypoints class="size-3.5" />活跃连接</div>
<p class="text-xl font-semibold tabular-nums">{{ connList.length }}</p>
</CardContent>
</Card>
<Card>
<CardContent class="py-3">
<div class="flex items-center gap-1.5 text-muted-foreground text-xs mb-1"><ArrowDown class="size-3.5 text-emerald-500" />下载速率</div>
<p class="text-xl font-semibold tabular-nums">{{ fmtSpeed(connTotalDownloadSec) }}</p>
</CardContent>
</Card>
<Card>
<CardContent class="py-3">
<div class="flex items-center gap-1.5 text-muted-foreground text-xs mb-1"><ArrowUp class="size-3.5 text-rose-500" />上传速率</div>
<p class="text-xl font-semibold tabular-nums">{{ fmtSpeed(connTotalUploadSec) }}</p>
</CardContent>
</Card>
<Card>
<CardContent class="py-3">
<div class="flex items-center gap-1.5 text-muted-foreground text-xs mb-1"><Target class="size-3.5" />命中规则</div>
<p class="text-xl font-semibold tabular-nums">{{ ruleHitCount }}</p>
</CardContent>
</Card>
</div>
<!-- 规则命中分布 -->
<Card>
<CardHeader class="pb-2">
<CardTitle class="text-sm flex items-center gap-2"><Target class="size-3.5 text-primary" />规则命中</CardTitle>
</CardHeader>
<CardContent class="pt-0">
<div v-if="!ruleHits.length" class="text-xs text-muted-foreground py-2">暂无连接</div>
<div v-else class="space-y-1.5">
<div v-for="r in ruleHits.slice(0, 6)" :key="r.rule" class="flex items-baseline gap-2 text-xs">
<span class="w-1.5 h-1.5 rounded-full bg-primary shrink-0 self-center" />
<span class="font-medium shrink-0">{{ translateRule(r.rule) }}</span>
<span class="flex-1 min-w-0 truncate text-muted-foreground">
<template v-if="ruleDesc(r.rule)">{{ ruleDesc(r.rule) }}</template>
</span>
<span class="shrink-0 tabular-nums">×{{ r.count }}</span>
</div>
</div>
</CardContent>
</Card>
<!-- 连接列表 -->
<Card class="flex-1 min-h-0 flex flex-col">
<CardHeader class="pb-2 space-y-2">
<div class="flex items-center justify-between gap-3">
<CardTitle class="text-sm flex items-center gap-2"><Waypoints class="size-3.5 text-primary" />当前连接</CardTitle>
<div class="flex items-center gap-2">
<Input v-model="connFilter" placeholder="按进程/域名/规则过滤" class="h-8 w-56" />
<Button size="xs" variant="outline" :disabled="!connList.length" @click="closeAllConnections">
<Square class="size-3" />断开全部
</Button>
</div>
</div>
<div class="flex flex-wrap items-center gap-1.5">
<span class="text-xs text-muted-foreground">走向:</span>
<Button
v-for="s in scopeFilterOptions"
:key="s.value"
size="xs"
:variant="connScopeFilter === s.value ? 'default' : 'outline'"
@click="connScopeFilter = s.value"
>{{ s.label }}</Button>
</div>
</CardHeader>
<CardContent class="flex-1 min-h-0 overflow-hidden pt-0">
<ScrollArea class="h-full">
<table class="w-full text-xs">
<thead class="sticky top-0 z-10 bg-card text-muted-foreground">
<tr class="border-b">
<th class="text-left font-medium py-2 px-2">进程 / 源地址</th>
<th class="text-left font-medium py-2 px-2">目标</th>
<th class="text-left font-medium py-2 px-2">规则</th>
<th class="text-right font-medium py-2 px-2">下载</th>
<th class="text-right font-medium py-2 px-2">上传</th>
<th class="text-center font-medium py-2 px-2"></th>
</tr>
</thead>
<tbody>
<tr v-for="c in filteredConnections" :key="c.id" class="border-b last:border-0 hover:bg-muted/40">
<td class="py-2 px-2 align-baseline">
<span class="font-medium truncate block max-w-[140px]">{{ connProcess(c) }}</span>
<span class="text-muted-foreground">{{ connSource(c) }}</span>
</td>
<td class="py-2 px-2 align-baseline">
<div class="flex items-center gap-1.5">
<Badge
v-if="connScopeOf(c) === 'direct'"
variant="outline" class="h-4 px-1.5 text-[10px] shrink-0 border-emerald-500 text-emerald-500"
>国内</Badge>
<Badge
v-else
variant="outline" class="h-4 px-1.5 text-[10px] shrink-0 border-sky-500 text-sky-500"
>国外</Badge>
<span class="truncate block max-w-[140px]">{{ connHost(c) }}</span>
</div>
<span class="text-muted-foreground">{{ c.metadata?.network }} / {{ c.metadata?.type }}</span>
</td>
<td class="py-2 px-2 align-baseline text-muted-foreground">
<span class="truncate block max-w-[160px]">{{ translateRule(c.rule || 'DIRECT') }}</span>
</td>
<td class="py-2 px-2 text-right tabular-nums align-baseline">{{ fmtBytes(c.download) }}</td>
<td class="py-2 px-2 text-right tabular-nums align-baseline">{{ fmtBytes(c.upload) }}</td>
<td class="py-2 px-2 text-center align-baseline">
<Button size="icon" variant="ghost" class="size-6" title="断开连接" @click="store.closeConnection(c.id)">
<X class="size-3.5" />
</Button>
</td>
</tr>
<tr v-if="!connList.length">
<td colspan="6" class="text-center text-muted-foreground py-8">暂无活跃连接</td>
</tr>
</tbody>
</table>
</ScrollArea>
</CardContent>
</Card>
</div>
</TabsContent>
<!-- 节点 -->
<TabsContent value="proxies" class="flex-1 mt-4 tab-animate">
<div v-if="!running" key="not-running" class="h-full flex flex-col items-center justify-center text-muted-foreground gap-2 pt-10 pr-10">
+16 -4
View File
@@ -426,13 +426,25 @@ onMounted(async () => {
mq.addEventListener('change', onThemeChange)
unlistenFns.push(() => mq.removeEventListener('change', onThemeChange))
// 右键显示的合并窗口:浏览器/线程内收到 tray-menu-show(基础状态、菜单较小)后,
// 不给 150ms 窗口等完整状态(含节点)到达,再一次性定位显示最终尺寸。
// 避免"先以小菜单显示 → 节点数据到达 → 可见后二次 resize"造成的闪烁。
let menuJustShown = 0 // 本次显示窗口内是否处于"等待合并完整状态"阶段
let finalizeTimer = 0
unlistenFns.push(await listen<TrayMenuState>('tray-menu-show', async (event) => {
await applyTheme()
Object.assign(state, event.payload)
osdVisible.value = readOsdVisible()
// 显示前重置上次残留的下拉/焦点状态(双保险)
resetMenuState()
await measureAndShow()
// 开始合并窗口:等待完整状态,结束时才显示
menuJustShown = Date.now()
window.clearTimeout(finalizeTimer)
finalizeTimer = window.setTimeout(async () => {
menuJustShown = 0
await measureAndShow()
}, 150)
}))
// 菜单失焦(点击其他位置自动隐藏)时重置下拉框与焦点,避免下次打开时残留
@@ -440,12 +452,12 @@ onMounted(async () => {
if (!focused) resetMenuState()
}))
// 仅更新状态数据,不重新显示窗口。
// - 显示流程的合并窗口内:完整状态补充到达,仅合并数据,让 finalizeTimer 统一显示
// - 动作完成后的状态更新:菜单已隐藏,只更新数据(不重新弹出)
// - 右键后完整状态补充到达(基础状态先行显示,节点数据异步跟上):
// 菜单可见时重新测量调整窗口尺寸(tray_menu_ready 幂等,重新定位+resize,不会重复显示动画)
// - 超时后(mihomo 慢)node 数据补充到达且菜单已可见:重新测量调整尺寸(兜底)
unlistenFns.push(await listen<TrayMenuState>('tray-menu-state-updated', async (event) => {
Object.assign(state, event.payload)
if (menuJustShown) return // 正处于首次显示的合并窗口,等待 finalize 统一显示
try {
const visible = await getCurrentWindow().isVisible()
if (visible) await measureAndShow()