1640 lines
64 KiB
Vue
1640 lines
64 KiB
Vue
<script setup lang="ts">
|
||
import {
|
||
Globe, Play, Square, RotateCw, Power, Zap, Plus, RefreshCw, Trash2,
|
||
Check, AlertCircle, Server, Settings as SettingsIcon, ListChecks,
|
||
Upload, Link2, Loader2, Download, Timer, Target, FolderOpen, Copy, DownloadCloud
|
||
} from '@lucide/vue'
|
||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||
import { toast } from 'vue-sonner'
|
||
import { invoke } from '@tauri-apps/api/core'
|
||
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 { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||
import { Button } from '@/components/ui/button'
|
||
import { Badge } from '@/components/ui/badge'
|
||
import { Switch } from '@/components/ui/switch'
|
||
import { Input } from '@/components/ui/input'
|
||
import { Label } from '@/components/ui/label'
|
||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
|
||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||
import { Separator } from '@/components/ui/separator'
|
||
import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from '@/components/ui/accordion'
|
||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||
import { Progress } from '@/components/ui/progress'
|
||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||
import {
|
||
AlertDialog, AlertDialogAction, AlertDialogCancel,
|
||
AlertDialogContent, AlertDialogDescription, AlertDialogFooter,
|
||
AlertDialogHeader, AlertDialogTitle
|
||
} from '@/components/ui/alert-dialog'
|
||
|
||
const store = useProxyStore()
|
||
const logger = createLogger('proxy')
|
||
|
||
// ===== 通用确认对话框(替代原生 confirm) =====
|
||
interface ConfirmOptions {
|
||
title: string
|
||
description: string
|
||
confirmText?: string
|
||
cancelText?: string
|
||
destructive?: boolean
|
||
}
|
||
const confirmState = ref<{
|
||
open: boolean
|
||
opts: ConfirmOptions
|
||
resolved: boolean
|
||
resolve?: (v: boolean) => void
|
||
}>({ open: false, opts: { title: '', description: '' }, resolved: false })
|
||
|
||
const showConfirm = (opts: ConfirmOptions): Promise<boolean> => {
|
||
return new Promise((resolve) => {
|
||
confirmState.value = { open: true, opts, resolved: false, resolve }
|
||
})
|
||
}
|
||
const onConfirmAction = () => {
|
||
if (confirmState.value.resolved) return
|
||
confirmState.value.resolved = true
|
||
confirmState.value.resolve?.(true)
|
||
}
|
||
const onConfirmCancel = () => {
|
||
if (confirmState.value.resolved) return
|
||
confirmState.value.resolved = true
|
||
confirmState.value.resolve?.(false)
|
||
}
|
||
const onConfirmOpenChange = (open: boolean) => {
|
||
if (!open) onConfirmCancel()
|
||
confirmState.value.open = open
|
||
}
|
||
|
||
const activeTab = ref('overview')
|
||
// 浮动标签切换器:注册到 TitleBar,滚动遮挡时自动显示
|
||
const tabsStore = useModuleTabsStore()
|
||
const tabsListRef = useModuleTabs(activeTab, [
|
||
{ value: 'overview', label: '概览' },
|
||
{ value: 'proxies', label: '节点' },
|
||
{ value: 'profiles', label: '订阅' },
|
||
{ value: 'settings', label: '设置' }
|
||
])
|
||
const starting = ref(false)
|
||
const stopping = ref(false)
|
||
const restarting = ref(false)
|
||
const sysProxyLoading = ref(false)
|
||
const importUrl = ref('')
|
||
const importName = ref('')
|
||
const importing = ref(false)
|
||
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)
|
||
|
||
// 自动切换节点(从 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 = () => {
|
||
if (store.settings) {
|
||
autoSwitchEnabled.value = store.settings.autoSwitchEnabled
|
||
autoSwitchInterval.value = store.settings.autoSwitchInterval
|
||
autoSwitchTargetGroup.value = store.settings.autoSwitchGroup
|
||
autoSwitchRegion.value = store.settings.autoSwitchRegion
|
||
}
|
||
}
|
||
|
||
// 保存自动切换设置到 settings.json
|
||
const saveAutoSwitchSettings = async () => {
|
||
if (!store.settings) return
|
||
try {
|
||
await store.saveSettings({
|
||
...store.settings,
|
||
autoSwitchEnabled: autoSwitchEnabled.value,
|
||
autoSwitchInterval: autoSwitchInterval.value,
|
||
autoSwitchGroup: autoSwitchTargetGroup.value,
|
||
autoSwitchRegion: autoSwitchRegion.value
|
||
})
|
||
} catch (e) {
|
||
logger.error('保存自动切换设置失败: ' + e)
|
||
}
|
||
}
|
||
|
||
// 手风琴展开项
|
||
const accordionValue = ref<string>('')
|
||
|
||
// 进程状态轮询
|
||
let statusTimer: ReturnType<typeof setInterval> | null = null
|
||
|
||
const running = computed(() => store.status.running)
|
||
|
||
// 伪节点关键词:DIRECT/REJECT/流量/套餐等非具体代理节点
|
||
const PSEUDO_NODE_KEYWORDS = [
|
||
'DIRECT', 'REJECT', 'PASS', 'COMPATIBLE',
|
||
'流量', '套餐', '到期', '续费', '官网', '网站', '刷新', '更新',
|
||
'x', '×', '✕', '✖', '⭐', '★', '☆',
|
||
'GLOBAL', ' GLOBAL'
|
||
]
|
||
|
||
const isPseudoNode = (name: string): boolean => {
|
||
const upper = name.toUpperCase().trim()
|
||
if (upper === 'DIRECT' || upper === 'REJECT' || upper === 'PASS') return true
|
||
return PSEUDO_NODE_KEYWORDS.some(kw => name.includes(kw))
|
||
}
|
||
|
||
// 代理组(Selector/URLTest/Fallback/LoadBalance)
|
||
const GROUP_TYPES = ['Selector', 'URLTest', 'Fallback', 'LoadBalance']
|
||
const groups = computed<Array<[string, ProxyNode]>>(() => {
|
||
return Object.entries(store.proxies).filter(([, n]) => GROUP_TYPES.includes(n.type))
|
||
})
|
||
|
||
// Selector 组(可手动切换)
|
||
const selectorGroups = computed<Array<[string, ProxyNode]>>(() => {
|
||
return Object.entries(store.proxies).filter(([, n]) => n.type === 'Selector')
|
||
})
|
||
|
||
// 主 Selector 组(第一个,用于快捷切换和自动切换)
|
||
const mainGroup = computed<[string, ProxyNode] | null>(() => {
|
||
// 优先找名为 PROXY/节点选择/Proxy 的组
|
||
const preferred = selectorGroups.value.find(([name]) =>
|
||
['PROXY', 'Proxy', '节点选择', '代理'].includes(name)
|
||
)
|
||
return preferred ?? selectorGroups.value[0] ?? null
|
||
})
|
||
|
||
const mainGroupName = computed(() => mainGroup.value?.[0] ?? '')
|
||
const mainGroupNow = computed(() => mainGroup.value?.[1]?.now ?? '')
|
||
const mainGroupNodes = computed(() => mainGroup.value?.[1]?.all ?? [])
|
||
|
||
// 当前节点延迟
|
||
const currentNodeDelay = computed(() => {
|
||
if (!mainGroupNow.value) return undefined
|
||
return store.proxies[mainGroupNow.value]?.history?.[0]?.delay
|
||
})
|
||
|
||
// 从节点名提取地区(如 "🇯🇵日本东京01" → "🇯🇵日本东京")
|
||
const extractRegion = (name: string): string => {
|
||
// 在数字、竖线、横线、括号前截断
|
||
const m = name.split(/[\d|\--—((【]/)[0]
|
||
return m?.trim() || name
|
||
}
|
||
|
||
// 所有可选地区列表(从目标组节点提取,过滤伪节点)
|
||
const regionOptions = computed(() => {
|
||
const regions = new Set<string>()
|
||
const nodes = autoSwitchTargetGroupNodes.value
|
||
for (const node of nodes) {
|
||
if (isPseudoNode(node)) continue
|
||
const region = extractRegion(node)
|
||
if (region) regions.add(region)
|
||
}
|
||
return Array.from(regions).sort()
|
||
})
|
||
|
||
// 目标代理组的有效节点(过滤伪节点)
|
||
const autoSwitchTargetGroupNodes = computed(() => {
|
||
const groupName = autoSwitchTargetGroup.value || mainGroupName.value
|
||
const group = store.proxies[groupName]
|
||
return group?.all?.filter(n => !isPseudoNode(n)) ?? []
|
||
})
|
||
|
||
// 按地区过滤的节点
|
||
const filteredNodes = computed(() => {
|
||
const nodes = autoSwitchTargetGroupNodes.value
|
||
if (!autoSwitchRegion.value) return nodes
|
||
return nodes.filter(n => extractRegion(n) === autoSwitchRegion.value)
|
||
})
|
||
|
||
const modeOptions = [
|
||
{ value: 'rule', label: '规则' },
|
||
{ value: 'global', label: '全局' },
|
||
{ value: 'direct', label: '直连' }
|
||
]
|
||
|
||
const currentProfile = computed(() =>
|
||
store.settings?.profiles.find(p => p.id === store.settings?.currentProfile) ?? null
|
||
)
|
||
|
||
const formatSize = (bytes: number) => {
|
||
if (bytes < 1024) return `${bytes} B`
|
||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||
return `${(bytes / 1024 / 1024).toFixed(2)} MB`
|
||
}
|
||
|
||
const versionShort = computed(() => {
|
||
const v = store.kernel?.version
|
||
if (!v) return '—'
|
||
const m = v.match(/v\d+\.\d+\.\d+/)
|
||
return m ? m[0] : v.split(/\s+/).slice(0, 3).join(' ')
|
||
})
|
||
|
||
const pathShort = computed(() => {
|
||
const p = store.kernel?.path
|
||
if (!p) return '—'
|
||
const parts = p.replace(/\\/g, '/').split('/')
|
||
if (parts.length <= 4) return p
|
||
return '.../' + parts.slice(-3).join('/')
|
||
})
|
||
|
||
// appData 路径(用于将完整路径中的 appData 部分替换为 %APPDATA%)
|
||
const appDataPath = ref('')
|
||
// 内核完整路径(带 %APPDATA% 环境变量形式,可复制到文件管理器地址栏打开)
|
||
const pathDisplay = computed(() => {
|
||
const p = store.kernel?.path
|
||
if (!p) return ''
|
||
if (appDataPath.value && p.toLowerCase().startsWith(appDataPath.value.toLowerCase())) {
|
||
return '%APPDATA%' + p.slice(appDataPath.value.length)
|
||
}
|
||
return p
|
||
})
|
||
|
||
const copyPath = async () => {
|
||
const p = store.kernel?.path
|
||
if (!p) return
|
||
try {
|
||
await navigator.clipboard.writeText(p)
|
||
toast.success('路径已复制', { description: pathDisplay.value })
|
||
} catch {
|
||
toast.error('复制失败')
|
||
}
|
||
}
|
||
|
||
const openFolder = async () => {
|
||
const p = store.kernel?.path
|
||
if (!p) return
|
||
try {
|
||
await revealItemInDir(p)
|
||
} catch (e) {
|
||
toast.error('打开文件夹失败', { description: String(e) })
|
||
}
|
||
}
|
||
|
||
// 延迟 Badge 样式(超时用醒目红色)
|
||
const delayBadgeClass = (delay: number | undefined) => {
|
||
if (delay === undefined) return 'border-transparent bg-muted text-muted-foreground'
|
||
if (delay === 0) return 'border-transparent bg-red-500 text-white'
|
||
if (delay < 150) return 'border-transparent bg-emerald-100 text-emerald-700'
|
||
if (delay < 400) return 'border-transparent bg-amber-100 text-amber-700'
|
||
return 'border-transparent bg-orange-100 text-orange-700'
|
||
}
|
||
|
||
const delayText = (delay: number | undefined) => {
|
||
if (delay === undefined) return '—'
|
||
if (delay === 0) return '超时'
|
||
return `${delay}ms`
|
||
}
|
||
|
||
// ===== 生命周期 =====
|
||
const loadProxiesWithError = async () => {
|
||
loadingProxies.value = true
|
||
try {
|
||
await store.loadProxies()
|
||
// 默认展开第一个代理组
|
||
if (groups.value.length && !accordionValue.value) {
|
||
accordionValue.value = groups.value[0][0]
|
||
}
|
||
} catch (e) {
|
||
logger.error('加载节点列表失败: ' + e)
|
||
toast.error('加载节点失败', { description: String(e) })
|
||
} finally {
|
||
loadingProxies.value = false
|
||
}
|
||
}
|
||
|
||
const init = async () => {
|
||
try {
|
||
// 获取 appData 路径,用于将内核路径替换为 %APPDATA% 形式
|
||
try {
|
||
appDataPath.value = await appDataDir()
|
||
} catch {
|
||
/* 忽略 */
|
||
}
|
||
try {
|
||
await Promise.all([store.loadSettings(), store.refreshKernel(), store.refreshStatus()])
|
||
} catch (e) {
|
||
logger.error('代理初始化失败: ' + e)
|
||
toast.error('代理模块初始化失败', { description: String(e) })
|
||
}
|
||
// 同步持久化的自动切换设置
|
||
syncAutoSwitchSettings()
|
||
if (running.value) {
|
||
await store.waitForApi()
|
||
store.refreshVersion()
|
||
loadProxiesWithError()
|
||
// 若自动切换已开启,恢复定时器
|
||
if (autoSwitchEnabled.value) {
|
||
startAutoSwitch()
|
||
}
|
||
}
|
||
} finally {
|
||
// 无论初始化链路是否出错,都结束"加载中"占位,避免模块永久卡死
|
||
store.initialized = true
|
||
}
|
||
}
|
||
|
||
onMounted(() => {
|
||
init()
|
||
statusTimer = setInterval(async () => {
|
||
// 窗口/标签页不可见时暂停状态轮询,恢复可见后下个 tick 自动继续
|
||
if (document.hidden) return
|
||
await store.refreshStatus()
|
||
}, 3000)
|
||
})
|
||
|
||
onUnmounted(() => {
|
||
if (statusTimer) clearInterval(statusTimer)
|
||
stopAutoSwitch()
|
||
})
|
||
|
||
watch(running, async (val, old) => {
|
||
if (val && !old) {
|
||
await store.waitForApi()
|
||
await store.refreshVersion()
|
||
await loadProxiesWithError()
|
||
// 自动切换若已开启,mihomo 启动/重启后恢复定时器
|
||
// (handleStop 会停掉旧定时器,此处统一接管启动路径,避免开关显示开但功能静默失效)
|
||
if (autoSwitchEnabled.value) {
|
||
startAutoSwitch()
|
||
}
|
||
}
|
||
})
|
||
|
||
// 切换到节点 Tab 时,加载节点列表并自动测速(10s 节流:快速切换 Tab 时避免重复 IPC 洪峰)
|
||
let lastAutoTestAt = 0
|
||
watch(activeTab, async (tab) => {
|
||
if (tab === 'proxies' && running.value) {
|
||
if (!Object.keys(store.proxies).length) {
|
||
await loadProxiesWithError()
|
||
}
|
||
const now = Date.now()
|
||
if (now - lastAutoTestAt > 10000) {
|
||
lastAutoTestAt = now
|
||
// 自动对所有组测速一次
|
||
autoTestAllGroups()
|
||
}
|
||
}
|
||
})
|
||
|
||
// ===== 进程控制 =====
|
||
const handleStart = async () => {
|
||
starting.value = true
|
||
try {
|
||
await store.start()
|
||
const ok = await store.waitForApi()
|
||
if (!ok) {
|
||
toast.error('mihomo 启动超时,API 无响应')
|
||
return
|
||
}
|
||
toast.success('mihomo 已启动')
|
||
await store.refreshVersion()
|
||
await loadProxiesWithError()
|
||
} catch (e) {
|
||
toast.error('启动失败', { description: String(e) })
|
||
} finally {
|
||
starting.value = false
|
||
}
|
||
}
|
||
|
||
const handleStop = async () => {
|
||
stopping.value = true
|
||
try {
|
||
stopAutoSwitch()
|
||
await store.stop()
|
||
toast.success('mihomo 已停止')
|
||
} catch (e) {
|
||
toast.error('停止失败', { description: String(e) })
|
||
} finally {
|
||
stopping.value = false
|
||
}
|
||
}
|
||
|
||
const handleRestart = async () => {
|
||
restarting.value = true
|
||
try {
|
||
await store.restart()
|
||
const ok = await store.waitForApi()
|
||
if (!ok) {
|
||
toast.error('mihomo 重启超时,API 无响应')
|
||
return
|
||
}
|
||
toast.success('mihomo 已重启')
|
||
await store.refreshVersion()
|
||
await loadProxiesWithError()
|
||
} catch (e) {
|
||
toast.error('重启失败', { description: String(e) })
|
||
} finally {
|
||
restarting.value = false
|
||
}
|
||
}
|
||
|
||
// ===== 系统代理 =====
|
||
const onToggleSystemProxy = async (on: boolean) => {
|
||
sysProxyLoading.value = true
|
||
try {
|
||
await store.toggleSystemProxy(on)
|
||
toast.success(on ? '系统代理已开启' : '系统代理已关闭')
|
||
} catch (e) {
|
||
toast.error('操作失败', { description: String(e) })
|
||
} finally {
|
||
sysProxyLoading.value = false
|
||
}
|
||
}
|
||
|
||
// ===== 模式切换 =====
|
||
const invokePatchConfigs = (body: Record<string, unknown>) =>
|
||
invoke('proxy_patch_configs', { body })
|
||
|
||
const changeMode = async (mode: string) => {
|
||
if (!store.settings || store.settings.mode === mode) return
|
||
const prev = store.settings.mode
|
||
store.settings.mode = mode
|
||
try {
|
||
await store.saveSettings({ ...store.settings })
|
||
if (running.value) {
|
||
await invokePatchConfigs({ mode })
|
||
}
|
||
toast.success(`已切换为${modeOptions.find(m => m.value === mode)?.label}模式`)
|
||
} catch (e) {
|
||
if (store.settings) store.settings.mode = prev
|
||
toast.error('模式切换失败', { description: String(e) })
|
||
}
|
||
}
|
||
|
||
// ===== 快捷切换节点 =====
|
||
const quickSwitchNode = async (name: string) => {
|
||
if (!mainGroupName.value) return
|
||
try {
|
||
await store.selectProxy(mainGroupName.value, name)
|
||
toast.success('节点已切换', { description: name })
|
||
// 测速新节点
|
||
store.testDelay(name).then(delay => {
|
||
toast.success(`${name}`, { description: `延迟 ${delay}ms` })
|
||
}).catch(() => {})
|
||
} catch (e) {
|
||
toast.error('切换节点失败', { description: String(e) })
|
||
}
|
||
}
|
||
|
||
// ===== 自动切换节点 =====
|
||
const startAutoSwitch = () => {
|
||
stopAutoSwitch()
|
||
if (!autoSwitchEnabled.value) return
|
||
const ms = autoSwitchInterval.value * 60 * 1000
|
||
autoSwitchTimer = setInterval(runAutoSwitch, ms)
|
||
toast.success('自动切换已开启', {
|
||
description: `每 ${autoSwitchInterval.value} 分钟测试并切换至最优节点`
|
||
})
|
||
// 立即执行一次
|
||
runAutoSwitch()
|
||
}
|
||
|
||
const stopAutoSwitch = () => {
|
||
if (autoSwitchTimer) {
|
||
clearInterval(autoSwitchTimer)
|
||
autoSwitchTimer = null
|
||
}
|
||
}
|
||
|
||
const onToggleAutoSwitch = (on: boolean) => {
|
||
autoSwitchEnabled.value = on
|
||
if (on) {
|
||
startAutoSwitch()
|
||
} else {
|
||
stopAutoSwitch()
|
||
toast.info('自动切换已关闭')
|
||
}
|
||
saveAutoSwitchSettings()
|
||
}
|
||
|
||
const onAutoSwitchIntervalChange = (val: unknown) => {
|
||
autoSwitchInterval.value = Number(val) || 5
|
||
if (autoSwitchEnabled.value) {
|
||
startAutoSwitch()
|
||
}
|
||
saveAutoSwitchSettings()
|
||
}
|
||
|
||
const runAutoSwitch = async () => {
|
||
if (autoSwitchRunning) return
|
||
autoSwitchRunning = true
|
||
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
|
||
}
|
||
}
|
||
|
||
// ===== 内核更新 =====
|
||
const handleCheckUpdate = async () => {
|
||
checkingUpdate.value = true
|
||
try {
|
||
const info = await store.checkKernelUpdate()
|
||
kernelUpdateInfo.value = { latestVersion: info.latestVersion, hasUpdate: info.hasUpdate }
|
||
if (info.hasUpdate) {
|
||
toast.info('发现新版本', { description: `最新: ${info.latestVersion}` })
|
||
} else {
|
||
toast.success('已是最新版本', { description: info.latestVersion })
|
||
}
|
||
} catch (e) {
|
||
toast.error('检查更新失败', { description: String(e) })
|
||
} finally {
|
||
checkingUpdate.value = false
|
||
}
|
||
}
|
||
|
||
const handleUpdateKernel = async () => {
|
||
// 展开更新区块(下载源选择 + 进度),由 handleStartUpdate 执行实际更新
|
||
updateExpanded.value = true
|
||
}
|
||
|
||
/** 是否展开"更新内核"区块(下载源 + 进度) */
|
||
const updateExpanded = ref(false)
|
||
|
||
/** 开始更新:停止 mihomo → 调用 updateKernel(复用 installProgress 进度机制) */
|
||
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
|
||
try {
|
||
await store.stop()
|
||
} catch (e) {
|
||
toast.error('停止 mihomo 失败', { description: String(e) })
|
||
updatingKernel.value = false
|
||
return
|
||
} finally {
|
||
updatingKernel.value = false
|
||
}
|
||
}
|
||
toast.info('开始下载更新...')
|
||
try {
|
||
await store.updateKernel(selectedMirrorPrefix.value)
|
||
} catch (e) {
|
||
toast.error('内核更新失败', { description: String(e) })
|
||
}
|
||
}
|
||
|
||
// ===== 首次安装内核 =====
|
||
const installStageText = computed(() => {
|
||
const stage = store.installProgress?.stage
|
||
switch (stage) {
|
||
case 'downloading': return '正在下载'
|
||
case 'extracting': return '正在解压'
|
||
case 'replacing': return '正在安装'
|
||
case 'done': return '安装完成'
|
||
case 'error': return '安装失败'
|
||
default: return ''
|
||
}
|
||
})
|
||
|
||
const installStageColor = computed(() => {
|
||
const stage = store.installProgress?.stage
|
||
if (stage === 'done') return 'text-emerald-500'
|
||
if (stage === 'error') return 'text-destructive'
|
||
return 'text-primary'
|
||
})
|
||
|
||
/** 进度条显示百分比:有 totalBytes 时用 percent,否则显示已下载 MB 而不显示百分比 */
|
||
const installPercentDisplay = computed(() => {
|
||
const p = store.installProgress
|
||
if (!p) return 0
|
||
// downloading 阶段用 percent(后端按 0-90 计算)
|
||
// extracting=92 / replacing=96 / done=100 / error=0
|
||
if (p.stage === 'downloading' && !p.totalBytes) {
|
||
// 无总长度时,前端不可知百分比,进度条用 indeterminate 动画
|
||
return 0
|
||
}
|
||
return p.percent
|
||
})
|
||
|
||
const installHasTotal = computed(() => store.installProgress?.totalBytes != null)
|
||
|
||
const formatMB = (bytes: number) => `${(bytes / 1024 / 1024).toFixed(2)} MB`
|
||
|
||
// ===== 首次安装内核 =====
|
||
// 镜像源选择:'__direct' = GitHub 直连(value 不能用空串,reka-ui SelectItem 禁止空 value)
|
||
// | 'ghproxy.net' 等预设 key | '__custom' = 自定义
|
||
const MIRROR_PRESETS = [
|
||
{ label: 'GitHub 直连', value: '__direct', hint: '能访问 GitHub 时选择,最稳定' },
|
||
{ label: 'gh-proxy.com', value: 'https://gh-proxy.com/', hint: '最推荐公益镜像' },
|
||
{ label: 'ghproxy.net', value: 'https://ghproxy.net/', hint: '老牌公益镜像' },
|
||
{ label: 'ghfast.top', value: 'https://ghfast.top/', hint: '较新镜像,备用' },
|
||
{ label: '自定义', value: '__custom', hint: '手动输入镜像站前缀' },
|
||
] as const
|
||
|
||
const mirrorChoice = ref<string>('__direct') // 默认直连
|
||
const customMirror = ref<string>('')
|
||
|
||
const selectedMirrorPrefix = computed(() => {
|
||
if (mirrorChoice.value === '__custom') {
|
||
// 自定义:保证以 / 结尾,避免拼接错误
|
||
const v = customMirror.value.trim()
|
||
if (!v) return ''
|
||
return v.endsWith('/') ? v : v + '/'
|
||
}
|
||
// __direct 映射回空串(后端用空串表示直连)
|
||
if (mirrorChoice.value === '__direct') return ''
|
||
return mirrorChoice.value
|
||
})
|
||
|
||
const handleInstallKernel = async () => {
|
||
if (store.installing) return
|
||
try {
|
||
await store.installKernel(selectedMirrorPrefix.value)
|
||
} catch {
|
||
// 忽略:watch 已处理 UI 反馈
|
||
}
|
||
}
|
||
|
||
// 监听安装/更新进度终态,弹 toast 并延时清空进度
|
||
// 同时处理更新场景下的 updateExpanded 清理(与 installProgress 同步清除,避免更新区块闪烁)
|
||
watch(
|
||
() => store.installProgress?.stage,
|
||
(stage) => {
|
||
if (stage === 'done') {
|
||
toast.success('内核安装完成', {
|
||
description: store.installProgress?.message
|
||
})
|
||
if (updateExpanded.value) {
|
||
kernelUpdateInfo.value = null
|
||
}
|
||
// 2 秒后同时清空进度和更新区块,让用户看到 100% 终态
|
||
setTimeout(() => {
|
||
store.clearInstallProgress()
|
||
updateExpanded.value = false
|
||
}, 2000)
|
||
} else if (stage === 'error') {
|
||
toast.error('内核安装失败', {
|
||
description: store.installProgress?.message
|
||
})
|
||
setTimeout(() => {
|
||
store.clearInstallProgress()
|
||
// 错误时不清除 updateExpanded,让用户可以重新选择下载源重试
|
||
}, 5000)
|
||
}
|
||
}
|
||
)
|
||
|
||
// ===== 节点 =====
|
||
const refreshProxies = async () => {
|
||
await loadProxiesWithError()
|
||
toast.success('节点列表已刷新')
|
||
}
|
||
|
||
const selectNode = async (group: string, name: string) => {
|
||
if (store.proxies[group]?.type !== 'Selector') return
|
||
try {
|
||
await store.selectProxy(group, name)
|
||
toast.success('节点已切换', { description: name })
|
||
} catch (e) {
|
||
toast.error('切换节点失败', { description: String(e) })
|
||
}
|
||
}
|
||
|
||
const testGroup = async (groupName: string) => {
|
||
const group = store.proxies[groupName]
|
||
if (!group?.all?.length) return
|
||
testingGroups.value.add(groupName)
|
||
toast.info(`正在测试「${groupName}」延迟...`)
|
||
try {
|
||
await store.testDelayBatch(group.all)
|
||
toast.success(`「${groupName}」测速完成`)
|
||
} catch (e) {
|
||
toast.error('测速失败', { description: String(e) })
|
||
} finally {
|
||
testingGroups.value.delete(groupName)
|
||
}
|
||
}
|
||
|
||
const nodeDelay = (name: string): number | undefined => {
|
||
return store.proxies[name]?.history?.[0]?.delay
|
||
}
|
||
|
||
/** 自动测试所有代理组的延迟(不弹 toast,静默执行) */
|
||
const autoTestAllGroups = async () => {
|
||
if (!groups.value.length) return
|
||
// 收集所有组的有效节点(过滤伪节点)
|
||
const allNodes = new Set<string>()
|
||
for (const [, group] of groups.value) {
|
||
for (const node of group.all ?? []) {
|
||
if (!isPseudoNode(node)) allNodes.add(node)
|
||
}
|
||
}
|
||
if (!allNodes.size) return
|
||
try {
|
||
await store.testDelayBatch(Array.from(allNodes))
|
||
} catch {
|
||
// 静默失败
|
||
}
|
||
}
|
||
|
||
// ===== 订阅 =====
|
||
// 订阅变更后重载 mihomo 配置(重新生成 config.yaml 并重启进程)
|
||
const reloadAfterProfileChange = async (label: string) => {
|
||
if (!running.value) return
|
||
try {
|
||
await store.restart()
|
||
// 等待 mihomo API 就绪(mihomo 启动后需要时间初始化配置和 geo 文件)
|
||
const ok = await store.waitForApi()
|
||
if (!ok) {
|
||
toast.error('mihomo 启动超时,API 无响应')
|
||
return
|
||
}
|
||
await store.refreshVersion()
|
||
await loadProxiesWithError()
|
||
toast.success(label)
|
||
} catch (e) {
|
||
toast.error('重载配置失败', { description: String(e) })
|
||
}
|
||
}
|
||
|
||
const doImport = async () => {
|
||
if (!importUrl.value.trim()) {
|
||
toast.warning('请输入订阅地址')
|
||
return
|
||
}
|
||
importing.value = true
|
||
try {
|
||
const name = importName.value.trim() || `订阅 ${new Date().toLocaleString()}`
|
||
await store.importProfile(importUrl.value.trim(), name)
|
||
importUrl.value = ''
|
||
importName.value = ''
|
||
await reloadAfterProfileChange('订阅导入成功,已重载配置')
|
||
} catch (e) {
|
||
toast.error('导入失败', { description: String(e) })
|
||
} finally {
|
||
importing.value = false
|
||
}
|
||
}
|
||
|
||
const doUpdate = async (id: string) => {
|
||
toast.info('正在更新订阅...')
|
||
try {
|
||
await store.updateProfile(id)
|
||
await reloadAfterProfileChange('订阅已更新,已重载配置')
|
||
} catch (e) {
|
||
toast.error('更新失败', { description: String(e) })
|
||
}
|
||
}
|
||
|
||
const doDelete = async (id: string, name: string) => {
|
||
const ok = await showConfirm({
|
||
title: '删除订阅',
|
||
description: `确定删除订阅「${name}」?此操作不可撤销。`,
|
||
confirmText: '删除',
|
||
destructive: true
|
||
})
|
||
if (!ok) return
|
||
try {
|
||
await store.deleteProfile(id)
|
||
await reloadAfterProfileChange('已删除订阅,已重载配置')
|
||
} catch (e) {
|
||
toast.error('删除失败', { description: String(e) })
|
||
}
|
||
}
|
||
|
||
const doActivate = async (id: string) => {
|
||
try {
|
||
await store.activateProfile(id)
|
||
toast.success('已切换订阅,配置已重新生成')
|
||
if (running.value) {
|
||
await handleRestart()
|
||
}
|
||
} catch (e) {
|
||
toast.error('切换失败', { description: String(e) })
|
||
}
|
||
}
|
||
|
||
// ===== 设置 =====
|
||
const localSettings = ref({
|
||
mixedPort: 7890,
|
||
externalController: '127.0.0.1:9090',
|
||
secret: '',
|
||
mode: 'rule',
|
||
logLevel: 'info',
|
||
allowLan: false,
|
||
autoStart: false,
|
||
autoSystemProxy: false
|
||
})
|
||
|
||
const syncLocalSettings = () => {
|
||
if (store.settings) {
|
||
localSettings.value = {
|
||
mixedPort: store.settings.mixedPort,
|
||
externalController: store.settings.externalController,
|
||
secret: store.settings.secret,
|
||
mode: store.settings.mode,
|
||
logLevel: store.settings.logLevel,
|
||
allowLan: store.settings.allowLan,
|
||
autoStart: store.settings.autoStart,
|
||
autoSystemProxy: store.settings.autoSystemProxy
|
||
}
|
||
}
|
||
}
|
||
|
||
watch(() => store.settings, syncLocalSettings, { immediate: true })
|
||
|
||
const saveSettingsForm = async () => {
|
||
if (!store.settings) return
|
||
try {
|
||
await store.saveSettings({
|
||
...store.settings,
|
||
...localSettings.value
|
||
})
|
||
toast.success('设置已保存')
|
||
} catch (e) {
|
||
toast.error('保存失败', { description: String(e) })
|
||
}
|
||
}
|
||
|
||
// 注册保存处理函数到标签栏 store(TitleBar 保存按钮调用)
|
||
tabsStore.registerSave(saveSettingsForm)
|
||
</script>
|
||
|
||
<template>
|
||
<div class="h-full p-6">
|
||
<Tabs v-model="activeTab" class="h-full flex flex-col">
|
||
<div ref="tabsListRef">
|
||
<TabsList class="grid w-full grid-cols-4 max-w-md !bg-transparent !p-0 !shadow-none">
|
||
<TabsTrigger value="overview" class="gap-1.5"><Globe class="size-3.5" />概览</TabsTrigger>
|
||
<TabsTrigger value="proxies" class="gap-1.5"><Server class="size-3.5" />节点</TabsTrigger>
|
||
<TabsTrigger value="profiles" class="gap-1.5"><ListChecks class="size-3.5" />订阅</TabsTrigger>
|
||
<TabsTrigger value="settings" class="gap-1.5"><SettingsIcon class="size-3.5" />设置</TabsTrigger>
|
||
</TabsList>
|
||
</div>
|
||
|
||
<!-- 概览 -->
|
||
<TabsContent value="overview" class="flex-1 mt-4 tab-animate">
|
||
<ScrollArea class="h-full pr-3">
|
||
<div class="columns-1 md:columns-2 gap-4 [&>*]:mb-4 [&>*]:break-inside-avoid">
|
||
<!-- 内核状态 -->
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle class="flex items-center justify-between text-base">
|
||
<span class="flex items-center gap-2"><Server class="size-4 text-primary" />内核</span>
|
||
<Button
|
||
v-if="store.kernel?.exists"
|
||
size="xs" variant="outline"
|
||
:disabled="checkingUpdate"
|
||
@click="handleCheckUpdate"
|
||
>
|
||
<Loader2 v-if="checkingUpdate" class="size-3 animate-spin" />
|
||
<Download v-else key="icon-download" class="size-3" />检查更新
|
||
</Button>
|
||
</CardTitle>
|
||
</CardHeader>
|
||
<CardContent class="space-y-3 text-sm">
|
||
<div class="flex items-center justify-between">
|
||
<span class="text-muted-foreground">状态</span>
|
||
<template v-if="!store.initialized">
|
||
<span class="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||
<Loader2 class="size-3 animate-spin" />加载中...
|
||
</span>
|
||
</template>
|
||
<template v-else>
|
||
<Badge v-if="store.kernel?.exists" variant="default" class="gap-1 bg-emerald-500 hover:bg-emerald-500">
|
||
<Check class="size-3" />已安装
|
||
</Badge>
|
||
<Badge v-else variant="destructive" class="gap-1">
|
||
<AlertCircle class="size-3" />未安装
|
||
</Badge>
|
||
</template>
|
||
</div>
|
||
<div v-if="store.kernel?.exists" class="flex items-center justify-between">
|
||
<span class="text-muted-foreground">当前版本</span>
|
||
<Tooltip>
|
||
<TooltipTrigger as-child>
|
||
<Badge variant="secondary" class="font-mono text-xs cursor-default">
|
||
{{ versionShort }}
|
||
</Badge>
|
||
</TooltipTrigger>
|
||
<TooltipContent>{{ store.kernel?.version ?? '' }}</TooltipContent>
|
||
</Tooltip>
|
||
</div>
|
||
<div v-if="kernelUpdateInfo" class="flex items-center justify-between">
|
||
<span class="text-muted-foreground">最新版本</span>
|
||
<span class="font-mono text-xs flex items-center gap-2">
|
||
{{ kernelUpdateInfo.latestVersion }}
|
||
<Button
|
||
v-if="kernelUpdateInfo.hasUpdate"
|
||
key="btn-update"
|
||
size="xs" variant="default"
|
||
:disabled="store.installing || updateExpanded"
|
||
@click="handleUpdateKernel"
|
||
>
|
||
<Download key="icon-download" class="size-3" />更新
|
||
</Button>
|
||
<Check v-else key="icon-updated" class="size-3 text-emerald-500" />
|
||
</span>
|
||
</div>
|
||
<div class="flex items-center justify-between gap-3">
|
||
<span class="text-muted-foreground shrink-0">路径</span>
|
||
<div class="flex items-center gap-1.5 min-w-0">
|
||
<Tooltip>
|
||
<TooltipTrigger as-child>
|
||
<span
|
||
v-if="store.kernel?.exists"
|
||
class="font-mono text-xs text-right truncate cursor-default"
|
||
>{{ pathDisplay || pathShort }}</span>
|
||
<span v-else class="font-mono text-xs text-right cursor-default">{{ pathShort }}</span>
|
||
</TooltipTrigger>
|
||
<TooltipContent class="max-w-[400px] break-all">{{ store.kernel?.path ?? '' }}</TooltipContent>
|
||
</Tooltip>
|
||
<template v-if="store.kernel?.exists">
|
||
<Tooltip>
|
||
<TooltipTrigger as-child>
|
||
<Button size="icon-xs" variant="ghost" @click="copyPath">
|
||
<Copy class="size-3" />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent>复制路径</TooltipContent>
|
||
</Tooltip>
|
||
<Tooltip>
|
||
<TooltipTrigger as-child>
|
||
<Button size="icon-xs" variant="ghost" @click="openFolder">
|
||
<FolderOpen class="size-3" />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent>在文件夹中显示</TooltipContent>
|
||
</Tooltip>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 更新内核区块:检查到更新且用户点击"更新"后展开(复用首次安装的下载源选择 UI) -->
|
||
<div
|
||
v-if="updateExpanded && !store.installProgress"
|
||
class="space-y-2 pt-2 border-t"
|
||
>
|
||
<div class="space-y-1.5">
|
||
<Label class="text-xs text-muted-foreground">下载源</Label>
|
||
<Select v-model="mirrorChoice">
|
||
<SelectTrigger size="sm" class="w-full">
|
||
<SelectValue placeholder="选择下载源" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem
|
||
v-for="m in MIRROR_PRESETS"
|
||
:key="m.value"
|
||
:value="m.value"
|
||
>
|
||
{{ m.label }}
|
||
</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
<p class="text-xs text-muted-foreground">
|
||
{{ MIRROR_PRESETS.find(m => m.value === mirrorChoice)?.hint }}
|
||
</p>
|
||
</div>
|
||
<div v-if="mirrorChoice === '__custom'" class="space-y-1.5">
|
||
<Label class="text-xs text-muted-foreground">镜像站前缀</Label>
|
||
<Input
|
||
v-model="customMirror"
|
||
placeholder="如 https://ghproxy.net/"
|
||
class="h-8 text-xs"
|
||
/>
|
||
<p class="text-xs text-muted-foreground">
|
||
前缀会拼接到 GitHub 下载链接前,需以 / 结尾(自动补全)
|
||
</p>
|
||
</div>
|
||
<div class="flex gap-2">
|
||
<Button
|
||
size="sm" variant="outline" class="flex-1"
|
||
:disabled="store.installing || updatingKernel"
|
||
@click="updateExpanded = false"
|
||
>
|
||
取消
|
||
</Button>
|
||
<Button
|
||
size="sm" variant="default" class="flex-1"
|
||
:disabled="store.installing || updatingKernel || (mirrorChoice === '__custom' && !customMirror.trim())"
|
||
@click="handleStartUpdate"
|
||
>
|
||
<Loader2 v-if="store.installing || updatingKernel" class="size-4 animate-spin" />
|
||
<DownloadCloud v-else class="size-4" />开始更新
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 首次安装区块:仅在内核未安装且不在安装中且已初始化时显示 -->
|
||
<div
|
||
v-if="store.initialized && !store.kernel?.exists && !store.installProgress"
|
||
class="space-y-2 pt-2 border-t"
|
||
>
|
||
<div class="space-y-1.5">
|
||
<Label class="text-xs text-muted-foreground">下载源</Label>
|
||
<Select v-model="mirrorChoice">
|
||
<SelectTrigger size="sm" class="w-full">
|
||
<SelectValue placeholder="选择下载源" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem
|
||
v-for="m in MIRROR_PRESETS"
|
||
:key="m.value"
|
||
:value="m.value"
|
||
>
|
||
{{ m.label }}
|
||
</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
<p class="text-xs text-muted-foreground">
|
||
{{ MIRROR_PRESETS.find(m => m.value === mirrorChoice)?.hint }}
|
||
</p>
|
||
</div>
|
||
<div v-if="mirrorChoice === '__custom'" class="space-y-1.5">
|
||
<Label class="text-xs text-muted-foreground">镜像站前缀</Label>
|
||
<Input
|
||
v-model="customMirror"
|
||
placeholder="如 https://ghproxy.net/"
|
||
class="h-8 text-xs"
|
||
/>
|
||
<p class="text-xs text-muted-foreground">
|
||
前缀会拼接到 GitHub 下载链接前,需以 / 结尾(自动补全)
|
||
</p>
|
||
</div>
|
||
<Button
|
||
size="sm" variant="default" class="w-full"
|
||
:disabled="store.installing || (mirrorChoice === '__custom' && !customMirror.trim())"
|
||
@click="handleInstallKernel"
|
||
>
|
||
<DownloadCloud class="size-4" />安装内核
|
||
</Button>
|
||
</div>
|
||
|
||
<!-- 安装进度区块 -->
|
||
<div
|
||
v-if="store.installProgress"
|
||
class="rounded-md border p-3 space-y-2 bg-muted/30"
|
||
>
|
||
<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)"
|
||
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" />
|
||
{{ installStageText }}
|
||
</span>
|
||
<span v-if="installHasTotal && store.installProgress.stage === 'downloading'" class="font-mono text-muted-foreground">
|
||
{{ store.installProgress.percent }}%
|
||
</span>
|
||
</div>
|
||
<Progress
|
||
v-if="installHasTotal || store.installProgress.stage !== 'downloading'"
|
||
key="progress-bar"
|
||
:model-value="installPercentDisplay"
|
||
class="h-2"
|
||
/>
|
||
<div
|
||
v-else
|
||
key="progress-indeterminate"
|
||
class="h-2 w-full overflow-hidden rounded-full bg-primary/20 relative"
|
||
>
|
||
<div class="absolute inset-y-0 left-0 w-1/3 bg-primary rounded-full animate-[indeterminate_1.2s_ease-in-out_infinite]" />
|
||
</div>
|
||
<p class="text-xs text-muted-foreground">
|
||
<template v-if="store.installProgress.stage === 'downloading'">
|
||
{{ store.installProgress.message }}
|
||
<span v-if="installHasTotal" class="ml-1">
|
||
({{ formatMB(store.installProgress.downloadedBytes) }} / {{ formatMB(store.installProgress.totalBytes!) }})
|
||
</span>
|
||
<span v-else class="ml-1">{{ formatMB(store.installProgress.downloadedBytes) }}</span>
|
||
</template>
|
||
<template v-else>{{ store.installProgress.message }}</template>
|
||
</p>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<!-- 运行状态 -->
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle class="flex items-center gap-2 text-base">
|
||
<Zap class="size-4 text-primary" />运行状态
|
||
</CardTitle>
|
||
</CardHeader>
|
||
<CardContent class="space-y-3 text-sm">
|
||
<div class="flex items-center justify-between">
|
||
<span class="text-muted-foreground">mihomo</span>
|
||
<template v-if="!store.initialized">
|
||
<span class="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||
<Loader2 class="size-3 animate-spin" />加载中...
|
||
</span>
|
||
</template>
|
||
<template v-else>
|
||
<span v-if="running" class="flex items-center gap-1 text-emerald-500">
|
||
<span class="size-2 rounded-full bg-emerald-500" />运行中
|
||
</span>
|
||
<span v-else class="flex items-center gap-1 text-muted-foreground">
|
||
<span class="size-2 rounded-full bg-muted-foreground" />已停止
|
||
</span>
|
||
</template>
|
||
</div>
|
||
<div class="flex items-center justify-between">
|
||
<span class="text-muted-foreground">PID</span>
|
||
<span class="font-mono text-xs">{{ store.status.pid ?? '—' }}</span>
|
||
</div>
|
||
<div class="flex items-center justify-between">
|
||
<span class="text-muted-foreground">API 版本</span>
|
||
<span class="font-mono text-xs">{{ store.version || '—' }}</span>
|
||
</div>
|
||
<div class="flex items-center justify-between">
|
||
<span class="text-muted-foreground">重启次数</span>
|
||
<span class="font-mono text-xs">{{ store.status.restartCount }}</span>
|
||
</div>
|
||
<Separator />
|
||
<div class="flex gap-2">
|
||
<Button v-if="!running" key="btn-start" size="sm" :disabled="starting" @click="handleStart">
|
||
<Loader2 v-if="starting" key="starting-loading" class="size-3.5 animate-spin" />
|
||
<Play v-else key="starting-icon" class="size-3.5" />启动
|
||
</Button>
|
||
<template v-else key="btn-stop-group">
|
||
<Button size="sm" variant="destructive" :disabled="stopping" @click="handleStop">
|
||
<Loader2 v-if="stopping" key="stopping-loading" class="size-3.5 animate-spin" />
|
||
<Square v-else key="stopping-icon" class="size-3.5" />停止
|
||
</Button>
|
||
<Button size="sm" variant="outline" :disabled="restarting" @click="handleRestart">
|
||
<Loader2 v-if="restarting" key="restarting-loading" class="size-3.5 animate-spin" />
|
||
<RotateCw v-else key="restarting-icon" class="size-3.5" />重启
|
||
</Button>
|
||
</template>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<!-- 当前订阅 & 模式 -->
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle class="flex items-center gap-2 text-base">
|
||
<ListChecks class="size-4 text-primary" />订阅与模式
|
||
</CardTitle>
|
||
</CardHeader>
|
||
<CardContent class="space-y-3 text-sm">
|
||
<div class="flex items-center justify-between gap-3">
|
||
<span class="text-muted-foreground shrink-0">当前订阅</span>
|
||
<span class="text-right truncate">{{ currentProfile?.name ?? '无' }}</span>
|
||
</div>
|
||
<Separator />
|
||
<div class="flex items-center justify-between">
|
||
<span class="text-muted-foreground">运行模式</span>
|
||
<div class="flex gap-1">
|
||
<Button
|
||
v-for="m in modeOptions" :key="m.value"
|
||
size="xs"
|
||
:variant="store.settings?.mode === m.value ? 'default' : 'outline'"
|
||
:disabled="!running && store.settings?.mode !== m.value"
|
||
@click="changeMode(m.value)"
|
||
>{{ m.label }}</Button>
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<!-- 当前节点 -->
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle class="flex items-center gap-2 text-base">
|
||
<Target class="size-4 text-primary" />当前节点
|
||
</CardTitle>
|
||
</CardHeader>
|
||
<CardContent class="space-y-3 text-sm">
|
||
<template v-if="mainGroupName" key="has-main">
|
||
<div class="flex items-center justify-between">
|
||
<span class="text-muted-foreground">代理组</span>
|
||
<span class="font-medium">{{ mainGroupName }}</span>
|
||
</div>
|
||
<div class="flex items-center justify-between gap-3">
|
||
<span class="text-muted-foreground shrink-0">当前节点</span>
|
||
<span class="text-right truncate">{{ mainGroupNow || '—' }}</span>
|
||
</div>
|
||
<div class="flex items-center justify-between">
|
||
<span class="text-muted-foreground">延迟</span>
|
||
<Badge variant="outline" :class="delayBadgeClass(currentNodeDelay)">{{ delayText(currentNodeDelay) }}</Badge>
|
||
</div>
|
||
<Separator />
|
||
<div class="space-y-2">
|
||
<Label class="text-xs text-muted-foreground">快捷切换</Label>
|
||
<Select :model-value="mainGroupNow" @update:model-value="(v) => quickSwitchNode(String(v))">
|
||
<SelectTrigger class="w-full">
|
||
<SelectValue :placeholder="mainGroupNow || '选择节点'" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem v-for="node in mainGroupNodes" :key="node" :value="node">
|
||
{{ node }}
|
||
</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
</template>
|
||
<div v-else key="no-main" class="text-center text-muted-foreground py-4 text-xs">
|
||
{{ running ? '暂无可选节点,请先导入订阅' : 'mihomo 未运行' }}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<!-- 自动切换 -->
|
||
<Card :class="{ 'opacity-60': !running && autoSwitchEnabled }">
|
||
<CardHeader>
|
||
<CardTitle class="flex items-center justify-between text-base">
|
||
<span class="flex items-center gap-2">
|
||
<Timer class="size-4 text-primary" />自动切换节点
|
||
</span>
|
||
<Switch
|
||
:model-value="autoSwitchEnabled"
|
||
:disabled="!running"
|
||
@update:model-value="onToggleAutoSwitch"
|
||
/>
|
||
</CardTitle>
|
||
</CardHeader>
|
||
<CardContent class="space-y-3 text-sm" :class="{ 'pointer-events-none': !autoSwitchEnabled }">
|
||
<div class="grid grid-cols-3 gap-3">
|
||
<div class="grid gap-1.5">
|
||
<Label class="text-xs text-muted-foreground">目标代理组</Label>
|
||
<Select :model-value="autoSwitchTargetGroup || '__default__'" @update:model-value="(v) => { autoSwitchTargetGroup = v === '__default__' ? '' : String(v); saveAutoSwitchSettings() }">
|
||
<SelectTrigger class="w-full">
|
||
<SelectValue placeholder="主代理组" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="__default__">主代理组</SelectItem>
|
||
<SelectItem v-for="[gname] in selectorGroups" :key="gname" :value="gname">{{ gname }}</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<div class="grid gap-1.5">
|
||
<Label class="text-xs text-muted-foreground">测试间隔</Label>
|
||
<Select :model-value="autoSwitchInterval" @update:model-value="(v) => onAutoSwitchIntervalChange(v)">
|
||
<SelectTrigger class="w-full">
|
||
<SelectValue placeholder="选择间隔" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem :value="1">每 1 分钟</SelectItem>
|
||
<SelectItem :value="5">每 5 分钟</SelectItem>
|
||
<SelectItem :value="10">每 10 分钟</SelectItem>
|
||
<SelectItem :value="30">每 30 分钟</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<div class="grid gap-1.5">
|
||
<Label class="text-xs text-muted-foreground">地区筛选</Label>
|
||
<Select :model-value="autoSwitchRegion || '__all__'" @update:model-value="(v) => { autoSwitchRegion = v === '__all__' ? '' : String(v); saveAutoSwitchSettings() }">
|
||
<SelectTrigger class="w-full">
|
||
<SelectValue placeholder="全部地区" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="__all__">全部地区</SelectItem>
|
||
<SelectItem v-for="r in regionOptions" :key="r" :value="r">{{ r }}</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
</div>
|
||
<p class="text-xs text-muted-foreground leading-relaxed">
|
||
定时测试「{{ autoSwitchTargetGroup || mainGroupName || '主代理组' }}」{{ autoSwitchRegion ? `中「${autoSwitchRegion}」地区` : '全部' }}有效节点的延迟,
|
||
自动切换至最优节点。当前 {{ filteredNodes.length }} 个节点在候选范围内。
|
||
</p>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<!-- 系统代理 -->
|
||
<Card :class="{ 'opacity-60': sysProxyLoading }">
|
||
<CardHeader>
|
||
<CardTitle class="flex items-center gap-2 text-base">
|
||
<Power class="size-4 text-primary" />系统代理
|
||
</CardTitle>
|
||
</CardHeader>
|
||
<CardContent class="flex items-center justify-between">
|
||
<div class="space-y-1">
|
||
<p class="text-sm">Windows 系统代理</p>
|
||
<p class="text-xs text-muted-foreground">
|
||
{{ store.systemProxy ? `指向 127.0.0.1:${store.settings?.mixedPort ?? 7890}` : '已关闭' }}
|
||
</p>
|
||
</div>
|
||
<Switch
|
||
:model-value="store.systemProxy"
|
||
:disabled="sysProxyLoading"
|
||
@update:model-value="onToggleSystemProxy"
|
||
/>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
</ScrollArea>
|
||
</TabsContent>
|
||
|
||
<!-- 节点 -->
|
||
<TabsContent value="proxies" class="flex-1 mt-4 tab-animate">
|
||
<div v-if="!running" key="not-running" class="h-full flex flex-col items-center justify-center text-muted-foreground gap-2 pt-10 pr-10">
|
||
<Server class="size-12 opacity-30" />
|
||
<p class="text-sm">mihomo 未运行,请先在概览页启动</p>
|
||
</div>
|
||
<ScrollArea v-else key="proxy-list" class="h-full pr-3">
|
||
<div class="flex items-center justify-between mb-3">
|
||
<p v-if="!groups.length" class="text-sm text-muted-foreground">暂无代理组</p>
|
||
<p v-else class="text-sm text-muted-foreground">{{ groups.length }} 个代理组</p>
|
||
<Button size="xs" variant="outline" :disabled="loadingProxies" @click="refreshProxies">
|
||
<Loader2 v-if="loadingProxies" key="loading-proxies" class="size-3 animate-spin" />
|
||
<RefreshCw v-else key="refresh-icon" class="size-3" />刷新
|
||
</Button>
|
||
</div>
|
||
<div v-if="!groups.length" key="no-groups" class="text-center text-sm text-muted-foreground py-8">
|
||
未能加载代理组,请点击刷新重试
|
||
</div>
|
||
<Accordion
|
||
v-else
|
||
key="groups-list"
|
||
v-model="accordionValue"
|
||
type="single"
|
||
collapsible
|
||
class="w-full space-y-2 pb-4"
|
||
>
|
||
<Card v-for="[gname, group] in groups" :key="gname" class="overflow-hidden py-0">
|
||
<AccordionItem :value="gname" class="border-b-0">
|
||
<AccordionTrigger class="px-4 py-2.5 hover:no-underline">
|
||
<div class="flex items-center justify-between w-full pr-2">
|
||
<span class="flex items-center gap-2">
|
||
<Server class="size-4 text-primary" />
|
||
<span class="font-medium">{{ gname }}</span>
|
||
<span class="text-xs font-normal text-muted-foreground">{{ group.type }}</span>
|
||
<span v-if="group.now" class="text-xs text-primary truncate max-w-32">{{ group.now }}</span>
|
||
</span>
|
||
<div class="flex items-center gap-2" @click.stop>
|
||
<span class="text-xs text-muted-foreground">{{ group.all?.length ?? 0 }} 节点</span>
|
||
<Button
|
||
size="xs" variant="outline"
|
||
:disabled="testingGroups.has(gname)"
|
||
@click.stop="testGroup(gname)"
|
||
>
|
||
<Loader2 v-if="testingGroups.has(gname)" key="testing" class="size-3 animate-spin" />
|
||
<Zap v-else key="test-icon" class="size-3" />测速
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</AccordionTrigger>
|
||
<AccordionContent class="px-4 pb-3 pt-0">
|
||
<div class="grid grid-cols-2 md:grid-cols-3 gap-1.5">
|
||
<button
|
||
v-for="node in group.all" :key="node"
|
||
type="button"
|
||
class="flex items-center justify-between gap-2 rounded-md border px-2.5 py-1.5 text-xs transition-colors hover:bg-accent disabled:opacity-50 disabled:cursor-not-allowed"
|
||
:class="group.now === node ? 'border-primary bg-primary/10' : 'border-border'"
|
||
:disabled="group.type !== 'Selector'"
|
||
@click="selectNode(gname, node)"
|
||
>
|
||
<span class="truncate text-left">{{ store.proxies[node]?.name ?? node }}</span>
|
||
<Badge variant="outline" :class="delayBadgeClass(nodeDelay(node))">
|
||
{{ delayText(nodeDelay(node)) }}
|
||
</Badge>
|
||
</button>
|
||
</div>
|
||
</AccordionContent>
|
||
</AccordionItem>
|
||
</Card>
|
||
</Accordion>
|
||
</ScrollArea>
|
||
</TabsContent>
|
||
|
||
<!-- 订阅 -->
|
||
<TabsContent value="profiles" class="flex-1 mt-4 tab-animate">
|
||
<ScrollArea class="h-full pr-3">
|
||
<div class="space-y-4 max-w-3xl">
|
||
<Card>
|
||
<CardHeader class="pb-3">
|
||
<CardTitle class="flex items-center gap-2 text-base">
|
||
<Plus class="size-4 text-primary" />导入订阅
|
||
</CardTitle>
|
||
</CardHeader>
|
||
<CardContent class="space-y-3">
|
||
<div class="grid gap-2">
|
||
<Label for="sub-url">订阅地址</Label>
|
||
<Input id="sub-url" v-model="importUrl" placeholder="https://example.com/sub.yaml" />
|
||
</div>
|
||
<div class="grid gap-2">
|
||
<Label for="sub-name">名称(可选)</Label>
|
||
<Input id="sub-name" v-model="importName" placeholder="我的订阅" />
|
||
</div>
|
||
<Button size="sm" :disabled="importing" @click="doImport">
|
||
<Loader2 v-if="importing" key="importing" class="size-3.5 animate-spin" />
|
||
<Upload v-else key="upload-icon" class="size-3.5" />导入
|
||
</Button>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card>
|
||
<CardHeader class="pb-3">
|
||
<CardTitle class="text-base">订阅列表</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div v-if="!store.settings?.profiles.length" class="text-center text-sm text-muted-foreground py-8">
|
||
暂无订阅
|
||
</div>
|
||
<div v-else class="space-y-2">
|
||
<div
|
||
v-for="p in store.settings.profiles" :key="p.id"
|
||
class="flex items-center gap-3 rounded-md border p-3"
|
||
:class="store.settings.currentProfile === p.id ? 'border-primary bg-primary/5' : 'border-border'"
|
||
>
|
||
<div class="flex-1 min-w-0 space-y-1">
|
||
<div class="flex items-center gap-2">
|
||
<Link2 class="size-3.5 text-muted-foreground shrink-0" />
|
||
<span class="font-medium text-sm truncate">{{ p.name }}</span>
|
||
<span v-if="store.settings.currentProfile === p.id" class="text-xs text-primary">当前</span>
|
||
</div>
|
||
<p class="text-xs text-muted-foreground truncate">{{ p.url }}</p>
|
||
<p class="text-xs text-muted-foreground">
|
||
{{ formatSize(p.size ?? 0) }} · 更新于 {{ p.updatedAt }}
|
||
</p>
|
||
</div>
|
||
<div class="flex gap-1 shrink-0">
|
||
<Tooltip>
|
||
<TooltipTrigger as-child>
|
||
<Button size="icon-sm" variant="ghost" @click="doUpdate(p.id)">
|
||
<RefreshCw class="size-3.5" />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent>更新</TooltipContent>
|
||
</Tooltip>
|
||
<Tooltip v-if="store.settings.currentProfile !== p.id">
|
||
<TooltipTrigger as-child>
|
||
<Button
|
||
size="icon-sm" variant="ghost" @click="doActivate(p.id)"
|
||
>
|
||
<Check class="size-3.5" />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent>切换</TooltipContent>
|
||
</Tooltip>
|
||
<Tooltip>
|
||
<TooltipTrigger as-child>
|
||
<Button size="icon-sm" variant="ghost" @click="doDelete(p.id, p.name)">
|
||
<Trash2 class="size-3.5 text-destructive" />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent>删除</TooltipContent>
|
||
</Tooltip>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
</ScrollArea>
|
||
</TabsContent>
|
||
|
||
<!-- 设置 -->
|
||
<TabsContent value="settings" class="flex-1 mt-4 tab-animate">
|
||
<ScrollArea class="h-full pr-3">
|
||
<Card class="max-w-2xl">
|
||
<CardHeader>
|
||
<CardTitle class="flex items-center gap-2 text-base">
|
||
<SettingsIcon class="size-4 text-primary" />基础设置
|
||
</CardTitle>
|
||
</CardHeader>
|
||
<CardContent class="space-y-4">
|
||
<!-- 说明文字(保存按钮已移至顶部标签栏) -->
|
||
<p class="text-xs text-muted-foreground rounded-md border border-primary/30 bg-primary/5 p-3">
|
||
修改端口/接口/密钥/模式后需重启 mihomo 生效。DNS、规则等高级配置请直接编辑订阅文件。
|
||
</p>
|
||
<Separator />
|
||
<div class="grid grid-cols-2 gap-4">
|
||
<div class="grid gap-2">
|
||
<Label for="mixed-port">混合代理端口</Label>
|
||
<Input id="mixed-port" v-model.number="localSettings.mixedPort" type="number" />
|
||
</div>
|
||
<div class="grid gap-2">
|
||
<Label for="api-addr">控制接口地址</Label>
|
||
<Input id="api-addr" v-model="localSettings.externalController" placeholder="127.0.0.1:9090" />
|
||
</div>
|
||
</div>
|
||
<div class="grid gap-2">
|
||
<Label for="secret">API 密钥(留空则不鉴权)</Label>
|
||
<Input id="secret" v-model="localSettings.secret" placeholder="可选" />
|
||
</div>
|
||
<div class="flex items-center justify-between rounded-md border p-3">
|
||
<div>
|
||
<p class="text-sm">允许局域网连接</p>
|
||
<p class="text-xs text-muted-foreground">允许其他设备通过本机代理上网</p>
|
||
</div>
|
||
<Switch v-model="localSettings.allowLan" />
|
||
</div>
|
||
<div class="flex items-center justify-between rounded-md border p-3">
|
||
<div>
|
||
<p class="text-sm">应用启动时自动启动 mihomo</p>
|
||
<p class="text-xs text-muted-foreground">软件启动时自动运行内核</p>
|
||
</div>
|
||
<Switch v-model="localSettings.autoStart" />
|
||
</div>
|
||
<div class="flex items-center justify-between rounded-md border p-3">
|
||
<div>
|
||
<p class="text-sm">启动时自动开启系统代理</p>
|
||
<p class="text-xs text-muted-foreground">mihomo 启动后自动设置 Windows 系统代理,退出时自动关闭</p>
|
||
</div>
|
||
<Switch v-model="localSettings.autoSystemProxy" />
|
||
</div>
|
||
<Separator />
|
||
<div class="grid grid-cols-2 gap-4">
|
||
<div class="grid gap-2">
|
||
<Label for="mode-select">运行模式</Label>
|
||
<Select v-model="localSettings.mode">
|
||
<SelectTrigger class="w-full">
|
||
<SelectValue placeholder="选择模式" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="rule">规则模式(按规则分流)</SelectItem>
|
||
<SelectItem value="global">全局模式(全部走代理)</SelectItem>
|
||
<SelectItem value="direct">直连模式(不走代理)</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<div class="grid gap-2">
|
||
<Label for="log-level-select">日志级别</Label>
|
||
<Select v-model="localSettings.logLevel">
|
||
<SelectTrigger class="w-full">
|
||
<SelectValue placeholder="选择日志级别" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="silent">silent(静默)</SelectItem>
|
||
<SelectItem value="error">error(错误)</SelectItem>
|
||
<SelectItem value="warning">warning(警告)</SelectItem>
|
||
<SelectItem value="info">info(信息)</SelectItem>
|
||
<SelectItem value="debug">debug(调试)</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</ScrollArea>
|
||
</TabsContent>
|
||
</Tabs>
|
||
|
||
<!-- 通用确认对话框 -->
|
||
<AlertDialog :model-value="confirmState.open" @update:model-value="onConfirmOpenChange">
|
||
<AlertDialogContent>
|
||
<AlertDialogHeader>
|
||
<AlertDialogTitle>{{ confirmState.opts.title }}</AlertDialogTitle>
|
||
<AlertDialogDescription>{{ confirmState.opts.description }}</AlertDialogDescription>
|
||
</AlertDialogHeader>
|
||
<AlertDialogFooter>
|
||
<AlertDialogCancel>{{ confirmState.opts.cancelText || '取消' }}</AlertDialogCancel>
|
||
<AlertDialogAction
|
||
:class="confirmState.opts.destructive ? 'bg-destructive text-white shadow-xs hover:bg-destructive/90' : ''"
|
||
@click="onConfirmAction"
|
||
>
|
||
{{ confirmState.opts.confirmText || '确认' }}
|
||
</AlertDialogAction>
|
||
</AlertDialogFooter>
|
||
</AlertDialogContent>
|
||
</AlertDialog>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
/* Tab 内容切换动画已移至 src/style.css 全局 .tab-animate 类,所有模块共用 */
|
||
</style>
|