细节调整及优化(26.8.3)
This commit is contained in:
@@ -86,6 +86,9 @@ export const useClipboardStore = defineStore('clipboard', () => {
|
||||
}
|
||||
|
||||
// ===== 查询 =====
|
||||
/** 列表请求序号:翻页/搜索/过滤快速操作时丢弃过期请求结果,避免旧请求覆盖新结果 */
|
||||
let listSeq = 0
|
||||
|
||||
/** 拉取指定页的历史数据。pageSize 默认 50。 */
|
||||
const fetchHistoryPage = async (opts: {
|
||||
kind?: string
|
||||
@@ -96,10 +99,13 @@ export const useClipboardStore = defineStore('clipboard', () => {
|
||||
const pageSize = opts.pageSize ?? 50
|
||||
const page = Math.max(1, opts.page ?? 1)
|
||||
const offset = (page - 1) * pageSize
|
||||
const seq = ++listSeq
|
||||
try {
|
||||
const res = await commands.clipboardGetHistory(pageSize, offset, kind)
|
||||
history.value = res.items
|
||||
historyTotal.value = res.total
|
||||
if (seq === listSeq) {
|
||||
history.value = res.items
|
||||
historyTotal.value = res.total
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('获取历史失败: ' + e)
|
||||
}
|
||||
@@ -123,10 +129,13 @@ export const useClipboardStore = defineStore('clipboard', () => {
|
||||
if (!query.trim()) {
|
||||
return fetchHistoryPage({ page, pageSize })
|
||||
}
|
||||
const seq = ++listSeq
|
||||
try {
|
||||
const res = await commands.clipboardSearch(query, pageSize, (page - 1) * pageSize)
|
||||
history.value = res.items
|
||||
historyTotal.value = res.total
|
||||
if (seq === listSeq) {
|
||||
history.value = res.items
|
||||
historyTotal.value = res.total
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('搜索失败: ' + e)
|
||||
}
|
||||
|
||||
@@ -48,6 +48,8 @@ interface ProgressPayload {
|
||||
totalSize: number
|
||||
speed: number
|
||||
status: TaskStatus
|
||||
/** 每个分段的已下载字节(与任务 segments 一一对应,供详情弹窗实时展示) */
|
||||
segments: number[]
|
||||
}
|
||||
|
||||
/** 下载完成事件载荷 */
|
||||
@@ -68,6 +70,7 @@ export const useDownloaderStore = defineStore('downloader', () => {
|
||||
let progressUnlisten: UnlistenFn | null = null
|
||||
let completeUnlisten: UnlistenFn | null = null
|
||||
let addedUnlisten: UnlistenFn | null = null
|
||||
let removedUnlisten: UnlistenFn | null = null
|
||||
|
||||
// ===== 任务列表 =====
|
||||
const refreshTasks = async () => {
|
||||
@@ -109,6 +112,13 @@ export const useDownloaderStore = defineStore('downloader', () => {
|
||||
task.totalSize = payload.totalSize
|
||||
task.speed = payload.speed
|
||||
task.status = payload.status
|
||||
// 分段实时进度(详情弹窗分段条随下载动态更新)
|
||||
if (Array.isArray(payload.segments)) {
|
||||
task.segments.forEach((seg, i) => {
|
||||
const v = payload.segments?.[i]
|
||||
if (v !== undefined) seg.completed = v
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,7 +225,7 @@ export const useDownloaderStore = defineStore('downloader', () => {
|
||||
}
|
||||
|
||||
const startEventListeners = async () => {
|
||||
if (progressUnlisten && completeUnlisten && addedUnlisten) return
|
||||
if (progressUnlisten && completeUnlisten && addedUnlisten && removedUnlisten) return
|
||||
if (!progressUnlisten) {
|
||||
progressUnlisten = await listen<ProgressPayload>('download-progress', (e) => {
|
||||
// 同名任务只保留最新进度,合并后由 rAF 统一应用
|
||||
@@ -234,6 +244,12 @@ export const useDownloaderStore = defineStore('downloader', () => {
|
||||
refreshTasks()
|
||||
})
|
||||
}
|
||||
if (!removedUnlisten) {
|
||||
removedUnlisten = await listen<{ id: string }>('download-removed', () => {
|
||||
// 任务被删除(浏览器扩展通过 HTTP API 删除时前端无从感知),刷新任务列表
|
||||
refreshTasks()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const stopEventListeners = () => {
|
||||
@@ -249,6 +265,10 @@ export const useDownloaderStore = defineStore('downloader', () => {
|
||||
addedUnlisten()
|
||||
addedUnlisten = null
|
||||
}
|
||||
if (removedUnlisten) {
|
||||
removedUnlisten()
|
||||
removedUnlisten = null
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 初始化 =====
|
||||
|
||||
+108
-63
@@ -3,7 +3,7 @@ import { computed, ref, watch } from 'vue'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { emit, listen, type UnlistenFn } from '@tauri-apps/api/event'
|
||||
import { WebviewWindow } from '@tauri-apps/api/webviewWindow'
|
||||
import { currentMonitor, LogicalPosition, LogicalSize } from '@tauri-apps/api/window'
|
||||
import { currentMonitor, LogicalPosition } from '@tauri-apps/api/window'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import { EVENTS, STORAGE_KEYS, WINDOWS } from '@/lib/constants'
|
||||
@@ -335,7 +335,7 @@ export const useMonitorStore = defineStore('monitor', () => {
|
||||
|
||||
/**
|
||||
* OSD 配置(store 级统一管理,App 启动时 initOsd 显式初始化)。
|
||||
* 快照/网速事件到达时若 OSD 开启则 store 统一推送 osd-state-update,
|
||||
* 快照/网速事件到达时若 OSD 开启则 store 统一推送 osd-data-update(仅显示项 key→value),
|
||||
* 使 OSD 数据流不依赖组件生命周期(模块卸载后 OSD 窗口仍能持续刷新)。
|
||||
*/
|
||||
const osdConfig = ref<OsdConfig>(loadOsdConfig())
|
||||
@@ -352,17 +352,47 @@ export const useMonitorStore = defineStore('monitor', () => {
|
||||
}, 200)
|
||||
}
|
||||
|
||||
/** 推送 OSD 状态到所有 OSD 窗口(仅 OSD 开启时生效) */
|
||||
/** 传感器 key→value 映射(key 格式与 OSD 显示项一致:{groupId}/{hwName}/{sensorName}/{type} 小写化)
|
||||
* 快照到达时重算一次,供 OSD 数据推送 O(1) 查值,替代向 OSD 窗口推送全量快照 */
|
||||
const sensorKeyMap = computed<Record<string, number | null>>(() => {
|
||||
const map: Record<string, number | null> = {}
|
||||
for (const g of snapshot.value?.groups ?? []) {
|
||||
for (const s of g.sensors) {
|
||||
map[`${g.id}/${s.hardwareName}/${s.name}/${s.type}`.replace(/\s+/g, '_').toLowerCase()] = s.value ?? null
|
||||
}
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
/** 推送 OSD 数据到所有 OSD 窗口(高频通道:仅显示项 key→value 映射 + 网速,每秒一次) */
|
||||
async function pushOsdState() {
|
||||
if (!osdConfig.value.overlayEnabled) return
|
||||
try {
|
||||
const map = sensorKeyMap.value
|
||||
const data: Record<string, number | null> = {}
|
||||
for (const item of osdConfig.value.overlayItems) {
|
||||
// 网速特殊项不查快照,通过 networkSpeed 字段携带
|
||||
if (item.special) continue
|
||||
data[item.key] = map[item.key] ?? null
|
||||
}
|
||||
await emit(EVENTS.osdDataUpdate, {
|
||||
data,
|
||||
networkSpeed: networkSpeed.value,
|
||||
})
|
||||
} catch (e) {
|
||||
logger.error('[OSD] 推送数据失败: ' + e)
|
||||
}
|
||||
}
|
||||
|
||||
/** 推送 OSD 配置到所有 OSD 窗口(低频通道:配置变化时调用,防抖合并) */
|
||||
async function pushOsdConfig() {
|
||||
if (!osdConfig.value.overlayEnabled) return
|
||||
try {
|
||||
await emit(EVENTS.osdStateUpdate, {
|
||||
config: osdConfig.value,
|
||||
snapshot: snapshot.value,
|
||||
networkSpeed: networkSpeed.value,
|
||||
})
|
||||
} catch (e) {
|
||||
logger.error('[OSD] 推送状态失败: ' + e)
|
||||
logger.error('[OSD] 推送配置失败: ' + e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -705,6 +735,8 @@ export const useMonitorStore = defineStore('monitor', () => {
|
||||
|
||||
/** 抑制百分比 watch 的程序定位标志:拖动 onMoved 更新百分比时置 true,避免触发 resetOverlayPosition 循环 */
|
||||
let suppressPercentWatch = false
|
||||
/** 抑制 onMoved 处理的程序定位标志:osd_set_bounds 原子调整触发 moved 事件时跳过反算(百分比已是驱动值,无需回写) */
|
||||
let suppressMovedHandling = false
|
||||
|
||||
/** 构建用于 OSD 窗口的 URL(基于当前页面 URL 替换 hash) */
|
||||
function osdUrl(hash: string): string {
|
||||
@@ -793,9 +825,11 @@ export const useMonitorStore = defineStore('monitor', () => {
|
||||
async function ensureOverlayWindow() {
|
||||
const existing = await WebviewWindow.getByLabel(OSD_OVERLAY_LABEL)
|
||||
if (existing) {
|
||||
// 窗口已存在,仅显示并推送最新状态
|
||||
// 窗口已存在,仅显示并推送最新配置 + 数据
|
||||
// 不做估算 resize:内容测量上报(osd-content-size)是唯一尺寸/位置更新源,
|
||||
// 推送配置后 OSD 重新渲染并上报实际尺寸,由监听端原子 setBounds
|
||||
await existing.show()
|
||||
await updateOsdWindowSize()
|
||||
await pushOsdConfig()
|
||||
await pushOsdState()
|
||||
return
|
||||
}
|
||||
@@ -816,16 +850,11 @@ export const useMonitorStore = defineStore('monitor', () => {
|
||||
osdConfig.value.overlayItems,
|
||||
)
|
||||
|
||||
// 优先使用保存的像素位置;否则根据百分比计算默认位置
|
||||
let x: number, y: number
|
||||
if (osdConfig.value.overlayX != null && osdConfig.value.overlayY != null) {
|
||||
x = osdConfig.value.overlayX
|
||||
y = osdConfig.value.overlayY
|
||||
} else {
|
||||
const pos = computePositionFromPct(logicalW, logicalH, w, h, osdConfig.value.positionXPct, osdConfig.value.positionYPct)
|
||||
x = pos.x
|
||||
y = pos.y
|
||||
}
|
||||
// 位置统一由百分比计算(百分比是唯一位置真相源:拖动后反算更新百分比,
|
||||
// 尺寸变化时按百分比重锚;不再使用保存的像素位置,避免旧尺寸像素与新尺寸不匹配)
|
||||
const pos = computePositionFromPct(logicalW, logicalH, w, h, osdConfig.value.positionXPct, osdConfig.value.positionYPct)
|
||||
const x = pos.x
|
||||
const y = pos.y
|
||||
|
||||
const win = new WebviewWindow(OSD_OVERLAY_LABEL, {
|
||||
url: osdUrl('osd-overlay'),
|
||||
@@ -848,29 +877,29 @@ export const useMonitorStore = defineStore('monitor', () => {
|
||||
})
|
||||
|
||||
win.once('tauri://created', async () => {
|
||||
// 等待 webview 加载后推送初始状态
|
||||
setTimeout(() => pushOsdState(), 300)
|
||||
// 监听窗口移动,保存像素位置并同步更新百分比(拖动结束后触发)
|
||||
// 等待 webview 加载后推送初始配置 + 数据
|
||||
setTimeout(() => { void pushOsdConfig(); void pushOsdState() }, 300)
|
||||
// 监听窗口移动(用户拖动结束后触发),反算更新百分比
|
||||
try {
|
||||
const winInstance = await win
|
||||
const unlisten = await winInstance.onMoved(async ({ payload }) => {
|
||||
osdConfig.value.overlayX = payload.x
|
||||
osdConfig.value.overlayY = payload.y
|
||||
// 反算百分比:xPct = x / availW * 100,availW = screenW - windowW
|
||||
// 程序性 setBounds/setPosition 触发的移动:位置由百分比驱动,无需反算
|
||||
if (suppressMovedHandling) return
|
||||
// payload 为物理像素(PhysicalPosition),需转逻辑像素后再反算百分比
|
||||
// 置 suppressPercentWatch=true 避免百分比变化触发 resetOverlayPosition 循环
|
||||
suppressPercentWatch = true
|
||||
try {
|
||||
const monitor = await currentMonitor()
|
||||
const screenW = (monitor?.size.width ?? 1920) / (monitor?.scaleFactor ?? 1)
|
||||
const screenH = (monitor?.size.height ?? 1080) / (monitor?.scaleFactor ?? 1)
|
||||
const size = await winInstance.outerSize()
|
||||
const scale = monitor?.scaleFactor ?? 1
|
||||
const screenW = (monitor?.size.width ?? 1920) / scale
|
||||
const screenH = (monitor?.size.height ?? 1080) / scale
|
||||
const size = await winInstance.outerSize()
|
||||
const winW = size.width / scale
|
||||
const winH = size.height / scale
|
||||
const availW = Math.max(1, screenW - winW)
|
||||
const availH = Math.max(1, screenH - winH)
|
||||
osdConfig.value.positionXPct = Math.round((payload.x / availW) * 100)
|
||||
osdConfig.value.positionYPct = Math.round((payload.y / availH) * 100)
|
||||
osdConfig.value.positionXPct = Math.round(((payload.x / scale) / availW) * 100)
|
||||
osdConfig.value.positionYPct = Math.round(((payload.y / scale) / availH) * 100)
|
||||
} catch { /* 忽略百分比反算失败 */ }
|
||||
saveOsdConfig(osdConfig.value)
|
||||
// 下一个微任务后解除抑制(让本次 watch 回调跳过即可)
|
||||
@@ -893,21 +922,38 @@ export const useMonitorStore = defineStore('monitor', () => {
|
||||
}
|
||||
}
|
||||
|
||||
/** 根据当前配置更新悬浮窗窗口尺寸(显示项数量/布局/字号变化时调用) */
|
||||
async function updateOsdWindowSize() {
|
||||
/** 调整悬浮窗尺寸并按百分比重锚位置(原子操作)
|
||||
* 尺寸变化(增删显示项/切换语言/改字号)时统一按百分比重新计算坐标,
|
||||
* 使右对齐/居中等相对位置在新宽度下保持(xPct=100 右对齐 → 右边缘始终贴屏)。
|
||||
* 通过 Rust osd_set_bounds 一次 SetWindowPos 同时更新位置+尺寸,
|
||||
* 避免 setSize 与 setPosition 两次调用之间的中间帧(宽度已变、位置未动 → 闪烁)。 */
|
||||
async function resizeOsdWindowWithAnchor(width: number, height: number) {
|
||||
const w = await WebviewWindow.getByLabel(OSD_OVERLAY_LABEL)
|
||||
if (!w) return
|
||||
try {
|
||||
const existing = await WebviewWindow.getByLabel(OSD_OVERLAY_LABEL)
|
||||
if (!existing) return
|
||||
const hasNetItem = osdConfig.value.overlayItems.some(i => i.special === 'net-up' || i.special === 'net-down')
|
||||
const { w, h } = computeOsdWindowSize(
|
||||
osdConfig.value.overlayItems.length,
|
||||
osdConfig.value.layout,
|
||||
osdConfig.value.fontSize,
|
||||
hasNetItem,
|
||||
osdConfig.value.overlayItems,
|
||||
)
|
||||
await existing.setSize(new LogicalSize(w, h))
|
||||
} catch { /* 忽略 */ }
|
||||
const monitor = await currentMonitor()
|
||||
const scale = monitor?.scaleFactor ?? 1
|
||||
const screenW = (monitor?.size.width ?? 1920) / scale
|
||||
const screenH = (monitor?.size.height ?? 1080) / scale
|
||||
const pos = computePositionFromPct(screenW, screenH, width, height, osdConfig.value.positionXPct, osdConfig.value.positionYPct)
|
||||
// 抑制本次程序性移动触发的 onMoved 反算(百分比已是驱动值,回写可能因 round 产生扰动)
|
||||
suppressMovedHandling = true
|
||||
suppressPercentWatch = true
|
||||
// 逻辑坐标 → 物理像素(osd_set_bounds 接收物理像素)
|
||||
await invoke('osd_set_bounds', {
|
||||
label: OSD_OVERLAY_LABEL,
|
||||
x: Math.round(pos.x * scale),
|
||||
y: Math.round(pos.y * scale),
|
||||
w: Math.round(width * scale),
|
||||
h: Math.round(height * scale),
|
||||
})
|
||||
} catch { /* 忽略 */ } finally {
|
||||
// moved 事件异步到达,延迟解除抑制
|
||||
setTimeout(() => {
|
||||
suppressMovedHandling = false
|
||||
suppressPercentWatch = false
|
||||
}, 150)
|
||||
}
|
||||
}
|
||||
|
||||
/** 重置悬浮窗位置到默认(百分比位置),清除保存的像素位置
|
||||
@@ -929,7 +975,10 @@ export const useMonitorStore = defineStore('monitor', () => {
|
||||
const w = size.width / scale
|
||||
const h = size.height / scale
|
||||
const pos = computePositionFromPct(screenW, screenH, w, h, osdConfig.value.positionXPct, osdConfig.value.positionYPct)
|
||||
// 抑制程序性移动触发的 onMoved 反算
|
||||
suppressMovedHandling = true
|
||||
await existing.setPosition(new LogicalPosition(pos.x, pos.y))
|
||||
setTimeout(() => { suppressMovedHandling = false }, 150)
|
||||
}
|
||||
} catch { /* 忽略 */ }
|
||||
}
|
||||
@@ -946,17 +995,21 @@ export const useMonitorStore = defineStore('monitor', () => {
|
||||
// 仅当尺寸变化超过 1px 时才 setSize,避免无意义的频繁调用
|
||||
let lastW = 0
|
||||
let lastH = 0
|
||||
const unlisten = await listen<{ width: number; height: number }>('osd-content-size', async (e) => {
|
||||
const unlisten = await listen<{ width: number; height: number }>(EVENTS.osdContentSize, async (e) => {
|
||||
const { width, height } = e.payload
|
||||
if (Math.abs(width - lastW) < 1 && Math.abs(height - lastH) < 1) return
|
||||
lastW = width
|
||||
lastH = height
|
||||
try {
|
||||
const w = await WebviewWindow.getByLabel(OSD_OVERLAY_LABEL)
|
||||
if (w) await w.setSize(new LogicalSize(width, height))
|
||||
} catch { /* 忽略 */ }
|
||||
await resizeOsdWindowWithAnchor(width, height)
|
||||
})
|
||||
osdEventUnlisteners.push(unlisten)
|
||||
// OSD 窗口挂载完成后请求补发配置+数据:
|
||||
// 数据通道不含配置,若窗口加载慢错过创建时的首推(300ms),需由窗口主动请求
|
||||
const unlistenReq = await listen(EVENTS.osdConfigRequest, () => {
|
||||
void pushOsdConfig()
|
||||
void pushOsdState()
|
||||
})
|
||||
osdEventUnlisteners.push(unlistenReq)
|
||||
}
|
||||
|
||||
/** initOsd 幂等守卫:监听/配置 watcher 只注册一次(App 启动 + 模块挂载均会调用) */
|
||||
@@ -1033,29 +1086,21 @@ export const useMonitorStore = defineStore('monitor', () => {
|
||||
resetOverlayPosition().catch(() => {})
|
||||
}))
|
||||
|
||||
// OSD 配置变化 → 推送到 OSD 窗口(位置/字体/显示项等)
|
||||
// 防抖 200ms 合并:滑块拖动期间只推送最终状态,避免每帧 emit 整份快照
|
||||
// OSD 配置变化 → 推送配置到 OSD 窗口(低频通道,数据通道不受影响)
|
||||
// 防抖 200ms 合并:滑块拖动期间只推送最终状态,避免每帧 emit 整份配置
|
||||
// 注意:不做估算 resize——配置推送后 OSD 重渲染并测量上报实际尺寸,
|
||||
// osd-content-size 监听端原子 setBounds,是唯一尺寸/位置更新源
|
||||
// (估算 resize 会先跳到估算位置再跳到实际位置,产生闪烁)
|
||||
osdWatchStops.push(watch(osdConfig, () => {
|
||||
if (osdPushTimer) clearTimeout(osdPushTimer)
|
||||
osdPushTimer = setTimeout(() => {
|
||||
osdPushTimer = null
|
||||
if (osdConfig.value.overlayEnabled) {
|
||||
pushOsdState()
|
||||
pushOsdConfig()
|
||||
}
|
||||
}, 200)
|
||||
}, { deep: true }))
|
||||
|
||||
// 显示项数量/布局/字号变化 → 更新悬浮窗窗口尺寸(自适应内容)
|
||||
osdWatchStops.push(watch([
|
||||
() => osdConfig.value.overlayItems.length,
|
||||
() => osdConfig.value.layout,
|
||||
() => osdConfig.value.fontSize,
|
||||
], () => {
|
||||
if (osdConfig.value.overlayEnabled) {
|
||||
updateOsdWindowSize().catch(() => {})
|
||||
}
|
||||
}))
|
||||
|
||||
// 初始化悬浮窗(如果开关已开启)
|
||||
if (osdConfig.value.overlayEnabled) {
|
||||
ensureOverlayWindow().catch(e => logger.error('[OSD] 初始化悬浮窗失败: ' + e))
|
||||
@@ -1111,9 +1156,9 @@ export const useMonitorStore = defineStore('monitor', () => {
|
||||
saveOsdConfig,
|
||||
saveOsdConfigDebounced,
|
||||
pushOsdState,
|
||||
pushOsdConfig,
|
||||
ensureOverlayWindow,
|
||||
hideOverlayWindow,
|
||||
updateOsdWindowSize,
|
||||
resetOverlayPosition,
|
||||
initOsd,
|
||||
disposeOsd,
|
||||
|
||||
+199
-55
@@ -4,6 +4,7 @@ import { invoke } from '@tauri-apps/api/core'
|
||||
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import { EVENTS } from '@/lib/constants'
|
||||
import { useDownloaderStore } from '@/stores/downloaderStore'
|
||||
// Rust 端通过 tauri-specta 生成的类型与命令绑定(bindings.ts)
|
||||
import {
|
||||
commands,
|
||||
@@ -72,6 +73,10 @@ export const useProxyStore = defineStore('proxy', () => {
|
||||
const installing = ref(false)
|
||||
const installProgress = ref<InstallProgress | null>(null)
|
||||
let progressUnlisten: UnlistenFn | null = null
|
||||
/** 当前进行中的内核安装/更新任务(取消时需等待其完全收尾,避免旧任务 finally 覆盖新任务状态) */
|
||||
let activeInstall: Promise<void> | null = null
|
||||
/** 取消下载等待的唤醒函数(cancelKernelInstall 调用,置位后下载等待立即以「已取消」返回) */
|
||||
let cancelDownload: (() => void) | null = null
|
||||
|
||||
/** 内核信息(同时尝试从 resource 提取到 cores/) */
|
||||
const refreshKernel = async () => {
|
||||
@@ -101,11 +106,15 @@ export const useProxyStore = defineStore('proxy', () => {
|
||||
const stop = async () => {
|
||||
await commands.proxyStop()
|
||||
await refreshStatus()
|
||||
// 后端关闭 mihomo 时会同步关闭系统代理,这里立即刷新 UI 状态(不等 3s 轮询)
|
||||
await refreshSystemProxy()
|
||||
}
|
||||
|
||||
const restart = async () => {
|
||||
await commands.proxyRestart()
|
||||
await refreshStatus()
|
||||
// 重启完成后刷新系统代理状态(后端失败时已同步关闭,成功时重新开启)
|
||||
await refreshSystemProxy()
|
||||
}
|
||||
|
||||
/** 等待 mihomo API 就绪(轮询 version 接口,最多等 10 秒) */
|
||||
@@ -260,12 +269,34 @@ export const useProxyStore = defineStore('proxy', () => {
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新内核:复用 installKernel 的完整进度事件机制(installing/installProgress/事件监听)
|
||||
* 与 installKernel 的区别仅在于后端会先 stop mihomo(由调用方在前端控制),
|
||||
* 后端 proxy_update_kernel 与 proxy_install_kernel 共用 install_kernel 实现
|
||||
* 通过下载模块下载内核 zip,然后调用后端 apply_kernel_update 完成解压替换。
|
||||
* 下载进度通过 download-progress 事件更新,解压/替换进度通过 kernel-install-progress 事件更新。
|
||||
* 成功后自动删除下载任务。
|
||||
* @param mirrorPrefix 镜像源前缀(空串=GitHub 直连)
|
||||
* @param githubUrl GitHub 原始下载 URL(前端已通过 checkKernelUpdate 获取,为空时自动调用 checkKernelUpdate 获取)
|
||||
*/
|
||||
const updateKernel = async (mirrorPrefix: string = ''): Promise<void> => {
|
||||
const updateKernel = async (mirrorPrefix: string = '', githubUrl: string = ''): Promise<void> => {
|
||||
if (installing.value) return
|
||||
// 等待上一个任务完全收尾
|
||||
if (activeInstall) {
|
||||
try { await activeInstall } catch {}
|
||||
}
|
||||
|
||||
// 确定下载 URL
|
||||
let finalUrl = githubUrl
|
||||
if (!finalUrl) {
|
||||
try {
|
||||
const info = await commands.proxyCheckKernelUpdate()
|
||||
finalUrl = info.downloadUrl
|
||||
} catch (e) {
|
||||
throw new Error('获取更新信息失败: ' + String(e))
|
||||
}
|
||||
}
|
||||
// 应用镜像前缀
|
||||
if (mirrorPrefix) {
|
||||
finalUrl = mirrorPrefix + finalUrl
|
||||
}
|
||||
|
||||
installing.value = true
|
||||
installProgress.value = {
|
||||
stage: 'downloading',
|
||||
@@ -274,62 +305,132 @@ export const useProxyStore = defineStore('proxy', () => {
|
||||
totalBytes: null,
|
||||
message: '准备开始下载...'
|
||||
}
|
||||
if (!progressUnlisten) {
|
||||
progressUnlisten = await listen<InstallProgress>(EVENTS.kernelInstallProgress, (e) => {
|
||||
installProgress.value = e.payload
|
||||
})
|
||||
}
|
||||
try {
|
||||
await commands.proxyUpdateKernel(mirrorPrefix)
|
||||
await refreshKernel()
|
||||
} catch (e) {
|
||||
logger.error('内核更新失败: ' + e)
|
||||
throw e
|
||||
} finally {
|
||||
installing.value = false
|
||||
if (progressUnlisten) {
|
||||
progressUnlisten()
|
||||
progressUnlisten = null
|
||||
|
||||
const task = (async () => {
|
||||
let downloadProgressFn: UnlistenFn | null = null
|
||||
// 用对象持有完成事件解绑函数,避免闭包内赋值导致的 TS 类型收窄问题
|
||||
const completeHolder: { fn: UnlistenFn | null } = { fn: null }
|
||||
/** 下载阶段是否已成功完成(决定清理时是否删除文件:下载失败删除,apply 失败保留以便重试) */
|
||||
let downloadOk = false
|
||||
let taskId: string | null = null
|
||||
try {
|
||||
// 注册内核安装进度监听(解压/替换/need_stop 阶段由后端 emit)
|
||||
if (!progressUnlisten) {
|
||||
progressUnlisten = await listen<InstallProgress>(EVENTS.kernelInstallProgress, (e) => {
|
||||
installProgress.value = e.payload
|
||||
})
|
||||
}
|
||||
|
||||
// 使用下载模块添加下载任务(保持下载模块自身的代理/进度机制)
|
||||
const downloaderStore = useDownloaderStore()
|
||||
try {
|
||||
// 确保下载模块事件监听已注册(下载器 UI 与这里共用事件流)
|
||||
await downloaderStore.startEventListeners()
|
||||
} catch {
|
||||
// 忽略:本流程自己也会监听进度/完成事件
|
||||
}
|
||||
taskId = await downloaderStore.addTask(finalUrl, 'mihomo-update.zip', undefined, {}, false)
|
||||
|
||||
// 监听下载进度,转为 InstallProgress 格式
|
||||
downloadProgressFn = await listen<{
|
||||
id: string; completedSize: number; totalSize: number; speed: number; status: string
|
||||
}>('download-progress', (e) => {
|
||||
if (e.payload.id === taskId) {
|
||||
const pct = e.payload.totalSize > 0
|
||||
? Math.round((e.payload.completedSize / e.payload.totalSize) * 90)
|
||||
: 0
|
||||
installProgress.value = {
|
||||
stage: 'downloading',
|
||||
percent: pct,
|
||||
downloadedBytes: e.payload.completedSize,
|
||||
totalBytes: e.payload.totalSize,
|
||||
message: '正在下载...'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 等待下载完成(download-complete 事件 / 初始终态 / 取消唤醒 三选一)
|
||||
const dlResult = await new Promise<{ ok: boolean; error?: string }>((resolve) => {
|
||||
let settled = false
|
||||
const finish = (r: { ok: boolean; error?: string }) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
cancelDownload = null
|
||||
resolve(r)
|
||||
}
|
||||
// 任务添加后瞬间进入终态(如探测即失败 → Error,无 complete 事件)
|
||||
const initial = downloaderStore.tasks.find(t => t.id === taskId)
|
||||
if (initial) {
|
||||
if (initial.status === 'complete') { finish({ ok: true }); return }
|
||||
if (initial.status === 'error') { finish({ ok: false, error: initial.error || '下载失败' }); return }
|
||||
}
|
||||
cancelDownload = () => finish({ ok: false, error: '下载已取消' })
|
||||
listen<{ id: string; filename: string; status: string; error: string | null }>(
|
||||
'download-complete', (e) => {
|
||||
if (e.payload.id === taskId) {
|
||||
if (e.payload.status === 'complete') finish({ ok: true })
|
||||
else finish({ ok: false, error: e.payload.error || '下载失败' })
|
||||
}
|
||||
}
|
||||
).then(fn => { completeHolder.fn = fn })
|
||||
})
|
||||
|
||||
if (!dlResult.ok) {
|
||||
throw new Error(dlResult.error || '下载失败')
|
||||
}
|
||||
downloadOk = true
|
||||
|
||||
// 获取下载任务的保存路径
|
||||
const dlTask = downloaderStore.tasks.find(t => t.id === taskId)
|
||||
if (!dlTask) throw new Error('下载任务未找到')
|
||||
const zipPath = dlTask.dir + '/' + dlTask.filename
|
||||
|
||||
// 调用后端执行解压替换(need_stop → 解压 → 替换,进度由 kernel-install-progress 事件上报)
|
||||
await commands.proxyApplyKernelUpdate(zipPath)
|
||||
await refreshKernel()
|
||||
|
||||
// 更新成功后自动删除下载任务(zip 已被后端清理,不删除磁盘文件)
|
||||
await downloaderStore.removeTask(taskId, false)
|
||||
taskId = null
|
||||
} catch (e) {
|
||||
// 清理下载任务:下载阶段失败/取消时删除文件;apply 阶段失败仅移除任务(zip 保留便于重试)
|
||||
if (taskId) {
|
||||
try {
|
||||
const downloaderStore = useDownloaderStore()
|
||||
await downloaderStore.removeTask(taskId, !downloadOk)
|
||||
} catch {
|
||||
// 忽略清理错误(任务可能已被移除)
|
||||
}
|
||||
}
|
||||
if (String(e).includes('下载已取消')) return
|
||||
logger.error('内核更新失败: ' + e)
|
||||
throw e
|
||||
} finally {
|
||||
installing.value = false
|
||||
cancelDownload = null
|
||||
if (progressUnlisten) {
|
||||
progressUnlisten()
|
||||
progressUnlisten = null
|
||||
}
|
||||
if (downloadProgressFn) downloadProgressFn()
|
||||
if (completeHolder.fn) completeHolder.fn()
|
||||
}
|
||||
})()
|
||||
activeInstall = task
|
||||
try {
|
||||
await task
|
||||
} finally {
|
||||
if (activeInstall === task) activeInstall = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 首次安装内核:调用后端 install_kernel,监听内核安装进度事件更新进度
|
||||
* @param mirrorPrefix 镜像源前缀(空串=GitHub 直连)
|
||||
* 完成或出错后自动取消监听并清空进度(由调用方控制何时隐藏 UI)
|
||||
* 首次安装内核:与 updateKernel 逻辑相同,但无已有 githubUrl 时自动获取。
|
||||
* 下载阶段使用下载模块,解压替换阶段使用后端 apply_kernel_update。
|
||||
*/
|
||||
const installKernel = async (mirrorPrefix: string = ''): Promise<void> => {
|
||||
if (installing.value) return
|
||||
installing.value = true
|
||||
installProgress.value = {
|
||||
stage: 'downloading',
|
||||
percent: 0,
|
||||
downloadedBytes: 0,
|
||||
totalBytes: null,
|
||||
message: '准备开始下载...'
|
||||
}
|
||||
// 注册进度事件监听
|
||||
if (!progressUnlisten) {
|
||||
progressUnlisten = await listen<InstallProgress>(EVENTS.kernelInstallProgress, (e) => {
|
||||
installProgress.value = e.payload
|
||||
})
|
||||
}
|
||||
try {
|
||||
await commands.proxyInstallKernel(mirrorPrefix)
|
||||
await refreshKernel()
|
||||
} catch (e) {
|
||||
// 错误事件已由后端 emit,这里仅记录日志
|
||||
logger.error('内核安装失败: ' + e)
|
||||
throw e
|
||||
} finally {
|
||||
// 保留 installProgress 一段时间供 UI 显示终态,由调用方负责清空
|
||||
installing.value = false
|
||||
if (progressUnlisten) {
|
||||
progressUnlisten()
|
||||
progressUnlisten = null
|
||||
}
|
||||
}
|
||||
const installKernel = async (mirrorPrefix: string = '', _githubUrl: string = ''): Promise<void> => {
|
||||
// 直接委托给 updateKernel(无 githubUrl 时内部自动获取)
|
||||
await updateKernel(mirrorPrefix, _githubUrl)
|
||||
}
|
||||
|
||||
/** 清空进度状态(UI 在动画结束后调用) */
|
||||
@@ -337,6 +438,47 @@ export const useProxyStore = defineStore('proxy', () => {
|
||||
installProgress.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
* 确认 mihomo 已停止,唤醒后端等待中的安装流程继续解压替换。
|
||||
* 需在 store.stop() 成功后再调用(解压替换时 exe 文件被占用会失败)。
|
||||
*/
|
||||
const confirmInstall = async (): Promise<void> => {
|
||||
await commands.proxyConfirmInstall()
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消内核下载/安装:
|
||||
* - 下载阶段:唤醒下载等待 → 流程内移除下载任务(删除已下载文件)
|
||||
* - need_stop 等待阶段:置位后端取消标志唤醒其返回(zip 保留,便于重试)
|
||||
* 等待旧任务真正结束是关键 —— 否则旧任务的 finally 会在新任务开始后执行,
|
||||
* 解绑新任务的进度监听并复位 installing,导致新下载"点了没反应"
|
||||
*/
|
||||
const cancelKernelInstall = async (): Promise<void> => {
|
||||
// 1. 唤醒下载等待(若正在下载,下载等待立即以「已取消」返回,由流程内清理任务)
|
||||
cancelDownload?.()
|
||||
// 2. 通知后端(若处于 need_stop 等待阶段,置位取消标志唤醒其返回)
|
||||
try {
|
||||
await commands.proxyCancelKernelInstall()
|
||||
} catch (e) {
|
||||
logger.error('取消内核下载失败: ' + e)
|
||||
}
|
||||
// 3. 等待旧任务完全收尾(其内部会移除下载任务并复位状态)
|
||||
if (activeInstall) {
|
||||
try {
|
||||
await activeInstall
|
||||
} catch {
|
||||
// 旧任务错误已在任务内部处理
|
||||
}
|
||||
}
|
||||
// 4. 复位 UI 状态
|
||||
if (progressUnlisten) {
|
||||
progressUnlisten()
|
||||
progressUnlisten = null
|
||||
}
|
||||
installing.value = false
|
||||
installProgress.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
// state
|
||||
kernel,
|
||||
@@ -378,6 +520,8 @@ export const useProxyStore = defineStore('proxy', () => {
|
||||
checkKernelUpdate,
|
||||
updateKernel,
|
||||
installKernel,
|
||||
clearInstallProgress
|
||||
clearInstallProgress,
|
||||
cancelKernelInstall,
|
||||
confirmInstall
|
||||
}
|
||||
})
|
||||
|
||||
@@ -8,6 +8,7 @@ import { toast } from 'vue-sonner'
|
||||
import { EVENTS, STORAGE_KEYS, WINDOWS } from '@/lib/constants'
|
||||
// Rust 端通过 tauri-specta 生成的命令绑定(bindings.ts)
|
||||
import { commands } from '@/lib/bindings'
|
||||
import type { WindowInfo } from '@/modules/screenshot/types'
|
||||
|
||||
export interface RecentCapture {
|
||||
id: string
|
||||
@@ -67,6 +68,8 @@ export const useScreenshotStore = defineStore('screenshot', () => {
|
||||
let overlayReadyResolve: (() => void) | null = null
|
||||
let overlayReadyPromise: Promise<void> | null = null
|
||||
let readyListenerInit = false
|
||||
/** 上次已应用的覆盖层布局矩形(虚拟屏未变化时跳过 setPosition/setSize,省 2 次 IPC 往返) */
|
||||
let lastOverlayRectKey = ''
|
||||
|
||||
// ===== 贴图窗口 =====
|
||||
const PIN_LABEL = WINDOWS.screenshotPin
|
||||
@@ -141,7 +144,7 @@ export const useScreenshotStore = defineStore('screenshot', () => {
|
||||
}
|
||||
|
||||
// ===== 截图流程 =====
|
||||
/** 启动截图:延时倒计时 → 捕获虚拟屏 → 定位常驻覆盖层 → 通知覆盖层开始 */
|
||||
/** 启动截图:延时倒计时 → 并行(捕获虚拟屏 + 枚举拾取窗口 + 计算虚拟屏矩形)→ 定位覆盖层 → 通知覆盖层开始 */
|
||||
async function startCapture() {
|
||||
if (capturing.value) return
|
||||
capturing.value = true
|
||||
@@ -163,14 +166,28 @@ export const useScreenshotStore = defineStore('screenshot', () => {
|
||||
await new Promise<void>((r) => setTimeout(r, 1000))
|
||||
}
|
||||
}
|
||||
// 捕获虚拟屏(覆盖层隐藏 → 不会出现在截图中),仅存原始像素,不做 PNG 编码
|
||||
await commands.screenshotCaptureFullscreen()
|
||||
// 用物理像素把覆盖层对齐到虚拟屏(多显示器/混合 DPI 下保证底图 1:1 与坐标一致)
|
||||
const rect = await computeVirtualPhysicalRect()
|
||||
await overlayWin?.setPosition(new PhysicalPosition(rect.x, rect.y))
|
||||
await overlayWin?.setSize(new PhysicalSize(rect.width, rect.height))
|
||||
// 三路并行:捕获(含光标)/ 拾取窗口列表 / 虚拟屏矩形 —— 互不依赖,缩短关键路径
|
||||
//(捕获时覆盖层已隐藏 → 不会出现在截图中;窗口列表与冻结底图同一时刻生成,命中一致)
|
||||
const [start, pickWindows, rect] = await Promise.all([
|
||||
commands.screenshotCaptureFullscreen(),
|
||||
commands.screenshotPickList().catch(() => [] as WindowInfo[]),
|
||||
computeVirtualPhysicalRect(),
|
||||
])
|
||||
// 用物理像素把覆盖层对齐到虚拟屏(多显示器/混合 DPI 下保证底图 1:1 与坐标一致);
|
||||
// 矩形未变化时跳过(覆盖层仅由本流程定位,跳过安全),省 2 次 IPC 往返
|
||||
const rectKey = `${rect.x},${rect.y},${rect.width},${rect.height}`
|
||||
if (rectKey !== lastOverlayRectKey) {
|
||||
await overlayWin?.setPosition(new PhysicalPosition(rect.x, rect.y))
|
||||
await overlayWin?.setSize(new PhysicalSize(rect.width, rect.height))
|
||||
lastOverlayRectKey = rectKey
|
||||
}
|
||||
await waitOverlayReady()
|
||||
await emit(EVENTS.screenshotBegin)
|
||||
// 携带捕获时刻光标 + 窗口列表:覆盖层显示前即可完成初始高亮,无"遮罩→高亮"闪烁
|
||||
await emit(EVENTS.screenshotBegin, {
|
||||
cursorX: start.cursorX,
|
||||
cursorY: start.cursorY,
|
||||
windows: pickWindows,
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('[screenshot] 捕获失败', e)
|
||||
toast.error('截图启动失败:' + (e as Error).message)
|
||||
@@ -552,6 +569,8 @@ export const useScreenshotStore = defineStore('screenshot', () => {
|
||||
if (pinWin) {
|
||||
try {
|
||||
if (await pinWin.isVisible()) {
|
||||
// 还原不可聚焦(贴图窗口交互时可能已置为可聚焦),避免下次 show() 激活窗口抢走当前应用焦点
|
||||
await pinWin.setFocusable(false).catch(() => {})
|
||||
await pinWin.hide()
|
||||
return
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user