Files
Thing/src/modules/proxy/ProxyModule.vue
T

2063 lines
87 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>