Files
Thing/src/modules/proxy/ProxyModule.vue
T
2026-08-06 10:33:16 +08:00

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