优化调整、BT下载(有bug)
This commit is contained in:
@@ -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
@@ -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 左右 padding(4*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 句柄存入 osdWatchStops,dispose 时统一释放,避免模块重挂载后重复注册
|
||||
|
||||
// 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))
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user