细节调整及优化(26.8.3)

This commit is contained in:
zhongluofeng
2026-08-21 17:25:47 +08:00
parent 0a19b4b38a
commit 4dd60f42a1
72 changed files with 4779 additions and 1395 deletions
+13 -4
View File
@@ -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)
}
+21 -1
View File
@@ -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
View File
@@ -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 * 100availW = 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
View File
@@ -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
}
})
+27 -8
View File
@@ -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
}