2063 lines
87 KiB
Vue
2063 lines
87 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,
|
||
Waypoints, ArrowDown, ArrowUp, Activity, X
|
||
} from '@lucide/vue'
|
||
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, type ProxyConnection } 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'
|
||
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) => {
|
||
// 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
|
||
}
|
||
|
||
const activeTab = ref('overview')
|
||
// 浮动标签切换器:注册到 TitleBar,滚动遮挡时自动显示
|
||
const tabsStore = useModuleTabsStore()
|
||
const tabsListRef = useModuleTabs('proxy', activeTab, [
|
||
{ value: 'overview', label: '概览' },
|
||
{ value: 'connections', 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; downloadUrl: string } | null>(null)
|
||
|
||
// 自动切换节点(从 settings 持久化;执行由后端调度)
|
||
const autoSwitchEnabled = ref(false)
|
||
const autoSwitchInterval = ref(5) // 分钟
|
||
const autoSwitchTargetGroup = ref('') // 目标代理组
|
||
const autoSwitchRegion = ref('') // 空=全部,否则按地区过滤
|
||
|
||
// 从 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)
|
||
}
|
||
}
|
||
|
||
// 后端自动切换事件监听句柄(模块卸载时关闭)
|
||
let autoSwitchUnlisten: UnlistenFn[] = []
|
||
|
||
// 手风琴展开项
|
||
const accordionValue = ref<string>('')
|
||
|
||
// 进程状态轮询
|
||
let statusTimer: ReturnType<typeof setInterval> | null = null
|
||
let trafficTimer: ReturnType<typeof setInterval> | null = null
|
||
let connTimer: ReturnType<typeof setInterval> | null = null
|
||
|
||
/** 字节 → 人类可读大小(B / KB / MB / GB / TB) */
|
||
function fmtBytes(v: number): string {
|
||
if (!v && v !== 0) return '--'
|
||
if (v < 1024) return v + ' B'
|
||
const units = ['KB', 'MB', 'GB', 'TB']
|
||
let n = v / 1024
|
||
let u = 0
|
||
while (n >= 1024 && u < units.length - 1) {
|
||
n /= 1024
|
||
u++
|
||
}
|
||
return (n >= 100 ? n.toFixed(0) : n >= 10 ? n.toFixed(1) : n.toFixed(2)) + ' ' + units[u]
|
||
}
|
||
|
||
/** 速率显示(字节/秒 → /s) */
|
||
function fmtSpeed(v: number): string {
|
||
return fmtBytes(v) + '/s'
|
||
}
|
||
|
||
const running = computed(() => store.status.running)
|
||
|
||
// ===== 连接页签 =====
|
||
/** 当前连接列表(store.connections 可能为 null → 视为空) */
|
||
const connList = computed(() => store.connections ?? [])
|
||
const connFilter = ref('')
|
||
/** 内网/国内/国外 一键过滤('all' = 全部) */
|
||
const connScopeFilter = ref<'all' | ConnScope>('all')
|
||
/** 一键过滤选项 */
|
||
const scopeFilterOptions: { value: 'all' | ConnScope; label: string }[] = [
|
||
{ value: 'all', label: '全部' },
|
||
{ value: 'direct', label: '国内' },
|
||
{ value: 'proxy', label: '国外' }
|
||
]
|
||
/** 顶部下载/上传速率(复用实时流量快照的整体速率) */
|
||
const connTotalDownloadSec = computed(() => store.traffic?.downloadSpeed ?? 0)
|
||
const connTotalUploadSec = computed(() => store.traffic?.uploadSpeed ?? 0)
|
||
/** 命中规则总数 = 活跃连接数(每条连接命中一条规则) */
|
||
const ruleHitCount = computed(() => connList.value.length)
|
||
/** 按规则聚合当前连接,便于观察哪些规则被频繁命中 */
|
||
const ruleHits = computed(() => {
|
||
const map = new Map<string, number>()
|
||
for (const c of connList.value) {
|
||
const r = c.rule || 'DIRECT'
|
||
map.set(r, (map.get(r) ?? 0) + 1)
|
||
}
|
||
return [...map.entries()]
|
||
.map(([rule, count]) => ({ rule, count }))
|
||
.sort((a, b) => b.count - a.count)
|
||
})
|
||
/** 按内网/国内/国外、通用关键词过滤后的连接 */
|
||
const filteredConnections = computed(() => {
|
||
const q = connFilter.value.trim().toLowerCase()
|
||
return connList.value.filter((c) => {
|
||
if (connScopeFilter.value !== 'all' && connScopeOf(c) !== connScopeFilter.value) return false
|
||
if (!q) return true
|
||
const process = (c.metadata?.process ?? '').toLowerCase()
|
||
const host = (c.metadata?.host ?? '').toLowerCase()
|
||
const rule = (c.rule ?? '').toLowerCase()
|
||
return process.includes(q) || host.includes(q) || rule.includes(q)
|
||
})
|
||
})
|
||
/** 连接进程显示名 */
|
||
const connProcess = (c: ProxyConnection) => c.metadata?.process || '未知'
|
||
/** 连接源地址显示(IP:端口) */
|
||
const connSource = (c: ProxyConnection) => {
|
||
const ip = c.metadata?.sourceIP
|
||
const port = c.metadata?.sourcePort
|
||
return ip ? `${ip}${port ? ':' + port : ''}` : '--'
|
||
}
|
||
/** 连接目标显示:优先 host,否则用 IP:端口 */
|
||
const connHost = (c: ProxyConnection) => {
|
||
const h = c.metadata?.host
|
||
if (h) return h
|
||
const ip = c.metadata?.destinationIP
|
||
const port = c.metadata?.destinationPort
|
||
return ip ? `${ip}${port ? ':' + port : ''}` : '--'
|
||
}
|
||
|
||
// ===== 规则中文名 / 内外网判断 =====
|
||
/** 规则类型归一化:忽略大小写与 "-" "_" 空格(订阅里可能写成 DomainSuffix / DOMAIN-SUFFIX) */
|
||
const normRuleType = (s: string) => s.trim().replace(/[-_\s]/g, '').toLowerCase()
|
||
const RULE_CN: Record<string, string> = {
|
||
// 匹配/动作
|
||
match: '兜底', final: '兜底', ruleset: '规则集', direct: '直连', reject: '拒绝',
|
||
// 域名
|
||
domain: '域名', domainsuffix: '域名后缀', domainkeyword: '域名关键字', domainregex: '域名正则',
|
||
// 地理 / 站点
|
||
geoip: '地区', geosite: '域名组', ipasn: 'ASN',
|
||
// 地址网段
|
||
ipcidr: 'IP段', ipcidr6: 'IP段(v6)', srcipcidr: '源IP段', srcipcidr6: '源IP段(v6)',
|
||
dstnet: '目标地址', srcnet: '源地址', network: '网络类型',
|
||
// 端口
|
||
srcport: '源端口', dstport: '目标端口', srcportrange: '源端口范围', dstportrange: '目标端口范围',
|
||
// 进程 / 用户
|
||
process: '进程', processname: '进程名', processpath: '进程路径', processpathregex: '进程路径正则', uid: '用户ID',
|
||
// 入站
|
||
intype: '入站类型', inuser: '入站用户', inname: '入站名称', inport: '入站端口',
|
||
// 规则集衍生
|
||
rulesetipcidr: '规则集IP', rulesetipcidr6: '规则集IP(v6)', rulesetdomainsuffix: '规则集域名后缀',
|
||
rulesetdomainkeyword: '规则集域名关键字', rulesetdomainregex: '规则集域名正则', rulesetgeoip: '规则集地区',
|
||
// 逻辑
|
||
and: '与', not: '非', or: '或', subrule: '子规则'
|
||
}
|
||
/** 将 mihomo 规则翻译为中文类型名(仅替换类型关键字,保留匹配内容) */
|
||
const translateRule = (rule: string): string => {
|
||
const parts = rule.split(',')
|
||
const mapped = RULE_CN[normRuleType(parts[0])]
|
||
if (!mapped) return rule
|
||
return [mapped, ...parts.slice(1)].join(',')
|
||
}
|
||
/**
|
||
* 连接走向分类:只区分国内/国外。
|
||
* - direct 国内(未走代理;内网/局域网因代理过滤也已直连,归入国内)
|
||
* - proxy 国外(已走代理节点;代理多用于访问境外,故视为国外)
|
||
* 判定依据:链路最后一跳是否为 DIRECT。
|
||
*/
|
||
type ConnScope = 'direct' | 'proxy'
|
||
const connScopeOf = (c: ProxyConnection): ConnScope => {
|
||
const chain = c.chains
|
||
if (chain && chain.length) {
|
||
return chain[chain.length - 1] === 'DIRECT' ? 'direct' : 'proxy'
|
||
}
|
||
// 退化:无链路信息时按国内直连兜底
|
||
return 'direct'
|
||
}
|
||
|
||
/** 各规则的简短释义(供「规则命中」展示),仅为便于理解,非精确语义 */
|
||
const RULE_DESC: Record<string, string> = {
|
||
// 匹配/动作
|
||
match: '未匹配任何规则时的兜底', final: '未匹配任何规则时的兜底', direct: '直连', reject: '拒绝访问',
|
||
// 域名
|
||
domain: '完全匹配该域名', domainsuffix: '匹配该域名及其子域名', domainkeyword: '域名包含该关键词', domainregex: '域名按正则匹配',
|
||
// 地理 / 站点
|
||
geoip: '按 IP 所属国家/地区', geosite: '按域名所属站点类别', ipasn: '按 IP 所属 ASN 自治域',
|
||
// 地址网段
|
||
ipcidr: '匹配该 IP 网段', ipcidr6: '匹配该 IPv6 网段', srcipcidr: '按源 IP 网段', srcipcidr6: '按源 IPv6 网段',
|
||
dstnet: '按目标 IP/域名', srcnet: '按源 IP/域名', network: '按网络类型(TCP/UDP)',
|
||
// 端口
|
||
srcport: '按源端口', dstport: '按目标端口', srcportrange: '按源端口范围', dstportrange: '按目标端口范围',
|
||
// 进程 / 用户
|
||
process: '按进程', processname: '按进程名', processpath: '按进程可执行路径', processpathregex: '按进程路径正则',
|
||
uid: '按 Linux 用户 ID',
|
||
// 入站
|
||
intype: '按入站类型', inuser: '按入站用户', inname: '按入站名称', inport: '按入站端口',
|
||
// 规则集衍生
|
||
ruleset: '按规则集内容匹配',
|
||
rulesetipcidr: '匹配规则集中任一 IP 网段', rulesetipcidr6: '匹配规则集中任一 IPv6 网段',
|
||
rulesetdomainsuffix: '匹配规则集中任一域名后缀', rulesetdomainkeyword: '匹配规则集中任一域名关键字',
|
||
rulesetdomainregex: '匹配规则集正则', rulesetgeoip: '匹配规则集中任一地区',
|
||
// 逻辑
|
||
and: '多个条件同时满足(与)', or: '任一条件满足(或)', not: '取反(非)', subrule: '子规则分发'
|
||
}
|
||
/** 取了某条规则的类型释义;未知类型返回空串 */
|
||
const ruleDesc = (rule: string): string => {
|
||
return RULE_DESC[normRuleType(rule.split(',')[0])] ?? ''
|
||
}
|
||
/** 断开全部连接 */
|
||
const closeAllConnections = async () => {
|
||
const ok = await showConfirm({
|
||
title: '断开全部连接',
|
||
description: `确定断开当前 ${connList.value.length} 条活跃连接吗?`,
|
||
confirmText: '断开',
|
||
destructive: true
|
||
})
|
||
if (!ok) return
|
||
for (const c of [...connList.value]) {
|
||
await store.closeConnection(c.id).catch(() => {})
|
||
}
|
||
}
|
||
|
||
// 伪节点关键词: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()
|
||
}
|
||
} finally {
|
||
// 无论初始化链路是否出错,都结束"加载中"占位,避免模块永久卡死
|
||
store.initialized = true
|
||
}
|
||
}
|
||
|
||
onMounted(() => {
|
||
init()
|
||
statusTimer = setInterval(async () => {
|
||
// 窗口/标签页不可见时暂停状态轮询,恢复可见后下个 tick 自动继续
|
||
if (document.hidden) return
|
||
await store.refreshStatus()
|
||
// 同步系统代理真实状态(注册表可能被外部改动,3s 周期足够感知)
|
||
await store.refreshSystemProxy()
|
||
}, 3000)
|
||
// 流量采样:运行中每秒拉取一次实时速率/累计流量
|
||
trafficTimer = setInterval(async () => {
|
||
if (document.hidden) return
|
||
if (running.value) await store.refreshTraffic()
|
||
}, 1000)
|
||
// 连接列表:仅「连接」页签激活且运行时低频拉取
|
||
connTimer = setInterval(async () => {
|
||
if (document.hidden) return
|
||
if (activeTab.value === 'connections' && running.value) await store.refreshConnections()
|
||
}, 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)
|
||
if (trafficTimer) clearInterval(trafficTimer)
|
||
if (connTimer) clearInterval(connTimer)
|
||
document.removeEventListener('visibilitychange', onVisibilityChange)
|
||
autoSwitchUnlisten.forEach(fn => fn())
|
||
autoSwitchUnlisten = []
|
||
})
|
||
|
||
/** 页面可见性变化时刷新系统代理状态(低成本感知外部修改) */
|
||
async function onVisibilityChange() {
|
||
if (!document.hidden) {
|
||
await store.refreshSystemProxy()
|
||
}
|
||
}
|
||
|
||
watch(running, async (val, old) => {
|
||
if (val && !old) {
|
||
await store.waitForApi()
|
||
await store.refreshVersion()
|
||
await loadProxiesWithError()
|
||
}
|
||
})
|
||
|
||
// 切换到节点 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 {
|
||
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) => {
|
||
// 停机时禁止开启(正常情况下开关已禁用,此处兜底防止外部调用)
|
||
if (on && !running.value) {
|
||
toast.warning('请先启动 mihomo 再开启系统代理')
|
||
store.refreshSystemProxy()
|
||
return
|
||
}
|
||
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 })
|
||
// 测速新节点:用 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 onToggleAutoSwitch = (on: boolean) => {
|
||
autoSwitchEnabled.value = on
|
||
if (on) {
|
||
toast.success('自动切换已开启', {
|
||
description: `每 ${autoSwitchInterval.value} 分钟测试并切换至最优节点`
|
||
})
|
||
} else {
|
||
toast.info('自动切换已关闭')
|
||
}
|
||
saveAutoSwitchSettings()
|
||
}
|
||
|
||
const onAutoSwitchIntervalChange = (val: unknown) => {
|
||
autoSwitchInterval.value = Number(val) || 5
|
||
saveAutoSwitchSettings()
|
||
}
|
||
|
||
/** 后端自动切换完成后刷新节点列表并提示(后台亦可运行,不依赖模块激活) */
|
||
const onProxyAutoSwitch = async (e: { payload: { switched?: boolean; group?: string; name?: string; delay?: number } }) => {
|
||
const p = e.payload
|
||
try {
|
||
await store.loadProxies()
|
||
} catch (err) {
|
||
logger.error('自动切换后刷新节点失败: ' + err)
|
||
}
|
||
if (p?.switched && p.name && p.delay) {
|
||
toast.success('已自动切换到最优节点', {
|
||
description: `${p.name} (${p.delay}ms)`
|
||
})
|
||
}
|
||
}
|
||
|
||
// ===== 内核更新 =====
|
||
const handleCheckUpdate = async () => {
|
||
checkingUpdate.value = true
|
||
try {
|
||
const info = await store.checkKernelUpdate()
|
||
kernelUpdateInfo.value = { latestVersion: info.latestVersion, hasUpdate: info.hasUpdate, downloadUrl: info.downloadUrl }
|
||
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)
|
||
|
||
/** 开始更新:确保有下载 URL → 调用 updateKernel(复用 installProgress 进度机制)。
|
||
* 下载阶段允许 mihomo 运行(可通过当前系统代理下载),
|
||
* 解压替换前由 need_stop 阶段弹窗要求停止 mihomo */
|
||
const handleStartUpdate = async () => {
|
||
if (store.installing) return
|
||
// 使用检查更新时获取的下载 URL(缺失时先补查一次,避免后端二次请求 GitHub)
|
||
let url = kernelUpdateInfo.value?.downloadUrl ?? ''
|
||
if (!url) {
|
||
try {
|
||
const info = await store.checkKernelUpdate()
|
||
url = info.downloadUrl
|
||
kernelUpdateInfo.value = { latestVersion: info.latestVersion, hasUpdate: info.hasUpdate, downloadUrl: info.downloadUrl }
|
||
} catch (e) {
|
||
toast.error('获取更新信息失败', { description: String(e) })
|
||
return
|
||
}
|
||
}
|
||
toast.info('开始下载更新...')
|
||
try {
|
||
await store.updateKernel(selectedMirrorPrefix.value, url)
|
||
} catch (e) {
|
||
toast.error('内核更新失败', { description: String(e) })
|
||
}
|
||
}
|
||
|
||
// ===== 首次安装内核 =====
|
||
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 '安装完成'
|
||
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'
|
||
if (stage === 'need_stop') return 'text-amber-500'
|
||
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 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`
|
||
|
||
// ===== 首次安装内核 =====
|
||
// 镜像源选择:'__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 反馈
|
||
}
|
||
}
|
||
|
||
/** 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 === 'need_stop') {
|
||
handleNeedStop()
|
||
} else 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
|
||
const prev = store.settings
|
||
try {
|
||
await store.saveSettings({
|
||
...store.settings,
|
||
...localSettings.value
|
||
})
|
||
|
||
// 网络相关字段(端口/接口/密钥)变更需重启 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) })
|
||
}
|
||
}
|
||
|
||
// 注册保存处理函数到标签栏 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-5 max-w-md !bg-transparent !p-0 !shadow-none">
|
||
<TabsTrigger value="overview" class="gap-1.5"><Globe class="size-3.5" />概览</TabsTrigger>
|
||
<TabsTrigger value="connections" class="gap-1.5"><Waypoints 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"><Activity class="size-4 text-primary" />实时流量</span>
|
||
<Badge v-if="running" variant="outline" class="gap-1 text-xs">
|
||
<span class="size-1.5 rounded-full bg-emerald-500" />实时更新
|
||
</Badge>
|
||
</CardTitle>
|
||
</CardHeader>
|
||
<CardContent class="space-y-4 text-sm">
|
||
<div class="grid grid-cols-2 gap-4">
|
||
<div>
|
||
<div class="flex items-center gap-1 text-muted-foreground text-xs mb-1">
|
||
<ArrowDown class="size-3.5 text-emerald-500" />下载
|
||
</div>
|
||
<p class="text-lg font-semibold tabular-nums">{{ fmtSpeed(store.traffic?.downloadSpeed ?? 0) }}</p>
|
||
<p class="text-xs text-muted-foreground tabular-nums">累计 {{ store.traffic ? fmtBytes(store.traffic.downloadTotal) : '--' }}</p>
|
||
</div>
|
||
<div>
|
||
<div class="flex items-center gap-1 text-muted-foreground text-xs mb-1">
|
||
<ArrowUp class="size-3.5 text-rose-500" />上传
|
||
</div>
|
||
<p class="text-lg font-semibold tabular-nums">{{ fmtSpeed(store.traffic?.uploadSpeed ?? 0) }}</p>
|
||
<p class="text-xs text-muted-foreground tabular-nums">累计 {{ store.traffic ? fmtBytes(store.traffic.uploadTotal) : '--' }}</p>
|
||
</div>
|
||
</div>
|
||
<Separator />
|
||
<div class="flex items-center justify-between">
|
||
<span class="flex items-center gap-1.5 text-muted-foreground">
|
||
<Waypoints class="size-3.5" />活跃连接
|
||
</span>
|
||
<span class="font-semibold tabular-nums">{{ store.traffic?.activeConnections ?? '--' }}</span>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
<!-- 内核状态 -->
|
||
<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 class="font-mono text-xs text-right truncate cursor-default">{{ pathDisplay || pathShort }}</span>
|
||
</TooltipTrigger>
|
||
<TooltipContent class="max-w-[480px] break-words">{{ 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="['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="['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">
|
||
{{ store.installProgress.percent }}%
|
||
</span>
|
||
</div>
|
||
<Progress
|
||
v-if="installHasTotal || (store.installProgress.stage !== 'downloading' && store.installProgress.stage !== 'checking')"
|
||
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
|
||
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>
|
||
|
||
<!-- 运行状态 -->
|
||
<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 || !running"
|
||
@update:model-value="onToggleSystemProxy"
|
||
/>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
</ScrollArea>
|
||
</TabsContent>
|
||
|
||
<!-- 连接 -->
|
||
<TabsContent value="connections" class="flex-1 mt-4 tab-animate">
|
||
<div v-if="!running" key="conn-not-running" class="h-full flex flex-col items-center justify-center text-muted-foreground gap-2 pt-10 pr-10">
|
||
<Waypoints class="size-12 opacity-30" />
|
||
<p class="text-sm">mihomo 未运行,请先在概览页启动</p>
|
||
</div>
|
||
<div v-else class="h-full flex flex-col gap-4 pr-3">
|
||
<!-- 顶部统计 -->
|
||
<div class="grid grid-cols-2 md:grid-cols-4 gap-2">
|
||
<Card>
|
||
<CardContent class="py-3">
|
||
<div class="flex items-center gap-1.5 text-muted-foreground text-xs mb-1"><Waypoints class="size-3.5" />活跃连接</div>
|
||
<p class="text-xl font-semibold tabular-nums">{{ connList.length }}</p>
|
||
</CardContent>
|
||
</Card>
|
||
<Card>
|
||
<CardContent class="py-3">
|
||
<div class="flex items-center gap-1.5 text-muted-foreground text-xs mb-1"><ArrowDown class="size-3.5 text-emerald-500" />下载速率</div>
|
||
<p class="text-xl font-semibold tabular-nums">{{ fmtSpeed(connTotalDownloadSec) }}</p>
|
||
</CardContent>
|
||
</Card>
|
||
<Card>
|
||
<CardContent class="py-3">
|
||
<div class="flex items-center gap-1.5 text-muted-foreground text-xs mb-1"><ArrowUp class="size-3.5 text-rose-500" />上传速率</div>
|
||
<p class="text-xl font-semibold tabular-nums">{{ fmtSpeed(connTotalUploadSec) }}</p>
|
||
</CardContent>
|
||
</Card>
|
||
<Card>
|
||
<CardContent class="py-3">
|
||
<div class="flex items-center gap-1.5 text-muted-foreground text-xs mb-1"><Target class="size-3.5" />命中规则</div>
|
||
<p class="text-xl font-semibold tabular-nums">{{ ruleHitCount }}</p>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
|
||
<!-- 规则命中分布 -->
|
||
<Card>
|
||
<CardHeader class="pb-2">
|
||
<CardTitle class="text-sm flex items-center gap-2"><Target class="size-3.5 text-primary" />规则命中</CardTitle>
|
||
</CardHeader>
|
||
<CardContent class="pt-0">
|
||
<div v-if="!ruleHits.length" class="text-xs text-muted-foreground py-2">暂无连接</div>
|
||
<div v-else class="space-y-1.5">
|
||
<div v-for="r in ruleHits.slice(0, 6)" :key="r.rule" class="flex items-baseline gap-2 text-xs">
|
||
<span class="w-1.5 h-1.5 rounded-full bg-primary shrink-0 self-center" />
|
||
<span class="font-medium shrink-0">{{ translateRule(r.rule) }}</span>
|
||
<span class="flex-1 min-w-0 truncate text-muted-foreground">
|
||
<template v-if="ruleDesc(r.rule)">({{ ruleDesc(r.rule) }})</template>
|
||
</span>
|
||
<span class="shrink-0 tabular-nums">×{{ r.count }}</span>
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<!-- 连接列表 -->
|
||
<Card class="flex-1 min-h-0 flex flex-col">
|
||
<CardHeader class="pb-2 space-y-2">
|
||
<div class="flex items-center justify-between gap-3">
|
||
<CardTitle class="text-sm flex items-center gap-2"><Waypoints class="size-3.5 text-primary" />当前连接</CardTitle>
|
||
<div class="flex items-center gap-2">
|
||
<Input v-model="connFilter" placeholder="按进程/域名/规则过滤" class="h-8 w-56" />
|
||
<Button size="xs" variant="outline" :disabled="!connList.length" @click="closeAllConnections">
|
||
<Square class="size-3" />断开全部
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
<div class="flex flex-wrap items-center gap-1.5">
|
||
<span class="text-xs text-muted-foreground">走向:</span>
|
||
<Button
|
||
v-for="s in scopeFilterOptions"
|
||
:key="s.value"
|
||
size="xs"
|
||
:variant="connScopeFilter === s.value ? 'default' : 'outline'"
|
||
@click="connScopeFilter = s.value"
|
||
>{{ s.label }}</Button>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent class="flex-1 min-h-0 overflow-hidden pt-0">
|
||
<ScrollArea class="h-full">
|
||
<table class="w-full text-xs">
|
||
<thead class="sticky top-0 z-10 bg-card text-muted-foreground">
|
||
<tr class="border-b">
|
||
<th class="text-left font-medium py-2 px-2">进程 / 源地址</th>
|
||
<th class="text-left font-medium py-2 px-2">目标</th>
|
||
<th class="text-left font-medium py-2 px-2">规则</th>
|
||
<th class="text-right font-medium py-2 px-2">下载</th>
|
||
<th class="text-right font-medium py-2 px-2">上传</th>
|
||
<th class="text-center font-medium py-2 px-2"></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="c in filteredConnections" :key="c.id" class="border-b last:border-0 hover:bg-muted/40">
|
||
<td class="py-2 px-2 align-baseline">
|
||
<span class="font-medium truncate block max-w-[140px]">{{ connProcess(c) }}</span>
|
||
<span class="text-muted-foreground">{{ connSource(c) }}</span>
|
||
</td>
|
||
<td class="py-2 px-2 align-baseline">
|
||
<div class="flex items-center gap-1.5">
|
||
<Badge
|
||
v-if="connScopeOf(c) === 'direct'"
|
||
variant="outline" class="h-4 px-1.5 text-[10px] shrink-0 border-emerald-500 text-emerald-500"
|
||
>国内</Badge>
|
||
<Badge
|
||
v-else
|
||
variant="outline" class="h-4 px-1.5 text-[10px] shrink-0 border-sky-500 text-sky-500"
|
||
>国外</Badge>
|
||
<span class="truncate block max-w-[140px]">{{ connHost(c) }}</span>
|
||
</div>
|
||
<span class="text-muted-foreground">{{ c.metadata?.network }} / {{ c.metadata?.type }}</span>
|
||
</td>
|
||
<td class="py-2 px-2 align-baseline text-muted-foreground">
|
||
<span class="truncate block max-w-[160px]">{{ translateRule(c.rule || 'DIRECT') }}</span>
|
||
</td>
|
||
<td class="py-2 px-2 text-right tabular-nums align-baseline">{{ fmtBytes(c.download) }}</td>
|
||
<td class="py-2 px-2 text-right tabular-nums align-baseline">{{ fmtBytes(c.upload) }}</td>
|
||
<td class="py-2 px-2 text-center align-baseline">
|
||
<Button size="icon" variant="ghost" class="size-6" title="断开连接" @click="store.closeConnection(c.id)">
|
||
<X class="size-3.5" />
|
||
</Button>
|
||
</td>
|
||
</tr>
|
||
<tr v-if="!connList.length">
|
||
<td colspan="6" class="text-center text-muted-foreground py-8">暂无活跃连接</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</ScrollArea>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
</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 :open="confirmState.open" @update:open="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>
|