优化调整、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
+57 -9
View File
@@ -14,8 +14,8 @@ import { useProcessStore } from '@/stores/processStore'
import { TooltipProvider } from '@/components/ui/tooltip'
import { moduleRegistry } from '@/modules/registry'
import type { ModuleMeta } from '@/types/module'
import { pendingNewDownload, pendingShowDownloadTasks } from '@/lib/trayEvents'
import { EVENTS, STORAGE_KEYS } from '@/lib/constants'
import { pendingNewDownload } from '@/lib/trayEvents'
import { EVENTS, STORAGE_KEYS, WINDOWS } from '@/lib/constants'
import { commands } from '@/lib/bindings'
const appStore = useAppStore()
@@ -104,6 +104,56 @@ const handleSearch = (moduleId: string) => {
handleModuleChange(moduleId)
}
// 为单个下载任务创建专属的一次性下载窗口(浏览器扩展发起)。
// label 带 task id 保证同时多个下载时各占一个窗口;对应 capabilities/download-window.json 的 glob "download-window-*"
async function openDownloadWindow(taskId: string) {
const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow')
const { currentMonitor } = await import('@tauri-apps/api/window')
const label = `${WINDOWS.downloadWindow}-${taskId}`
try {
const existing = await WebviewWindow.getByLabel(label)
if (existing) {
// 已存在:用 Rust 端强制置前(绕过前台锁定,双屏/后台创建也能到前台)
await commands.downloaderFocusWindow(label)
return
}
// 定位到主窗口当前所在显示器的中央偏上
const monitor = await currentMonitor()
const scale = monitor?.scaleFactor ?? 1
const w = 420
const h = 176
const x = Math.round(((monitor?.size.width ?? 1920) / scale - w) / 2)
const y = Math.round(((monitor?.size.height ?? 1080) / scale - h) / 2 * 0.8)
const win = new WebviewWindow(label, {
url: `index.html#download-window?task=${encodeURIComponent(taskId)}`,
title: '下载',
width: w,
height: h,
x,
y,
decorations: false,
transparent: true,
resizable: false,
maximizable: false,
minimizable: true,
shadow: true,
visible: false,
focus: false,
// 默认不置顶、放入任务栏(可最小化,任务栏图标唤出);下载完成时窗口置前提醒。
// 隐藏创建:由 DownloadWindow 贴合内容高度后一次性 show,避免显示后再 resize 闪烁
})
win.once('tauri://error', (e) => console.error('创建下载窗口失败:', e))
// 窗口改为隐藏创建:由 DownloadWindow 在 onMounted 贴合内容高度后一次性 show,
// 避免"先以 176 高度显示、再 resize 到内容高度"造成的闪烁。
// 此处仅保留异常兜底:WebView 加载异常导致 DownloadWindow 未 reveal 时,强制显示。
win.once('tauri://created', () => {
window.setTimeout(() => { void commands.downloaderFocusWindow(label) }, 2500)
})
} catch (e) {
console.error('创建下载窗口失败:', e)
}
}
const getFallbackModule = () => {
const enabledIds = appStore.enabledModules.map(m => m.id)
const fallback = moduleRegistry.getAllMetas().find(
@@ -198,14 +248,12 @@ onMounted(async () => {
}
})
)
// 浏览器扩展新增下载:直接切到下载模块的任务列表页(主窗口已由 Rust 端置前)
// 浏览器扩展新增下载:为该任务创建一个专属的一次性下载窗口(不打断主界面)。
// 主窗口本身无需置前,下载进度/完成事件由独立窗口自行监听。
trayUnlisteners.push(
await listen(EVENTS.downloadExtensionAdded, () => {
const enabledIds = appStore.enabledModules.map(m => m.id)
if (enabledIds.includes('downloader') || moduleRegistry.getConfig('downloader')?.builtin) {
pendingShowDownloadTasks.value = true
handleModuleChange('downloader')
}
await listen<{ id: string }>(EVENTS.downloadExtensionAdded, (e) => {
if (!e.payload?.id) return
void openDownloadWindow(e.payload.id)
})
)
trayUnlisteners.push(
+1 -1
View File
@@ -27,7 +27,7 @@ watch(
<template>
<main
ref="containerRef"
class="flex-1"
class="flex-1 min-w-0"
:style="{ backgroundColor: 'var(--effect-bg)', backdropFilter: 'var(--effect-blur)' }"
>
<ScrollArea data-main-scroll class="h-full w-full">
+10
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { ref } from "vue"
import { useVModel } from "@vueuse/core"
import { cn } from "@/lib/utils"
@@ -17,10 +18,19 @@ const modelValue = useVModel(props, "modelValue", emits, {
passive: true,
defaultValue: props.defaultValue,
})
// 暴露原生 input 元素与 focus,供父级 `ref="xx"` 后调用 xx.focus()
// (组件默认不转发,ref 拿到的是组件实例,调用 .focus() 会报 "focus is not a function"
const inputEl = ref<HTMLInputElement | null>(null)
defineExpose({
focus: () => inputEl.value?.focus(),
element: () => inputEl.value,
})
</script>
<template>
<input
ref="inputEl"
v-model="modelValue"
data-slot="input"
:class="cn(
+81 -4
View File
@@ -43,6 +43,7 @@ export const commands = {
proxyStart: () => __TAURI_INVOKE<ProcessInfo>("proxy_start"),
proxyStatus: () => __TAURI_INVOKE<ProxyStatus>("proxy_status"),
proxyStop: () => __TAURI_INVOKE<null>("proxy_stop"),
proxyTraffic: () => __TAURI_INVOKE<TrafficSnapshot>("proxy_traffic"),
proxyTestDelay: (name: string, url: string | null, timeout: number | null) => __TAURI_INVOKE<number>("proxy_test_delay", { name, url, timeout }),
proxyUpdateProfile: (id: string) => __TAURI_INVOKE<ProfileMeta>("proxy_update_profile", { id }),
/** 读取快速面板设置(快捷键等) */
@@ -198,11 +199,15 @@ export const commands = {
/** 检查 URL 重复性并探测文件信息(添加下载前调用) */
downloaderCheckUrl: (url: string, dir: string | null, headers: { [key in string]: string } | null) => __TAURI_INVOKE<CheckUrlResult>("downloader_check_url", { url, dir, headers }),
/** 添加下载任务 */
downloaderAddTask: (url: string, filename: string | null, dir: string | null, headers: { [key in string]: string } | null, autoRename: boolean | null) => __TAURI_INVOKE<string>("downloader_add_task", { url, filename, dir, headers, autoRename }),
downloaderAddTask: (url: string, filename: string | null, dir: string | null, headers: { [key in string]: string } | null, autoRename: boolean | null, onlyFiles: number[] | null) => __TAURI_INVOKE<string>("downloader_add_task", { url, filename, dir, headers, autoRename, onlyFiles }),
/** 暂停任务 */
downloaderPauseTask: (id: string) => __TAURI_INVOKE<null>("downloader_pause_task", { id }),
/** 恢复任务 */
downloaderResumeTask: (id: string) => __TAURI_INVOKE<null>("downloader_resume_task", { id }),
/** 取消任务(置为已取消,清空进度并删除下载文件,但保留记录) */
downloaderCancelTask: (id: string) => __TAURI_INVOKE<null>("downloader_cancel_task", { id }),
/** 重新下载已取消/出错的任务 */
downloaderRedownload: (id: string) => __TAURI_INVOKE<null>("downloader_redownload", { id }),
/** 移除任务 */
downloaderRemoveTask: (id: string, deleteFiles: boolean | null) => __TAURI_INVOKE<null>("downloader_remove_task", { id, deleteFiles }),
/** 获取设置 */
@@ -213,6 +218,17 @@ export const commands = {
downloaderOpenDir: (path: string) => __TAURI_INVOKE<null>("downloader_open_dir", { path }),
/** 用系统默认浏览器打开 URL */
downloaderOpenUrl: (url: string) => __TAURI_INVOKE<null>("downloader_open_url", { url }),
/**
* 将指定 label 的下载窗口显示并强制置为前台。
* Tauri 的 set_focus 在 Windows 上受前台锁定限制(尤其下载窗口由后台进程创建、
* 或创建到非主显示器时更明显),改用原生 SetForegroundWindow + BringWindowToTop
* (模拟 Alt 键重置前台锁定),保证开始/完成下载时窗口能正确定位到前台。
*/
downloaderFocusWindow: (label: string) => __TAURI_INVOKE<null>("downloader_focus_window", { label }),
/** 解析磁力链 / .torrent 文件,返回种子信息(名称 / infohash / 文件列表),供前端做文件勾选 */
downloaderInspect: (input: string) => __TAURI_INVOKE<TorrentInfo>("downloader_inspect", { input }),
/** 磁力任务:元数据解析成功后,用户勾选文件并确认开始下载 */
downloaderSelectBtFiles: (id: string, onlyFiles: number[]) => __TAURI_INVOKE<null>("downloader_select_bt_files", { id, onlyFiles }),
/** 禁用指定窗口(按 label 查找)的显示/隐藏过渡动画,消除覆盖层出现/消失时的缩放动画 */
screenshotDisableTransitions: (label: string) => __TAURI_INVOKE<null>("screenshot_disable_transitions", { label }),
/** 一次 IPC 完成指定窗口的显示与聚焦(截图覆盖层显示关键路径,减少串行往返) */
@@ -280,6 +296,16 @@ export type ArchiveInfo = {
size: number,
};
/** BT 种子内文件条目(多文件任务用;阶段1下载全部文件,但保留列表供 UI 展示) */
export type BtFileInfo = {
/** 文件在种子内的索引 */
index: number,
/** 相对种子根目录的路径(如 "sub/file.mkv" */
path: string,
/** 文件大小(字节) */
size: number,
};
/** 前端可见的捕获数据 */
export type CaptureData = {
pngBase64: string,
@@ -372,12 +398,20 @@ export type DeleteResult = {
export type DownloadTask = {
/** 任务 ID(自增 hex 字符串) */
id: string,
/** 下载地址 */
/** 下载地址HTTP URL 或磁力链接) */
url: string,
/** 文件名 */
/** 文件名(HTTP:目标文件名;BT:种子名称) */
filename: string,
/** 保存目录(绝对路径) */
dir: string,
/** 协议类型 */
protocol?: TaskProtocol,
/** BT 种子 infohash(协议=BitTorrent 时存在) */
infoHash?: string | null,
/** BT 种子内文件列表(协议=BitTorrent 时存在) */
btFiles?: BtFileInfo[],
/** BT 元数据是否已解析就绪(异步添加时:后台解析完成前为 false,调度器跳过) */
btMetadataReady?: boolean,
/** 状态 */
status: TaskStatus,
/** 文件总大小(字节),0=未知 */
@@ -420,6 +454,14 @@ export type DownloaderSettings = {
checkDuplicate?: boolean,
/** 下载是否使用代理:true=尊重系统代理(mihomo 开启系统代理时经其转发),false=强制直连 */
useProxy?: boolean,
/** BitTorrent 上传限速 KB/s0=不限) */
btUploadLimitKb?: number,
/** BitTorrent 下载完成后是否继续做种上传(false=下载完即停止上传) */
btSeedAfterDownload?: boolean,
/** BitTorrent 监听端口(0=自动选择) */
btListenPort?: number,
/** BitTorrent 使用代理下载:开启后自动使用代理模块(mihomo)的 SOCKS5 端口;代理不可用时降级直连 */
btUseProxy?: boolean,
};
/** 重复类型 */
@@ -604,6 +646,13 @@ export type SpecialLocation = {
args: string[],
};
/** 任务下载协议类型 */
export type TaskProtocol =
/** HTTP/HTTPS 直链 */
"http" |
/** BitTorrent(磁力链 / .torrent 文件) */
"bittorrent";
/** 任务状态 */
export type TaskStatus =
/** 排队等待(并发数已满) */
@@ -615,7 +664,35 @@ export type TaskStatus =
/** 已完成 */
"complete" |
/** 错误 */
"error";
"error" |
/** 已取消(用户取消:进度与文件已清除,仅保留记录,只能再次下载) */
"cancelled";
/** 种子信息(inspect 解析结果,供命令返回给前端做文件勾选) */
export type TorrentInfo = {
/** 种子名称 */
name: string,
/** infohashhex 小写字符串) */
infoHash: string,
/** 种子内全部文件总大小(字节) */
totalSize: number,
/** 种子内文件列表 */
files: BtFileInfo[],
};
/** 实时流量快照(由 /connections 的会话总量差分得出实时速率) */
export type TrafficSnapshot = {
/** 本次会话累计下载字节数 */
downloadTotal: number,
/** 本次会话累计上传字节数 */
uploadTotal: number,
/** 实时下载速率(字节/秒) */
downloadSpeed: number,
/** 实时上传速率(字节/秒) */
uploadSpeed: number,
/** 当前活跃连接数 */
activeConnections: number,
};
/** release 中的一个资产 */
export type UpdateAsset = {
+5 -1
View File
@@ -10,6 +10,8 @@ export const WINDOWS = {
osdOverlay: 'osd-overlay',
screenshotOverlay: 'screenshot-overlay',
screenshotPin: 'screenshot-pin',
/** 单文件一次性下载窗口前缀,实际 label = `${downloadWindow}-<taskId>` */
downloadWindow: 'download-window',
} as const
/** Tauri 事件名(前端 emit / listen 与 Rust constants::events 对应) */
@@ -72,7 +74,7 @@ export const EVENTS = {
downloadAdded: 'download-added',
/** 任务被删除(浏览器扩展通过 HTTP API 删除任务时发出,前端据此刷新任务列表) */
downloadRemoved: 'download-removed',
/** 浏览器扩展通过 HTTP API 新增下载(置前主窗口并跳到下载画面 */
/** 浏览器扩展通过 HTTP API 新增下载(负载 { id },前端据以为该任务创建专属下载窗口 */
downloadExtensionAdded: 'download-extension-added',
} as const
@@ -94,6 +96,8 @@ export const STORAGE_KEYS = {
quickpanelDeleteFilterFavs: 'thing_quickpanel_delete_filter_favs',
currencyRates: 'thing_quickpanel_currency_rates',
monitorOsdConfig: 'thing_monitor_osd_config',
/** 关闭"自动启动监控内核"时暂存的 OSD 开关状态(开启自动启动时据此恢复) */
monitorOsdPending: 'thing_monitor_osd_pending',
monitorOverviewCards: 'thing_monitor_overview_cards',
screenshotHistory: 'thing_screenshot_history',
screenshotPinIndex: 'thing_screenshot_pin_index',
+6 -4
View File
@@ -31,14 +31,16 @@ const standaloneWindowApps: Array<[hash: string, label: string, loader: () => Pr
['#screenshot-overlay', '截图覆盖层', () => import('./modules/screenshot/ScreenshotOverlay.vue')],
['#screenshot-editor', '截图编辑器', () => import('./modules/screenshot/ScreenshotEditor.vue')],
['#screenshot-pin', '贴图窗口', () => import('./modules/screenshot/ScreenshotPin.vue')],
['#download-window', '下载窗口', () => import('./modules/downloader/DownloadWindow.vue')],
]
const winHash = window.location.hash
// #screenshot-overlay 带窗口号参数(多屏),按前缀匹配;其余精确匹配
const matched = standaloneWindowApps.find(([hash]) =>
hash === '#screenshot-overlay' ? winHash.startsWith(hash) : winHash === hash
)
// #screenshot-overlay 带窗口号参数(多屏)、#download-window 带 ?task= 参数,按前缀匹配;其余精确匹配
const matched = standaloneWindowApps.find(([hash]) => {
if (hash === '#screenshot-overlay' || hash === '#download-window') return winHash.startsWith(hash)
return winHash === hash
})
if (matched) {
const [, label, loader] = matched
+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()
+82 -7
View File
@@ -9,6 +9,7 @@ import type {
DownloadTask as BindDownloadTask,
DownloaderSettings as BindDownloaderSettings,
CheckUrlResult as BindCheckUrlResult,
TorrentInfo as BindTorrentInfo,
TaskStatus,
} from '@/lib/bindings'
@@ -19,11 +20,14 @@ const logger = createLogger('downloader')
export type DownloadTask = Required<BindDownloadTask>
export type DownloaderSettings = Required<BindDownloaderSettings>
export type CheckUrlResult = Required<BindCheckUrlResult>
export type TorrentInfo = Required<BindTorrentInfo>
// re-export:跨端类型统一由 bindings 提供,组件从本 store import 的路径保持不变
export type {
TaskStatus,
TaskProtocol,
Segment,
BtFileInfo,
DuplicateKind,
ExistingTaskInfo,
} from '@/lib/bindings'
@@ -71,6 +75,10 @@ export const useDownloaderStore = defineStore('downloader', () => {
let completeUnlisten: UnlistenFn | null = null
let addedUnlisten: UnlistenFn | null = null
let removedUnlisten: UnlistenFn | null = null
let inspectReadyUnlisten: UnlistenFn | null = null
/** 磁力元数据解析就绪回调(模块注册,用于弹文件勾选对话框) */
let btInspectReadyHandler: ((id: string) => void) | null = null
// ===== 任务列表 =====
const refreshTasks = async () => {
@@ -85,13 +93,26 @@ export const useDownloaderStore = defineStore('downloader', () => {
if (
freshTask.status === 'paused' ||
freshTask.status === 'complete' ||
freshTask.status === 'error'
freshTask.status === 'error' ||
freshTask.status === 'cancelled'
) {
return freshTask
}
const local = tasks.value.find(t => t.id === freshTask.id)
if (!local) return freshTask
return { ...local, status: freshTask.status, error: freshTask.error }
// 进度/速度沿用本地实时值;但元数据字段(文件名/文件列表/总大小/infohash
// 必须以后端为准 —— 这些只在后台解析完成后才就绪,本地快照在添加时是占位空值,
// 若沿用会导致"元数据已解析但勾选对话框仍无文件列表"(旧快照覆盖新元数据)
return {
...local,
status: freshTask.status,
error: freshTask.error,
filename: freshTask.filename,
btFiles: freshTask.btFiles,
btMetadataReady: freshTask.btMetadataReady,
infoHash: freshTask.infoHash,
totalSize: freshTask.totalSize,
}
})
tasks.value = merged
} catch (e) {
@@ -105,9 +126,15 @@ export const useDownloaderStore = defineStore('downloader', () => {
const task = tasks.value.find((t) => t.id === payload.id)
if (task) {
// 终态任务忽略迟到的进度事件(下载完成后 in-flight 事件可能把状态/进度回退)
if (task.status === 'complete' || task.status === 'error') return
// 已暂停任务忽略仍携带 active 的迟到事件(暂停瞬间发出的旧事件)
if (task.status === 'paused' && payload.status === 'active') return
if (task.status === 'complete' || task.status === 'error' || task.status === 'cancelled') return
// 已暂停任务忽略"停止瞬间残留的 active 心跳"(无速度且进度未变化的迟到事件)。
// 真正恢复下载后发来的 active(有速度或进度增长)必须放行,否则恢复后列表一直停留在暂停态
if (
task.status === 'paused' &&
payload.status === 'active' &&
payload.speed <= 0 &&
payload.completedSize <= task.completedSize
) return
task.completedSize = payload.completedSize
task.totalSize = payload.totalSize
task.speed = payload.speed
@@ -134,19 +161,32 @@ export const useDownloaderStore = defineStore('downloader', () => {
filename?: string,
dir?: string,
headers?: Record<string, string>,
autoRename = false
autoRename = false,
onlyFiles?: number[]
): Promise<string> => {
const id = await commands.downloaderAddTask(
url,
filename || null,
dir || null,
headers || null,
autoRename
autoRename,
onlyFiles || null
)
await refreshTasks()
return id
}
/** 解析磁力链 / .torrent,返回种子信息(文件勾选用) */
const inspect = async (input: string): Promise<TorrentInfo> => {
return await commands.downloaderInspect(input)
}
/** 磁力任务元数据解析成功后:设置勾选文件并开始下载 */
const selectBtFiles = async (id: string, onlyFiles: number[]) => {
await commands.downloaderSelectBtFiles(id, onlyFiles)
await refreshTasks()
}
/** 检查 URL 重复性并探测文件信息 */
const checkUrl = async (
url: string,
@@ -171,6 +211,18 @@ export const useDownloaderStore = defineStore('downloader', () => {
await refreshTasks()
}
/** 取消下载:置为已取消、清空进度并删除下载文件,但保留记录 */
const cancelTask = async (id: string) => {
await commands.downloaderCancelTask(id)
await refreshTasks()
}
/** 重新下载已取消/出错的任务 */
const redownload = async (id: string) => {
await commands.downloaderRedownload(id)
await refreshTasks()
}
// ===== 设置 =====
const loadSettings = async () => {
try {
@@ -250,6 +302,15 @@ export const useDownloaderStore = defineStore('downloader', () => {
refreshTasks()
})
}
if (!inspectReadyUnlisten) {
inspectReadyUnlisten = await listen<{ id: string }>('download-inspect-ready', async (e) => {
// 磁力元数据解析成功:先刷新任务(拿到文件列表),再通知模块弹文件勾选对话框。
// 必须 await —— 否则回调读取的 store.tasks 仍是旧快照(btFiles 为空),
// 导致勾选对话框无条目、默认选中空数组,进而在后端被 librqbit 秒判为"已完成 0%"
await refreshTasks()
btInspectReadyHandler?.(e.payload.id)
})
}
}
const stopEventListeners = () => {
@@ -269,6 +330,10 @@ export const useDownloaderStore = defineStore('downloader', () => {
removedUnlisten()
removedUnlisten = null
}
if (inspectReadyUnlisten) {
inspectReadyUnlisten()
inspectReadyUnlisten = null
}
}
// ===== 初始化 =====
@@ -281,6 +346,11 @@ export const useDownloaderStore = defineStore('downloader', () => {
// ===== 工具函数 =====
const openDir = (path: string) => commands.downloaderOpenDir(path)
/** 注册磁力元数据就绪回调(模块传入处理函数,替换式) */
const setBtInspectReadyHandler = (handler: ((id: string) => void) | null) => {
btInspectReadyHandler = handler
}
return {
// state
tasks,
@@ -290,10 +360,14 @@ export const useDownloaderStore = defineStore('downloader', () => {
// tasks
refreshTasks,
addTask,
inspect,
selectBtFiles,
checkUrl,
pauseTask,
resumeTask,
removeTask,
cancelTask,
redownload,
// settings
loadSettings,
saveSettings,
@@ -307,6 +381,7 @@ export const useDownloaderStore = defineStore('downloader', () => {
// init
init,
// utils
setBtInspectReadyHandler,
openDir
}
})
+182 -50
View File
@@ -261,14 +261,14 @@ function defaultOsdConfig(): OsdConfig {
labelLanguage: 'zh',
layout: 'single',
updateIntervalMs: 1000,
// 默认关闭点击穿透:关闭后左键可直接拖动悬浮窗
clickThrough: false,
// 默认开启点击穿透:悬浮窗不拦截鼠标,需拖动时临时关闭
clickThrough: true,
fontColor: '#ffffff',
fontOpacity: 100,
bgColor: 'transparent',
colorThemeEnabled: true,
colorTheme: { ...DEFAULT_COLOR_THEME },
fontStrokeEnabled: false,
fontStrokeEnabled: true,
fontStrokeWidth: 1,
fontStrokeColor: '#000000',
alert: { ...DEFAULT_ALERT_CONFIG, maxValues: { ...DEFAULT_ALERT_CONFIG.maxValues } },
@@ -583,11 +583,41 @@ export const useMonitorStore = defineStore('monitor', () => {
return autoStart.value
}
/** 设置"自动启动监控内核"开关 */
/** 关闭"自动启动监控内核"时暂存的 OSD 开关状态(开启自动启动时据此恢复) */
const OSD_PENDING_KEY = STORAGE_KEYS.monitorOsdPending
/** 待恢复的 OSD 开关状态:关闭自动启动时记录、开启时按记录恢复。持久化于 localStorage,跨重启有效。 */
let pendingOsdEnabled: boolean | null = null
try {
pendingOsdEnabled = JSON.parse(localStorage.getItem(OSD_PENDING_KEY) ?? 'null')
} catch { /* 忽略非法缓存,视为无恢复记录 */ }
/**
* 设置"自动启动监控内核"开关。
* 与 OSD 开关联动(OSD 开关本身可自由开关):
* - 开→关:记录当前 OSD 开关状态,再关闭 OSD(OSD 依赖内核随应用启动)
* - 关→开:若记录状态为开,则恢复 OSD 显示
*/
async function setAutoStart(enabled: boolean) {
try {
await invoke('monitor_set_auto_start', { enabled })
autoStart.value = enabled
if (!enabled) {
// 关闭时记录当前 OSD 状态,随后由 overlayEnabled watch 统一隐藏悬浮窗
pendingOsdEnabled = osdConfig.value.overlayEnabled
try { localStorage.setItem(OSD_PENDING_KEY, JSON.stringify(pendingOsdEnabled)) } catch { /* 忽略 */ }
osdConfig.value.overlayEnabled = false
saveOsdConfig(osdConfig.value)
} else {
// 开启时若有记录且曾为开,恢复 OSD 显示
if (pendingOsdEnabled === true) {
osdConfig.value.overlayEnabled = true
saveOsdConfig(osdConfig.value)
}
// 消费记录,避免下次开启再次恢复
pendingOsdEnabled = null
try { localStorage.removeItem(OSD_PENDING_KEY) } catch { /* 忽略 */ }
}
} catch (e) {
errorMsg.value = String(e)
logger.error('设置自动启动开关失败: ' + e)
@@ -758,67 +788,172 @@ export const useMonitorStore = defineStore('monitor', () => {
/** 根据显示项估算悬浮窗窗口尺寸(逻辑像素)
* single: 单行分组式,组间用 | 分隔,固定宽度数据列
* group: 分组横排,标题在上 + 数据列在下
* multiline: 多行,每组一行,标题 + 固定宽度数据列 */
* multiline: 多行,每组一行,标题 + 固定宽度数据列
*
* 宽度严格按 OsdWindow.vue 的渲染结构估算:
* - 标签/箭头里的 CJK 按全角宽度(≈fontSize),ASCII 按等宽(≈0.6em
* - 数值用 fmtFixedValue 的固定 pad 宽度、单位用 fmtFixedUnit 的文本
* - 计入各类 gap(组内 3px、single 组间 4px、group 组间 8px、项内 gap、单位 margin
* - 计入 osd-bar 左右 padding4*2
* 这样创建时的窗口宽度与真实内容一致,避免窗口小于内容而截断,
* 也避免因宽度估算偏差导致 computePositionFromPct 的位置百分比偏移。 */
function computeOsdWindowSize(
_itemCount: number,
layout: 'single' | 'group' | 'multiline',
fontSize: number,
_hasNetItem = false,
items?: OsdItem[],
items: OsdItem[] = [],
): { w: number; h: number } {
const charW = fontSize * 0.62
const charW = fontSize * 0.6 // 等宽 ASCII 字符宽(Cascadia/Consolas ≈0.6em
const cjkW = fontSize // CJK 全角字符宽
const barHPad = 8 // osd-bar 左右 padding 4*2
// 按硬件类型分组(与渲染逻辑一致)
const groupMap = new Map<string, OsdItem[]>()
if (items?.length) {
for (const item of items) {
let gkey: string
if (item.special === 'net-up' || item.special === 'net-down') gkey = 'network'
else if (item.groupId.startsWith('gpu')) gkey = 'gpu'
else gkey = item.groupId
if (!groupMap.has(gkey)) groupMap.set(gkey, [])
groupMap.get(gkey)!.push(item)
const isCjk = (ch: string) => {
const c = ch.codePointAt(0)!
return (
(c >= 0x2e80 && c <= 0x9fff) ||
(c >= 0x3000 && c <= 0x303f) ||
(c >= 0xff00 && c <= 0xffef) ||
(c >= 0xf900 && c <= 0xfaff)
)
}
// 字符串像素宽(CJK 全角,ASCII 等宽)
const textPx = (s: string) => {
let w = 0
for (const ch of s) w += isCjk(ch) ? cjkW : charW
return w
}
const showLabel = osdConfig.value?.showLabel !== false
const en = osdConfig.value?.labelLanguage === 'en'
const groupLabelText = (gkey: string): string => {
if (en) {
switch (gkey) {
case 'cpu': return 'CPU'
case 'gpu': return 'GPU'
case 'memory': return 'RAM'
case 'storage': return 'DISK'
case 'network': return 'NET'
case 'motherboard': return 'MB'
case 'battery': return 'BAT'
case 'psu': return 'PSU'
default: return gkey.toUpperCase().slice(0, 6)
}
}
switch (gkey) {
case 'cpu': return 'CPU'
case 'gpu': return 'GPU'
case 'memory': return '内存'
case 'storage': return '存储'
case 'network': return '网络'
case 'motherboard': return '主板'
case 'battery': return '电池'
case 'psu': return '电源'
default: return gkey
}
}
const groupCount = Math.max(1, groupMap.size)
// 每组数据列宽度:标签(6ch) + 各项(数值+单位+箭头/gap)
const groupWidths: number[] = []
for (const [, groupItems] of groupMap) {
const labelW = 6
const dataW = groupItems.reduce((sum, item) => {
const isNet = item.special === 'net-up' || item.special === 'net-down'
return sum + (isNet ? 11 : 8) + 1
}, 0)
groupWidths.push(labelW + dataW)
// 数值固定宽度(字符数,对应 fmtFixedValue 的 pad 宽度)
const numChars = (it: OsdItem): number => {
if (it.special === 'net-up' || it.special === 'net-down') return 6
switch (it.type) {
case 'load':
case 'level':
case 'temperature': return 3
case 'power':
case 'voltage': return 5
case 'clock':
case 'frequency': return 4
case 'fan': return 4
case 'data':
case 'smalldata': return 5
default: return 5
}
}
// 单位文本(对应 fmtFixedUnit;网速取最宽单位 MB/s 估算)
const showUnit = osdConfig.value?.showUnit !== false
const unitText = (it: OsdItem): string => {
if (it.special === 'net-up' || it.special === 'net-down') return showUnit ? 'MB/s' : ''
if (!showUnit) return ''
switch (it.type) {
case 'temperature': return '°C'
case 'load': return '%'
case 'power': return 'W'
case 'voltage': return 'V'
case 'fan': return 'RPM'
case 'clock':
case 'frequency': return 'MHz'
case 'data':
case 'smalldata': return 'GB'
case 'level': return '%'
default: return it.unit || ''
}
}
// 单一项像素宽:箭头 + 数值 + 单位 + 项内 gap(1px) + 单位 margin(1px)
const itemPx = (it: OsdItem): number => {
let w = it.special ? charW : 0 // 箭头
if (it.special) w += 1 // 箭头与数值 gap
w += numChars(it) * charW
const unit = unitText(it)
if (unit) w += textPx(unit) + 1 + 1 // 数值-单位 gap + 单位 left margin
return w
}
// 一个分组的像素宽:组内各子项 gap(3px) + 标签 + 各项
const groupWidthPx = (gkey: string, list: OsdItem[]): number => {
const labelW = showLabel ? textPx(groupLabelText(gkey)) : 0
const itemsW = list.reduce((s, it) => s + itemPx(it), 0)
const gapCount = list.length + (showLabel ? 1 : 0) - 1
return Math.ceil(labelW + itemsW + Math.max(0, gapCount) * 3)
}
// 构建分组(归一化 key,与渲染逻辑一致)
const groupMap = new Map<string, OsdItem[]>()
for (const it of items) {
let gkey = it.groupId
if (it.special === 'net-up' || it.special === 'net-down') gkey = 'network'
else if (it.groupId.startsWith('gpu')) gkey = 'gpu'
if (!groupMap.has(gkey)) groupMap.set(gkey, [])
groupMap.get(gkey)!.push(it)
}
const groupEntries = [...groupMap.entries()]
const groupCount = Math.max(1, groupEntries.length)
const gw = groupEntries.map(([k, list]) => groupWidthPx(k, list))
const lineH = Math.ceil(fontSize + 2)
if (layout === 'multiline') {
// 多行:取最宽行
const maxLineW = groupWidths.length ? Math.max(...groupWidths) : 10
const w = Math.ceil(maxLineW * charW + barHPad)
const lineH = Math.ceil(fontSize + 2)
const h = Math.ceil(groupCount * lineH + 6)
return { w: Math.max(120, w), h: Math.max(28, h) }
// 每行 = 标签(min 4ch) + 组内 gap(4px) + 各项;取最宽行
const labelMinW = 4 * charW
let maxLineW = 0
for (const [gkey, list] of groupEntries) {
const labelW = showLabel ? Math.max(textPx(groupLabelText(gkey)), labelMinW) : 0
const itemsW = list.reduce((s, it) => s + itemPx(it), 0)
const gapCount = list.length + (showLabel ? 1 : 0) - 1
maxLineW = Math.max(maxLineW, labelW + itemsW + Math.max(0, gapCount) * 4)
}
const w = Math.max(120, Math.ceil(maxLineW + barHPad))
const h = Math.max(28, Math.ceil(groupCount * lineH + 6))
return { w, h }
}
if (layout === 'group') {
// 分组横排:各组横排 + 标题行
const totalW = groupWidths.reduce((s, w) => s + w + 4, 0) + (groupCount - 1) * 4
const w = Math.ceil(totalW * charW + barHPad)
// 分组横排:各组横排,组间 gap(8px),每组含 padding(4*2)
const totalW = gw.reduce((s, w) => s + w, 0)
+ groupCount * 8 // 每组左右 padding 4*2
+ Math.max(0, groupCount - 1) * 8 // 组间 gap
const w = Math.max(120, Math.ceil(totalW + barHPad))
const titleH = Math.ceil(fontSize * 0.85) + 2
const dataH = Math.ceil(fontSize) + 2
const h = Math.ceil(titleH + dataH + 10)
return { w: Math.max(120, w), h: Math.max(40, h) }
const dataH = lineH
const h = Math.max(40, Math.ceil(titleH + dataH + 3 + 3))
return { w, h }
}
// single:单行分组式,各组横排 + 组间 | 分隔符(1ch)
const sepW = (groupCount - 1) * 1
const totalW = groupWidths.reduce((s, w) => s + w, 0) + sepW
const w = Math.ceil(totalW * charW + barHPad)
const h = Math.ceil(fontSize + 8)
return { w: Math.max(120, w), h: Math.max(28, h) }
// single:单行分组式,组间 | 分隔符 + 组间 gap(4px)
const sepW = (groupCount - 1) * (textPx('|') + 4)
const betweenW = Math.max(0, groupCount - 1) * 4
const w = Math.max(120, Math.ceil(gw.reduce((s, w) => s + w, 0) + sepW + betweenW + barHPad))
const h = Math.max(28, Math.ceil(fontSize + 8))
return { w, h }
}
/** 创建/显示悬浮窗窗口(默认置顶 + NoActivate + 点击穿透) */
@@ -1049,13 +1184,10 @@ export const useMonitorStore = defineStore('monitor', () => {
// stop 句柄存入 osdWatchStopsdispose 时统一释放,避免模块重挂载后重复注册
// OSD 开关变化时创建/隐藏悬浮窗
// 注意:开启 OSD 不再自动开启"应用启动时自动启动监控内核"。
// 二者保持独立(双向联动会导致:关自动启动→关 OSD→再开 OSD→自动启动又被强行打开)。
osdWatchStops.push(watch(() => osdConfig.value.overlayEnabled, (enabled) => {
if (enabled) {
// OSD 显示开启时自动开启"应用启动时自动启动监控内核",
// 使 OSD 持续显示不因重启而中断
if (!autoStart.value) {
setAutoStart(true).catch(e => logger.error('[OSD] 自动开启 autoStart 失败: ' + e))
}
// 开启时若显示项为空则不创建窗口
if (osdConfig.value.overlayItems.length === 0) return
ensureOverlayWindow().catch(e => logger.error('[OSD] 创建悬浮窗失败: ' + e))
+72 -1
View File
@@ -12,7 +12,8 @@ import {
type ProfileMeta,
type KernelInfo,
type KernelUpdateInfo,
type ProxyStatus
type ProxyStatus,
type TrafficSnapshot,
} from '@/lib/bindings'
// Rust 端结构体字段均带 serde(default),返回必完整;用 Required 收窄 bindings 的 optional
@@ -53,6 +54,32 @@ export interface ProxiesResponse {
proxies: Record<string, ProxyNode>
}
/** mihomo /connections 单条连接(完整字段以原始 JSON 为准,仅取前端用到的部分) */
export interface ProxyConnection {
id: string
chains?: string[]
rule?: string
rulePayload?: string
upload: number
download: number
start: string
metadata?: {
network?: string
type?: string
process?: string
host?: string
sourceIP?: string
sourcePort?: number
destinationIP?: string
destinationPort?: number
}
}
/** mihomo /connections 响应(原始 JSON,字段可能缺失) */
export interface ConnectionsResponse {
connections?: ProxyConnection[]
}
export interface MihomoVersion {
version: string
meta?: boolean
@@ -65,6 +92,10 @@ export const useProxyStore = defineStore('proxy', () => {
const proxies = ref<Record<string, ProxyNode>>({})
const settings = ref<FullProxySettings | null>(null)
const systemProxy = ref(false)
/** 实时流量快照(上传/下载速率、会话总量、活跃连接数) */
const traffic = ref<TrafficSnapshot | null>(null)
/** 当前活跃连接列表(仅连接页签需要时拉取) */
const connections = ref<ProxyConnection[] | null>(null)
/** 是否已完成首次加载(避免初始 null/false 导致闪烁误导状态) */
const initialized = ref(false)
@@ -209,6 +240,40 @@ export const useProxyStore = defineStore('proxy', () => {
}
}
// ---------- 流量 / 连接 ----------
/** 刷新实时流量快照(速率 + 会话总量 + 活跃连接数)。失败时保留上一次数据,避免抖动。 */
const refreshTraffic = async () => {
try {
traffic.value = await commands.proxyTraffic()
} catch {
/* mihomo 瞬时不可用(如重启)时保留上一次数据 */
}
}
/** 刷新当前活跃连接列表(原始 /connections)。供「连接」页签低频拉取。 */
const refreshConnections = async () => {
try {
const resp = await invoke<ConnectionsResponse>('proxy_get_connections')
connections.value = resp.connections ?? []
} catch {
/* mihomo 瞬时不可用(如重启)时保留上一次数据 */
}
}
/** 关闭指定连接 */
const closeConnection = async (id: string) => {
try {
await commands.proxyCloseConnection(id)
// 本地立即移除,无需等下一轮轮询
if (connections.value) {
connections.value = connections.value.filter((c) => c.id !== id)
}
} catch (e) {
logger.error('关闭连接失败: ' + e)
throw e
}
}
const saveSettings = async (s: FullProxySettings) => {
await commands.proxySaveSettings(s)
settings.value = s
@@ -487,6 +552,8 @@ export const useProxyStore = defineStore('proxy', () => {
proxies,
settings,
systemProxy,
traffic,
connections,
initialized,
installing,
installProgress,
@@ -516,6 +583,10 @@ export const useProxyStore = defineStore('proxy', () => {
setSystemProxy,
clearSystemProxy,
toggleSystemProxy,
// traffic & connections
refreshTraffic,
refreshConnections,
closeConnection,
// kernel update / install
checkKernelUpdate,
updateKernel,