细节调整及优化(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
+169 -113
View File
@@ -7,12 +7,14 @@ import {
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { toast } from 'vue-sonner'
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 { useModuleTabs } from '@/lib/use-module-tabs'
import { useModuleTabsStore } from '@/stores/moduleTabsStore'
import { createLogger } from '@/lib/logger'
import { EVENTS } from '@/lib/constants'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
@@ -66,7 +68,14 @@ const onConfirmCancel = () => {
confirmState.value.resolve?.(false)
}
const onConfirmOpenChange = (open: boolean) => {
if (!open) onConfirmCancel()
// reka-ui 的 AlertDialogAction/Cancel 点击时会先触发 update:open(false)(自动关闭),
// 再触发各自的 @click。若关闭事件立即按「取消」处理,会把确认误判为取消(确认按钮点了没反应)。
// 因此关闭时的取消判定推迟到当前事件循环的 click 处理器执行完毕后再进行。
if (!open && !confirmState.value.resolved) {
setTimeout(() => {
if (!confirmState.value.resolved) onConfirmCancel()
}, 0)
}
confirmState.value.open = open
}
@@ -90,16 +99,13 @@ const testingGroups = ref<Set<string>>(new Set())
const loadingProxies = ref(false)
const checkingUpdate = ref(false)
const updatingKernel = ref(false)
const kernelUpdateInfo = ref<{ latestVersion: string; hasUpdate: boolean } | null>(null)
const kernelUpdateInfo = ref<{ latestVersion: string; hasUpdate: boolean; downloadUrl: string } | null>(null)
// 自动切换节点(从 settings 持久化)
// 自动切换节点(从 settings 持久化;执行由后端调度
const autoSwitchEnabled = ref(false)
const autoSwitchInterval = ref(5) // 分钟
const autoSwitchTargetGroup = ref('') // 目标代理组
const autoSwitchRegion = ref('') // 空=全部,否则按地区过滤
let autoSwitchTimer: ReturnType<typeof setInterval> | null = null
/** 自动切换执行中标志(防重入:测速超时时上一轮未结束,间隔触发会重叠) */
let autoSwitchRunning = false
// 从 store.settings 同步自动切换设置
const syncAutoSwitchSettings = () => {
@@ -127,6 +133,9 @@ const saveAutoSwitchSettings = async () => {
}
}
// 后端自动切换事件监听句柄(模块卸载时关闭)
let autoSwitchUnlisten: UnlistenFn[] = []
// 手风琴展开项
const accordionValue = ref<string>('')
@@ -328,10 +337,6 @@ const init = async () => {
await store.waitForApi()
store.refreshVersion()
loadProxiesWithError()
// 若自动切换已开启,恢复定时器(静默,不弹通知、不立即执行)
if (autoSwitchEnabled.value) {
startAutoSwitch(false, false)
}
}
} finally {
// 无论初始化链路是否出错,都结束"加载中"占位,避免模块永久卡死
@@ -350,12 +355,17 @@ onMounted(() => {
}, 3000)
// 页面重新可见时立即刷新一次系统代理状态(切回标签页/从托盘返回主窗口)
document.addEventListener('visibilitychange', onVisibilityChange)
// 监听后端自动切换节点完成事件(后台执行,不依赖模块激活)
listen<{ switched?: boolean; group?: string; name?: string; delay?: number }>(EVENTS.proxyAutoSwitch, onProxyAutoSwitch)
.then(fn => autoSwitchUnlisten.push(fn))
.catch(err => logger.error('注册自动切换事件监听失败: ' + err))
})
onUnmounted(() => {
if (statusTimer) clearInterval(statusTimer)
document.removeEventListener('visibilitychange', onVisibilityChange)
stopAutoSwitch()
autoSwitchUnlisten.forEach(fn => fn())
autoSwitchUnlisten = []
})
/** 页面可见性变化时刷新系统代理状态(低成本感知外部修改) */
@@ -370,11 +380,6 @@ watch(running, async (val, old) => {
await store.waitForApi()
await store.refreshVersion()
await loadProxiesWithError()
// 自动切换若已开启,mihomo 启动/重启后恢复定时器(静默,不弹通知)
// handleStop 会停掉旧定时器,此处统一接管启动路径,避免开关显示开但功能静默失效)
if (autoSwitchEnabled.value) {
startAutoSwitch(false)
}
}
})
@@ -417,7 +422,6 @@ const handleStart = async () => {
const handleStop = async () => {
stopping.value = true
try {
stopAutoSwitch()
await store.stop()
toast.success('mihomo 已停止')
} catch (e) {
@@ -448,6 +452,12 @@ const handleRestart = async () => {
// ===== 系统代理 =====
const onToggleSystemProxy = async (on: boolean) => {
// 停机时禁止开启(正常情况下开关已禁用,此处兜底防止外部调用)
if (on && !running.value) {
toast.warning('请先启动 mihomo 再开启系统代理')
store.refreshSystemProxy()
return
}
sysProxyLoading.value = true
try {
await store.toggleSystemProxy(on)
@@ -485,46 +495,26 @@ const quickSwitchNode = async (name: string) => {
try {
await store.selectProxy(mainGroupName.value, name)
toast.success('节点已切换', { description: name })
// 测速新节点
store.testDelay(name).then(delay => {
toast.success(`${name}`, { description: `延迟 ${delay}ms` })
// 测速新节点:用 testDelayBatch 以更新 history,保证节点 Badge 显示与结果一致
store.testDelayBatch([name]).then(() => {
const delay = store.proxies[name]?.history?.[0]?.delay
if (delay && delay > 0) {
toast.success(`${name}`, { description: `延迟 ${delay}ms` })
}
}).catch(() => {})
} catch (e) {
toast.error('切换节点失败', { description: String(e) })
}
}
// ===== 自动切换节点 =====
const startAutoSwitch = (notify = true, immediate = true) => {
stopAutoSwitch()
if (!autoSwitchEnabled.value) return
const ms = autoSwitchInterval.value * 60 * 1000
autoSwitchTimer = setInterval(runAutoSwitch, ms)
// 仅用户主动开启时提示;模块挂载/内核重启恢复定时器时静默,避免每次切换都弹通知
if (notify) {
toast.success('自动切换已开启', {
description: `${autoSwitchInterval.value} 分钟测试并切换至最优节点`
})
}
// 立即执行一次(用户主动开启/调整时立即生效;进入模块恢复时跳过,避免每次进入都测速切换)
if (immediate) {
runAutoSwitch()
}
}
const stopAutoSwitch = () => {
if (autoSwitchTimer) {
clearInterval(autoSwitchTimer)
autoSwitchTimer = null
}
}
// ===== 自动切换节点(执行由后端调度,前端仅负责维护设置并刷新/提示) =====
const onToggleAutoSwitch = (on: boolean) => {
autoSwitchEnabled.value = on
if (on) {
startAutoSwitch()
toast.success('自动切换已开启', {
description: `${autoSwitchInterval.value} 分钟测试并切换至最优节点`
})
} else {
stopAutoSwitch()
toast.info('自动切换已关闭')
}
saveAutoSwitchSettings()
@@ -532,52 +522,21 @@ const onToggleAutoSwitch = (on: boolean) => {
const onAutoSwitchIntervalChange = (val: unknown) => {
autoSwitchInterval.value = Number(val) || 5
if (autoSwitchEnabled.value) {
startAutoSwitch()
}
saveAutoSwitchSettings()
}
const runAutoSwitch = async () => {
if (autoSwitchRunning) return
autoSwitchRunning = true
/** 后端自动切换完成后刷新节点列表并提示(后台亦可运行,不依赖模块激活) */
const onProxyAutoSwitch = async (e: { payload: { switched?: boolean; group?: string; name?: string; delay?: number } }) => {
const p = e.payload
try {
const groupName = autoSwitchTargetGroup.value || mainGroupName.value
if (!groupName || !running.value) return
const nodes = filteredNodes.value
if (!nodes.length) return
// 使用 testDelayBatch 测速,它会更新 store.proxies[name].history
// 确保 UI 显示的延迟与选优结果一致
await store.testDelayBatch(nodes)
// 从更新后的 history 读取最新延迟
const results = nodes.map(name => ({
name,
delay: store.proxies[name]?.history?.[0]?.delay ?? 0
}))
// 找到有效延迟中最低的
const valid = results.filter(r => r.delay > 0)
if (!valid.length) {
toast.warning('所有节点均超时,未切换')
return
}
valid.sort((a, b) => a.delay - b.delay)
const best = valid[0]
// 如果当前节点不是最优,则切换
const currentNow = store.proxies[groupName]?.now ?? ''
if (currentNow !== best.name) {
await store.selectProxy(groupName, best.name)
toast.success('已自动切换到最优节点', {
description: `${best.name} (${best.delay}ms)`
})
}
} catch (e) {
logger.error('自动切换失败: ' + e)
} finally {
autoSwitchRunning = false
await store.loadProxies()
} catch (err) {
logger.error('自动切换后刷新节点失败: ' + err)
}
if (p?.switched && p.name && p.delay) {
toast.success('已自动切换到最优节点', {
description: `${p.name} (${p.delay}ms)`
})
}
}
@@ -586,7 +545,7 @@ const handleCheckUpdate = async () => {
checkingUpdate.value = true
try {
const info = await store.checkKernelUpdate()
kernelUpdateInfo.value = { latestVersion: info.latestVersion, hasUpdate: info.hasUpdate }
kernelUpdateInfo.value = { latestVersion: info.latestVersion, hasUpdate: info.hasUpdate, downloadUrl: info.downloadUrl }
if (info.hasUpdate) {
toast.info('发现新版本', { description: `最新: ${info.latestVersion}` })
} else {
@@ -607,31 +566,26 @@ const handleUpdateKernel = async () => {
/** 是否展开"更新内核"区块(下载源 + 进度) */
const updateExpanded = ref(false)
/** 开始更新:停止 mihomo → 调用 updateKernel(复用 installProgress 进度机制) */
/** 开始更新:确保有下载 URL → 调用 updateKernel(复用 installProgress 进度机制)
* 下载阶段允许 mihomo 运行(可通过当前系统代理下载),
* 解压替换前由 need_stop 阶段弹窗要求停止 mihomo */
const handleStartUpdate = async () => {
if (store.installing) return
// 确认停止 mihomo
if (running.value) {
const ok = await showConfirm({
title: '更新内核',
description: '更新内核需要先停止 mihomo,确认继续?',
confirmText: '继续更新'
})
if (!ok) return
updatingKernel.value = true
// 使用检查更新时获取的下载 URL(缺失时先补查一次,避免后端二次请求 GitHub)
let url = kernelUpdateInfo.value?.downloadUrl ?? ''
if (!url) {
try {
await store.stop()
const info = await store.checkKernelUpdate()
url = info.downloadUrl
kernelUpdateInfo.value = { latestVersion: info.latestVersion, hasUpdate: info.hasUpdate, downloadUrl: info.downloadUrl }
} catch (e) {
toast.error('停止 mihomo 失败', { description: String(e) })
updatingKernel.value = false
toast.error('获取更新信息失败', { description: String(e) })
return
} finally {
updatingKernel.value = false
}
}
toast.info('开始下载更新...')
try {
await store.updateKernel(selectedMirrorPrefix.value)
await store.updateKernel(selectedMirrorPrefix.value, url)
} catch (e) {
toast.error('内核更新失败', { description: String(e) })
}
@@ -641,7 +595,9 @@ const handleStartUpdate = async () => {
const installStageText = computed(() => {
const stage = store.installProgress?.stage
switch (stage) {
case 'checking': return '正在检查'
case 'downloading': return '正在下载'
case 'need_stop': return '等待停止 mihomo'
case 'extracting': return '正在解压'
case 'replacing': return '正在安装'
case 'done': return '安装完成'
@@ -654,6 +610,7 @@ const installStageColor = computed(() => {
const stage = store.installProgress?.stage
if (stage === 'done') return 'text-emerald-500'
if (stage === 'error') return 'text-destructive'
if (stage === 'need_stop') return 'text-amber-500'
return 'text-primary'
})
@@ -672,6 +629,25 @@ const installPercentDisplay = computed(() => {
const installHasTotal = computed(() => store.installProgress?.totalBytes != null)
/** 停止下载进行中标记(防止重复点击) */
const stoppingDownload = ref(false)
/** 停止下载:通知后端中止,并立即回退到下载方式卡片(保留 updateExpanded 供重新选择) */
const handleStopDownload = async () => {
if (stoppingDownload.value || !store.installing) return
stoppingDownload.value = true
try {
await store.cancelKernelInstall()
// 立即复位 UI 状态回退到下载方式卡片;后端下载循环稍后中止,updateKernel 会静默结束
store.installing = false
store.clearInstallProgress()
} catch (e) {
toast.error('停止下载失败', { description: String(e) })
} finally {
stoppingDownload.value = false
}
}
const formatMB = (bytes: number) => `${(bytes / 1024 / 1024).toFixed(2)} MB`
// ===== 首次安装内核 =====
@@ -709,12 +685,55 @@ const handleInstallKernel = async () => {
}
}
/** need_stop 弹窗处理中标记(防止重复触发) */
let handlingNeedStop = false
/**
* 下载完成、解压替换前:弹窗提示用户停止 mihomo,确认后停止 mihomo 并唤醒后端继续安装。
* 取消则中止整个安装流程(后端正在等待确认,通过取消唤醒)。
*/
const handleNeedStop = async () => {
if (handlingNeedStop) return
handlingNeedStop = true
try {
const wasRunning = running.value
const ok = await showConfirm({
title: '停止 mihomo 后继续',
description: wasRunning
? '下载已完成。安装新内核前需要停止 mihomo,点击「停止并继续」将自动停止 mihomo 并完成安装。'
: '下载已完成。即将安装新内核,点击「继续」完成安装。',
confirmText: wasRunning ? '停止并继续' : '继续'
})
if (!ok) {
// 用户取消:中止安装(后端在等待确认,置取消标志唤醒其返回)
await store.cancelKernelInstall()
return
}
if (wasRunning) {
await store.stop()
}
await store.confirmInstall()
} catch (e) {
toast.error('停止 mihomo 失败', { description: String(e) })
// 停止失败则中止安装,避免替换阶段因 exe 占用而报错
try {
await store.cancelKernelInstall()
} catch {
// 忽略:cancelKernelInstall 内部已记录日志
}
} finally {
handlingNeedStop = false
}
}
// 监听安装/更新进度终态,弹 toast 并延时清空进度
// 同时处理更新场景下的 updateExpanded 清理(与 installProgress 同步清除,避免更新区块闪烁)
watch(
() => store.installProgress?.stage,
(stage) => {
if (stage === 'done') {
if (stage === 'need_stop') {
handleNeedStop()
} else if (stage === 'done') {
toast.success('内核安装完成', {
description: store.installProgress?.message
})
@@ -899,12 +918,35 @@ watch(() => store.settings, syncLocalSettings, { immediate: true })
const saveSettingsForm = async () => {
if (!store.settings) return
const prev = store.settings
try {
await store.saveSettings({
...store.settings,
...localSettings.value
})
toast.success('设置已保存')
// 网络相关字段(端口/接口/密钥)变更需重启 mihomo 才生效,运行实例仍在旧值上;
// 提示用户重启,避免后续代理 API 调用打到新地址而失败
const networkChanged =
localSettings.value.mixedPort !== prev.mixedPort ||
localSettings.value.externalController !== prev.externalController ||
localSettings.value.secret !== prev.secret
if (networkChanged && running.value) {
toast.warning('端口/接口/密钥已保存,重启 mihomo 后生效(期间代理 API 使用新地址可能暂时不可用)')
} else {
toast.success('设置已保存')
}
// 纯模式变更(网络字段未变)在运行中即时生效,与概览页行为一致,
// 避免「UI 显示新模式、运行实例仍是旧模式」的不一致
if (!networkChanged && localSettings.value.mode !== prev.mode && running.value) {
try {
await invokePatchConfigs({ mode: localSettings.value.mode })
await store.loadProxies()
} catch (modeErr) {
logger.error('运行中应用模式失败,重启后生效: ' + modeErr)
}
}
} catch (e) {
toast.error('保存失败', { description: String(e) })
}
@@ -1128,12 +1170,12 @@ tabsStore.registerSave(saveSettingsForm)
<div class="flex items-center justify-between text-xs">
<span :class="installStageColor" class="flex items-center gap-1.5 font-medium">
<Loader2
v-if="['downloading', 'extracting', 'replacing'].includes(store.installProgress.stage)"
v-if="['checking', 'downloading', 'extracting', 'replacing'].includes(store.installProgress.stage)"
key="stage-loading"
class="size-3 animate-spin"
/>
<Check v-else-if="store.installProgress.stage === 'done'" key="stage-done" class="size-3" />
<AlertCircle v-else-if="store.installProgress.stage === 'error'" key="stage-error" class="size-3" />
<AlertCircle v-else-if="['need_stop', 'error'].includes(store.installProgress.stage)" key="stage-warn" class="size-3" />
{{ installStageText }}
</span>
<span v-if="installHasTotal && store.installProgress.stage === 'downloading'" class="font-mono text-muted-foreground">
@@ -1141,7 +1183,7 @@ tabsStore.registerSave(saveSettingsForm)
</span>
</div>
<Progress
v-if="installHasTotal || store.installProgress.stage !== 'downloading'"
v-if="installHasTotal || (store.installProgress.stage !== 'downloading' && store.installProgress.stage !== 'checking')"
key="progress-bar"
:model-value="installPercentDisplay"
class="h-2"
@@ -1163,6 +1205,20 @@ tabsStore.registerSave(saveSettingsForm)
</template>
<template v-else>{{ store.installProgress.message }}</template>
</p>
<!-- 停止下载仅在进行中的检查/下载阶段显示点击后回退到下载方式卡片 -->
<div
v-if="['checking', 'downloading'].includes(store.installProgress.stage)"
class="flex justify-end"
>
<Button
size="sm" variant="outline" class="h-7 text-xs"
:disabled="stoppingDownload"
@click="handleStopDownload"
>
<Loader2 v-if="stoppingDownload" class="size-3 animate-spin" />
<Square v-else class="size-3" />停止下载
</Button>
</div>
</div>
</CardContent>
</Card>
@@ -1371,7 +1427,7 @@ tabsStore.registerSave(saveSettingsForm)
</div>
<Switch
:model-value="store.systemProxy"
:disabled="sysProxyLoading"
:disabled="sysProxyLoading || !running"
@update:model-value="onToggleSystemProxy"
/>
</CardContent>
@@ -1627,7 +1683,7 @@ tabsStore.registerSave(saveSettingsForm)
</Tabs>
<!-- 通用确认对话框 -->
<AlertDialog :model-value="confirmState.open" @update:model-value="onConfirmOpenChange">
<AlertDialog :open="confirmState.open" @update:open="onConfirmOpenChange">
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{{ confirmState.opts.title }}</AlertDialogTitle>