feat: 重构前端代码结构并增强类型定义

- 新增 API 模块统一封装所有 IPC 调用,提高代码可维护性
- 创建 `src/types/index.ts` 集中定义核心业务类型(Game, NodeConfig 等)
- 新增组合式函数 `useFilePicker`、`useGameScanner` 和 `useAppUpdater` 以复用逻辑
- 改进设置页面,新增侧边栏导航和多个设置模块(通用、网络、日志、关于)
- 优化本地代理逻辑,增加节点列表变化监听和自动重启机制
- 扩展协议解析测试用例,覆盖更多 VMess、VLESS Reality 等场景
- 更新项目结构文档 `docs/src-structure.md` 说明模块职责
- 修复 Electron 主进程预加载路径兼容性问题
This commit is contained in:
ZenEcho committed 2026-02-19 20:16:55 +08:00
1 parent 23938d6479
commit c2bb7e6ed8
57 files changed
+3661 -1917

No files matched your search

+1
View File
@@ -0,0 +1 @@
test
+92
View File
@@ -0,0 +1,92 @@
# Frontend (Vue) 项目结构说明
本文档详细描述了 `src/` 目录下的代码结构与模块功能。本项目采用 **Vue 3 + TypeScript + Vite** 技术栈,使用 **Composition API** 和 **Pinia** 进行状态管理。
## 目录树
```
src/
├── api/ # [接口层] 后端通信接口 (IPC 封装)
│ ├── index.ts # 统一导出所有 API 模块
│ └── *.ts # 各业务模块 API (如 games.ts, nodes.ts, singbox.ts)
│
├── assets/ # [资源层] 静态资源文件
│ ├── main.css # 全局样式入口
│ └── style.css # 基础样式定义
│
├── autoImport/ # [自动导入] 自动生成的类型声明
│ ├── auto-imports.d.ts # 自动导入函数的类型声明
│ └── components.d.ts # 自动导入组件的类型声明
│
├── components/ # [组件层] Vue 组件
│ ├── common/ # 通用基础组件 (图标选择器、主题切换等)
│ ├── dashboard/ # 仪表盘相关组件 (图表、节点选择器)
│ ├── library/ # 游戏库相关组件 (分类管理、编辑弹窗)
│ ├── node/ # 节点管理相关组件 (节点卡片、编辑/导入弹窗)
│ └── settings/ # 设置页面相关组件 (侧边栏、各设置模块)
│
├── composables/ # [逻辑层] 组合式函数 (Hooks)
│ ├── useAppUpdater.ts # 应用更新逻辑
│ ├── useFilePicker.ts # 文件选择逻辑
│ ├── useGameScanner.ts # 游戏扫描逻辑
│ └── useTheme.ts # 主题切换逻辑
│
├── constants/ # [常量层] 全局常量定义
│ └── index.ts # 常量统一导出
│
├── layouts/ # [布局层] 页面布局组件
│ └── MainLayout.vue # 主布局 (侧边栏 + 内容区)
│
├── locales/ # [国际化] i18n 语言包
│ ├── en-US.json # 英文翻译
│ └── zh-CN.json # 中文翻译
│
├── router/ # [路由层] 路由配置
│ └── index.ts # Vue Router 配置 (定义路由表)
│
├── stores/ # [状态层] Pinia 状态管理
│ ├── index.ts # Pinia 实例导出
│ ├── games.ts # 游戏数据状态
│ ├── nodes.ts # 节点数据状态
│ ├── settings.ts # 应用设置状态
│ └── local-proxy.ts # 本地代理状态
│
├── types/ # [类型层] TypeScript 类型定义
│ ├── electron.d.ts # Electron API 类型扩展
│ └── index.ts # 核心业务类型定义 (Game, NodeConfig 等)
│
├── utils/ # [工具层] 通用工具函数
│ ├── latency-session.ts # 延迟测试会话管理
│ ├── protocol.ts # 代理协议解析工具 (VMess, SS, VLESS 等)
│ ├── runtime-logger.ts # 运行时日志捕获工具
│ └── singbox-config.ts # sing-box 配置文件生成器
│
├── views/ # [视图层] 页面级组件
│ ├── dashboard/ # 仪表盘页面
│ ├── library/ # 游戏库页面
│ ├── nodes/ # 节点列表页面
│ └── settings/ # 设置中心页面
│
├── App.vue # 应用根组件
├── main.ts # 应用入口文件 (挂载 Vue, Pinia, Router)
└── vite-env.d.ts # Vite 环境类型声明
```
## 模块详细说明
### 1. 接口层 (`api/`)
- **IPC 封装**: 所有与 Electron 主进程的通信都通过 `window.electron` 等预加载脚本暴露的对象进行。
- **模块化**: 每个文件对应后端的 Service 模块,例如 `singbox.ts` 对应后端的 `SingboxService`。
### 2. 状态层 (`stores/`)
- **`games.ts`**: 管理本地游戏列表、当前选中游戏、加速状态。
- **`nodes.ts`**: 管理节点列表、节点延迟测试结果。
- **`settings.ts`**: 持久化存储用户设置(语言、主题、DNS 模式等)。
### 3. 工具层 (`utils/`)
- **`protocol.ts`**: 核心协议解析逻辑,负责将分享链接 (vmess://, ss://) 解析为标准节点配置对象。
- **`singbox-config.ts`**: 根据当前游戏规则和选中的节点,动态生成 sing-box 的 JSON 配置文件。
### 4. 逻辑层 (`composables/`)
- **Composition API**: 封装可复用的业务逻辑,例如 `useTheme.ts` 处理深色模式切换和主题色应用。
- **`useGameScanner.ts`**: 协调前端 UI 和后端扫描接口,处理扫描进度和结果展示。
+9 -2
View File
@@ -25,7 +25,7 @@ global.__filename = __filename
// @ts-ignore
global.__dirname = __dirname
process.env.APP_ROOT = path.join(__dirname, '../..')
process.env.APP_ROOT = path.join(__dirname, '../')
const APP_ID = 'com.' + pkg.name + '.client'
export const VITE_DEV_SERVER_URL = process.env['VITE_DEV_SERVER_URL']
@@ -147,6 +147,12 @@ function createTray(icon: NativeImage | null) {
function createWindow() {
const appIcon = loadAppIcon()
const preloadCandidates = [
path.join(__dirname, '../preload/index.mjs'),
path.join(__dirname, 'preload.mjs'),
path.join(__dirname, 'index.mjs'),
]
const preloadPath = preloadCandidates.find(p => fs.existsSync(p)) || preloadCandidates[0]
win = new BrowserWindow({
width: 1200,
@@ -155,7 +161,8 @@ function createWindow() {
minHeight: 620,
frame: false,
webPreferences: {
preload: path.join(__dirname, '../preload/index.mjs'), // Adjusted path
// Compatible with both nested and flat dist-electron outputs.
preload: preloadPath,
nodeIntegration: false,
contextIsolation: true,
webSecurity: false,
+1 -1
View File
@@ -244,7 +244,7 @@ export class DatabaseService {
process_name: '[]',
category_id: otherCategoryId,
proxy_mode: 'routing',
routing_rules: JSON.stringify([]),
routing_rules: JSON.stringify(['global']),
status: 'idle',
latency: 0
}
+67 -9
View File
@@ -3,6 +3,7 @@ import { runCommand } from '../utils/command'
import { findAvailablePort } from '../utils/port'
import { ping, tcpPing } from '../utils/ping'
import nodeNet from 'node:net'
import nodeTls from 'node:tls'
type SystemCommandResult = {
ok: boolean
@@ -76,12 +77,16 @@ export class SystemService {
async testHttpProxyConnect(proxyPort: number, targetHost: string, targetPort: number = 443, timeoutMs: number = 5000) {
return new Promise<{ ok: boolean, statusLine: string, error?: string }>((resolve) => {
const socket = nodeNet.createConnection({ host: '127.0.0.1', port: proxyPort })
let tlsSocket: nodeTls.TLSSocket | null = null
let settled = false
let buf = ''
let connectBuf = ''
let responseBuf = ''
const requestPath = targetHost.includes('google') ? '/generate_204' : '/'
const done = (payload: { ok: boolean, statusLine: string, error?: string }) => {
if (settled) return
settled = true
try { tlsSocket?.destroy() } catch { }
socket.destroy()
resolve(payload)
}
@@ -94,11 +99,54 @@ export class SystemService {
socket.write(req)
})
socket.on('data', (chunk: Buffer) => {
buf += chunk.toString('utf8')
const lineEnd = buf.indexOf('\r\n')
if (lineEnd < 0) return
const statusLine = buf.slice(0, lineEnd).trim()
done({ ok: /^HTTP\/1\.[01]\s+200\b/i.test(statusLine), statusLine })
connectBuf += chunk.toString('utf8')
const headerEnd = connectBuf.indexOf('\r\n\r\n')
if (headerEnd < 0) return
const lineEnd = connectBuf.indexOf('\r\n')
const connectStatusLine = (lineEnd >= 0 ? connectBuf.slice(0, lineEnd) : '').trim()
if (!/^HTTP\/1\.[01]\s+200\b/i.test(connectStatusLine)) {
done({ ok: false, statusLine: connectStatusLine, error: 'connect-not-200' })
return
}
socket.removeAllListeners('data')
socket.setTimeout(0)
tlsSocket = nodeTls.connect({
socket,
servername: targetHost,
rejectUnauthorized: false
}, () => {
const getReq = [
`GET ${requestPath} HTTP/1.1`,
`Host: ${targetHost}`,
'User-Agent: LagZero/1.0',
'Accept: */*',
'Connection: close',
'',
''
].join('\r\n')
tlsSocket?.write(getReq)
})
tlsSocket.setTimeout(Math.max(1000, timeoutMs))
tlsSocket.on('timeout', () => done({ ok: false, statusLine: '', error: 'https-timeout' }))
tlsSocket.on('error', (err: any) => done({ ok: false, statusLine: '', error: `https:${String(err?.message || err)}` }))
tlsSocket.on('data', (data: Buffer) => {
responseBuf += data.toString('utf8')
const responseHeaderEnd = responseBuf.indexOf('\r\n\r\n')
if (responseHeaderEnd < 0) return
const responseLineEnd = responseBuf.indexOf('\r\n')
const responseStatusLine = (responseLineEnd >= 0 ? responseBuf.slice(0, responseLineEnd) : '').trim()
const ok = /^HTTP\/1\.[01]\s+(2\d\d|3\d\d)\b/i.test(responseStatusLine)
done({
ok,
statusLine: responseStatusLine,
...(ok ? {} : { error: 'http-not-2xx-3xx' })
})
})
})
})
}
@@ -114,12 +162,22 @@ export class SystemService {
ipcMain.handle('system:test-http-proxy-connect', async (_, proxyPort: number, targetHost: string, targetPort: number = 443, timeoutMs: number = 5000) => {
return this.testHttpProxyConnect(proxyPort, targetHost, targetPort, timeoutMs)
})
ipcMain.handle('system:ping', async (_, host: string) => ping(host))
ipcMain.handle('system:ping', async (_, host: string) => {
try {
const result = await ping(host)
return typeof result?.latency === 'number' ? result.latency : -1
} catch (e: any) {
console.warn('[SystemService] system:ping failed', host, e)
return -1
}
})
ipcMain.handle('system:tcp-ping', async (_, host: string, port: number) => {
try {
return await tcpPing(host, port)
const result = await tcpPing(host, port)
return typeof result?.latency === 'number' ? result.latency : -1
} catch (e: any) {
return { latency: -1, loss: 100 }
console.warn('[SystemService] system:tcp-ping failed', host, port, e)
return -1
}
})
}
+18 -3
View File
@@ -26,13 +26,28 @@ export function normalizeProcessNames(processNames: string[]): string[] {
for (const item of processNames) {
const normalized = normalizeProcessName(item)
if (!normalized) continue
set.add(normalized)
const lower = normalized.toLowerCase()
if (lower !== normalized) set.add(lower)
for (const alias of expandProcessAliases(normalized)) {
set.add(alias)
const lower = alias.toLowerCase()
if (lower !== alias) set.add(lower)
}
}
return Array.from(set)
}
function expandProcessAliases(processName: string): string[] {
const value = String(processName || '').trim()
if (!value) return []
const aliases = new Set<string>([value])
// Tolerate users entering "chrome" instead of "chrome.exe" on Windows.
if (!value.includes('.') && !value.includes('/') && !value.includes('\\')) {
aliases.add(`${value}.exe`)
}
return Array.from(aliases)
}
/**
* 比较两个字符串数组是否相等 (忽略顺序)
*
+3 -3
View File
@@ -3,10 +3,10 @@
"productName": "LagZero",
"private": true,
"version": "0.1.0",
"main": "dist-electron/main/index.js",
"main": "dist-electron/index.js",
"type": "module",
"author": "LagZero Team",
"description": "Cross-platform Game Accelerator",
"description": "是一款专为极致游戏体验打造的、高性能游戏加速器桌面客户端",
"repository": {
"type": "git",
"url": "https://github.com/ZenEcho/LagZero.git"
@@ -153,4 +153,4 @@
"vuedraggable": "^4.1.0",
"vue-tsc": "^3.1.4"
}
}
}
+16
View File
@@ -0,0 +1,16 @@
import type { LogEntry } from '@/types/electron'
export const appApi = {
getVersion: (): Promise<string> => window.app.getVersion(),
checkUpdate: () => window.app.checkUpdate(),
openUrl: (url: string) => window.app.openUrl(url),
}
export const logsApi = {
getAll: () => window.logs.getAll(),
clear: () => window.logs.clear(),
getFilePath: () => window.logs.getFilePath(),
pushFrontend: (entry: Partial<LogEntry>) => window.logs.pushFrontend(entry),
onNew: (callback: (entry: LogEntry) => void) => window.logs.onNew(callback),
offNew: (callback: (entry: LogEntry) => void) => window.logs.offNew(callback),
}
+7
View File
@@ -0,0 +1,7 @@
import type { Category } from '@/types'
export const categoryApi = {
getAll: () => window.categories.getAll(),
save: (category: Category) => window.categories.save(category),
delete: (id: string) => window.categories.delete(id),
}
+10
View File
@@ -0,0 +1,10 @@
export const electronApi = {
minimize: () => window.electron.minimize(),
maximize: () => window.electron.maximize(),
close: () => window.electron.close(),
pickImage: () => window.electron.pickImage(),
pickProcess: () => window.electron.pickProcess(),
pickProcessFolder: (maxDepth?: number) => window.electron.pickProcessFolder(maxDepth),
on: (channel: string, callback: (...args: any[]) => void) => window.electron.on(channel, callback),
off: (channel: string, callback: (...args: any[]) => void) => window.electron.off(channel, callback),
}
+7
View File
@@ -0,0 +1,7 @@
import type { Game } from '@/types'
export const gameApi = {
getAll: () => window.games.getAll(),
save: (game: Game) => window.games.save(game),
delete: (id: string) => window.games.delete(id),
}
+8
View File
@@ -0,0 +1,8 @@
export * from './games'
export * from './nodes'
export * from './categories'
export * from './system'
export * from './singbox'
export * from './proxy-monitor'
export * from './app'
export * from './electron'
+8
View File
@@ -0,0 +1,8 @@
import type { NodeConfig } from '@/types'
export const nodeApi = {
getAll: () => window.nodes.getAll(),
save: (node: NodeConfig) => window.nodes.save(node),
delete: (id: string) => window.nodes.delete(id),
import: (nodes: NodeConfig[]) => window.nodes.import(nodes),
}
+4
View File
@@ -0,0 +1,4 @@
export const proxyMonitorApi = {
start: (gameId: string, processNames: string[]) => window.proxyMonitor.start(gameId, processNames),
stop: () => window.proxyMonitor.stop(),
}
+5
View File
@@ -0,0 +1,5 @@
export const singboxApi = {
start: (config: string) => window.singbox.start(config),
stop: () => window.singbox.stop(),
restart: (config: string) => window.singbox.restart(config),
}
+16
View File
@@ -0,0 +1,16 @@
export const systemApi = {
scanProcesses: () => window.system.scanProcesses(),
scanLocalGames: () => window.system.scanLocalGames(),
getProcessTree: () => window.system.getProcessTree(),
ping: (host: string) => window.system.ping(host),
tcpPing: (host: string, port: number) => window.system.tcpPing(host, port),
flushDnsCache: () => window.system.flushDnsCache(),
reinstallTunAdapter: (interfaceName?: string) => window.system.reinstallTunAdapter(interfaceName),
findAvailablePort: (port: number, count?: number) => window.system.findAvailablePort(port, count),
testHttpProxyConnect: (
proxyPort: number,
targetHost: string,
targetPort?: number,
timeoutMs?: number
) => window.system.testHttpProxyConnect(proxyPort, targetHost, targetPort, timeoutMs),
}
+395
View File
@@ -0,0 +1,395 @@
/* 全局样式调整 */
:root {
/* Primitive Colors (Semantic Base) */
--rgb-white: 255, 255, 255;
--rgb-black: 0, 0, 0;
/* Status Colors (Modern Zinc/Tailwind Palette) */
--rgb-success: 22, 163, 74; /* Green-600 */
--rgb-success-bg: 220, 252, 231; /* Green-100 */
--rgb-on-success: 255, 255, 255; /* White */
--rgb-warning: 202, 138, 4; /* Yellow-600 */
--rgb-warning-bg: 254, 249, 195; /* Yellow-100 */
--rgb-on-warning: 255, 255, 255; /* White */
--rgb-error: 225, 29, 72; /* Rose-600 */
--rgb-error-bg: 255, 228, 230; /* Rose-100 */
--rgb-on-error: 255, 255, 255; /* White */
--rgb-info: 37, 99, 235; /* Blue-600 */
--rgb-info-bg: 219, 234, 254; /* Blue-100 */
--rgb-on-info: 255, 255, 255; /* White */
/* Theme: Light (Sophisticated Modern) */
--rgb-primary: 16, 185, 129; /* Emerald-500 */
--rgb-primary-hover: 5, 150, 105; /* Emerald-600 */
--rgb-on-primary: 255, 255, 255;
--rgb-secondary: 100, 116, 139; /* Slate-500 */
--rgb-secondary-hover: 71, 85, 105; /* Slate-600 */
--rgb-on-secondary: 255, 255, 255;
/* Backgrounds - Refined Off-white */
--rgb-background: 250, 250, 251; /* Softer than pure white */
--rgb-surface: 255, 255, 255; /* Pure white for cards */
--rgb-surface-panel: 244, 245, 247; /* Panel background */
--rgb-surface-overlay: 238, 240, 243; /* Hover/Input background */
/* Text - Slate Palette for better legibility */
--rgb-on-surface: 15, 23, 42; /* Slate-900 */
--rgb-on-surface-variant: 71, 85, 105; /* Slate-600 */
--rgb-on-surface-muted: 148, 163, 184; /* Slate-400 */
/* Borders - Soft Slate */
--rgb-border: 226, 232, 240; /* Slate-200 */
--rgb-border-hover: 203, 213, 225; /* Slate-300 */
--rgb-overlay: 0, 0, 0;
/* CSS Variables for direct usage */
--color-primary: rgb(var(--rgb-primary));
--color-background: rgb(var(--rgb-background));
--color-surface: rgb(var(--rgb-surface));
--color-on-surface: rgb(var(--rgb-on-surface));
--color-border: rgb(var(--rgb-border));
/* Font Base */
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
html,
body,
#app {
height: 100%;
margin: 0;
overflow: hidden; /* Prevent body scroll, use inner containers */
}
/* Reset buttons */
button {
border: none;
background: none;
}
body {
min-width: 320px;
min-height: 100vh;
background-color: var(--color-background);
color: var(--color-on-surface);
}
/* Theme Colors Override */
html.theme-green {
--rgb-primary: 16, 185, 129; /* Emerald-500 */
--rgb-primary-hover: 5, 150, 105; /* Emerald-600 */
}
html.theme-blue {
--rgb-primary: 59, 130, 246; /* Blue-500 */
--rgb-primary-hover: 37, 99, 235; /* Blue-600 */
}
html.theme-purple {
--rgb-primary: 139, 92, 246; /* Violet-500 */
--rgb-primary-hover: 124, 58, 237; /* Violet-600 */
}
html.theme-orange {
--rgb-primary: 249, 115, 22; /* Orange-500 */
--rgb-primary-hover: 234, 88, 12; /* Orange-600 */
}
html.theme-red {
--rgb-primary: 239, 68, 68; /* Red-500 */
--rgb-primary-hover: 220, 38, 38; /* Red-600 */
}
html.dark {
/* Theme: Dark (Deep Midnight Noir) */
--rgb-on-primary: 255, 255, 255;
--rgb-secondary: 148, 163, 184; /* Slate-400 */
--rgb-secondary-hover: 226, 232, 240; /* Slate-200 */
--rgb-on-secondary: 15, 23, 42; /* Slate-900 */
/* Backgrounds - DEEP NOIR */
--rgb-background: 10, 10, 12; /* Midnight Black */
--rgb-surface: 18, 18, 22; /* Ebony surface */
--rgb-surface-panel: 28, 28, 32; /* Obsidian panel */
--rgb-surface-overlay: 38, 38, 44; /* Hover state */
/* Text - High Contrast for Dark Mode */
--rgb-on-surface: 248, 250, 252; /* Slate-50 */
--rgb-on-surface-variant: 148, 163, 184; /* Slate-400 */
--rgb-on-surface-muted: 71, 85, 105; /* Slate-600 */
/* Borders - Subdued Obsidian */
--rgb-border: 38, 38, 44; /* Quiet borders */
--rgb-border-hover: 63, 63, 70; /* Visible hover borders */
/* Adjusted Status Colors for Dark Mode (Vibrant but readable) */
--rgb-success: 52, 211, 153; /* Emerald-400 */
--rgb-warning: 251, 191, 36; /* Amber-400 */
--rgb-error: 251, 113, 133; /* Rose-400 */
--rgb-info: 56, 189, 248; /* Sky-400 */
}
/* Transitions */
*,
*::before,
*::after {
transition:
background-color 0.2s cubic-bezier(0.4, 0, 0.2, 1),
border-color 0.2s cubic-bezier(0.4, 0, 0.2, 1),
color 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
transition: none !important;
}
}
/* Animations */
@keyframes pulse-slow {
0%,
100% {
opacity: 0.3;
transform: scale(1);
}
50% {
opacity: 0.5;
transform: scale(1.1);
}
}
.animate-pulse-slow {
animation: pulse-slow 8s infinite ease-in-out;
}
@keyframes fade-in-up {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in-up {
animation: fade-in-up 0.5s ease-out;
}
@keyframes scale-in {
from {
opacity: 0;
transform: scale(0.9);
}
to {
opacity: 1;
transform: scale(1);
}
}
.animate-scale-in {
animation: scale-in 0.4s cubic-bezier(0.16, 1, 0.3, 1);
}
/* Transitions */
.fade-slide-enter-active,
.fade-slide-leave-active {
transition: all 0.3s ease;
}
.fade-slide-enter-from {
opacity: 0;
transform: translateX(10px);
}
.fade-slide-leave-to {
opacity: 0;
transform: translateX(-10px);
}
/* Custom Scrollbar */
.custom-scrollbar::-webkit-scrollbar,
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.custom-scrollbar::-webkit-scrollbar-track,
::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb,
::-webkit-scrollbar-thumb {
background: rgba(var(--rgb-border), 0.5);
border-radius: 10px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover,
::-webkit-scrollbar-thumb:hover {
background: rgba(var(--rgb-border), 0.8);
}
/* Glass overrides */
.glass-input :deep(.n-input) {
background-color: rgba(255, 255, 255, 0.03);
backdrop-filter: blur(10px);
}
.glass-select :deep(.n-base-selection) {
background-color: rgba(255, 255, 255, 0.03) !important;
backdrop-filter: blur(10px);
}
.glass-button,
.glass-button-primary {
background-color: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(5px);
border: 1px solid rgba(255, 255, 255, 0.1);
transition: all 0.3s ease;
}
.glass-button:hover,
.glass-button-primary:hover {
background-color: rgba(255, 255, 255, 0.1);
transform: translateY(-1px);
}
.glass-button-primary {
background-color: rgba(var(--rgb-primary), 0.1);
border-color: rgba(var(--rgb-primary), 0.3);
color: rgb(var(--rgb-primary));
}
.glass-button-primary:hover {
background-color: rgba(var(--rgb-primary), 0.2);
box-shadow: 0 4px 12px rgba(var(--rgb-primary), 0.2);
}
/*
Fix for Naive UI Primary Button transparency issue in Light Mode
caused by Tailwind Preflight or style conflicts.
*/
/* Green Theme (Default) */
html.theme-green .n-button.n-button--primary-type,
html:not([class*="theme-"]) .n-button.n-button--primary-type {
background-color: #3eaf7c !important;
color: #ffffff !important;
border: 1px solid #3eaf7c !important;
}
html.theme-green .n-button.n-button--primary-type:hover,
html:not([class*="theme-"]) .n-button.n-button--primary-type:hover {
background-color: #10b981 !important;
border-color: #10b981 !important;
}
/* Blue Theme */
html.theme-blue .n-button.n-button--primary-type {
background-color: #3b82f6 !important;
color: #ffffff !important;
border: 1px solid #3b82f6 !important;
}
html.theme-blue .n-button.n-button--primary-type:hover {
background-color: #2563eb !important;
border-color: #2563eb !important;
}
/* Purple Theme */
html.theme-purple .n-button.n-button--primary-type {
background-color: #8b5cf6 !important;
color: #ffffff !important;
border: 1px solid #8b5cf6 !important;
}
html.theme-purple .n-button.n-button--primary-type:hover {
background-color: #7c3aed !important;
border-color: #7c3aed !important;
}
/* Orange Theme */
html.theme-orange .n-button.n-button--primary-type {
background-color: #f97316 !important;
color: #ffffff !important;
border: 1px solid #f97316 !important;
}
html.theme-orange .n-button.n-button--primary-type:hover {
background-color: #ea580c !important;
border-color: #ea580c !important;
}
/* Red Theme */
html.theme-red .n-button.n-button--primary-type {
background-color: #ef4444 !important;
color: #ffffff !important;
border: 1px solid #ef4444 !important;
}
html.theme-red .n-button.n-button--primary-type:hover {
background-color: #dc2626 !important;
border-color: #dc2626 !important;
}
/* Fix for Error and Warning buttons transparency issue */
.n-button.n-button--error-type:not(.n-button--quaternary):not(
.n-button--tertiary
):not(.n-button--secondary):not(.n-button--ghost) {
background-color: rgb(var(--rgb-error)) !important;
color: #ffffff !important;
border: 1px solid rgb(var(--rgb-error)) !important;
}
.n-button.n-button--error-type:not(.n-button--quaternary):not(
.n-button--tertiary
):not(.n-button--secondary):not(.n-button--ghost):hover {
filter: brightness(0.9);
}
.n-button.n-button--warning-type:not(.n-button--quaternary):not(
.n-button--tertiary
):not(.n-button--secondary):not(.n-button--ghost) {
background-color: rgb(var(--rgb-warning)) !important;
color: #ffffff !important;
border: 1px solid rgb(var(--rgb-warning)) !important;
}
.n-button.n-button--warning-type:not(.n-button--quaternary):not(
.n-button--tertiary
):not(.n-button--secondary):not(.n-button--ghost):hover {
filter: brightness(0.9);
}
/* Electron Drag Region Support */
.app-drag-region {
-webkit-app-region: drag;
}
.no-drag {
-webkit-app-region: no-drag;
}
/* Ensure Naive UI modals and overlays are not draggable and thus clickable */
.n-modal-container,
.n-modal-mask,
.n-modal-body-wrapper,
.n-dialog,
.n-notification-container,
.n-message-container,
.n-dropdown-menu,
.n-popover {
-webkit-app-region: no-drag;
}
/* Disabled state for Primary Button */
.n-button.n-button--primary-type.n-button--disabled {
opacity: 0.6 !important;
cursor: not-allowed !important;
}
+8
View File
@@ -11,14 +11,19 @@ export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
AboutSettings: typeof import('./../components/settings/AboutSettings.vue')['default']
AdvancedConfigEditor: typeof import('./../components/library/AdvancedConfigEditor.vue')['default']
CategoryManager: typeof import('./../components/library/CategoryManager.vue')['default']
GameEditModal: typeof import('./../components/library/GameEditModal.vue')['default']
GeneralSettings: typeof import('./../components/settings/GeneralSettings.vue')['default']
IconSelector: typeof import('./../components/common/IconSelector.vue')['default']
LatencyChart: typeof import('./../components/dashboard/LatencyChart.vue')['default']
LogsSettings: typeof import('./../components/settings/LogsSettings.vue')['default']
NButton: typeof import('naive-ui')['NButton']
NConfigProvider: typeof import('naive-ui')['NConfigProvider']
NDialogProvider: typeof import('naive-ui')['NDialogProvider']
NDivider: typeof import('naive-ui')['NDivider']
NetworkSettings: typeof import('./../components/settings/NetworkSettings.vue')['default']
NForm: typeof import('naive-ui')['NForm']
NFormItem: typeof import('naive-ui')['NFormItem']
NInput: typeof import('naive-ui')['NInput']
@@ -37,8 +42,11 @@ declare module 'vue' {
NSelect: typeof import('naive-ui')['NSelect']
NSpace: typeof import('naive-ui')['NSpace']
NSwitch: typeof import('naive-ui')['NSwitch']
NTag: typeof import('naive-ui')['NTag']
ProcessSelector: typeof import('./../components/common/ProcessSelector.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
SettingsSidebar: typeof import('./../components/settings/SettingsSidebar.vue')['default']
ThemeToggle: typeof import('./../components/common/ThemeToggle.vue')['default']
}
}
+32
View File
@@ -0,0 +1,32 @@
<template>
<div class="flex items-center gap-2 w-full">
<div class="w-10 h-10 bg-surface rounded border border-border overflow-hidden flex items-center justify-center">
<img v-if="modelValue" :src="modelValue" class="w-full h-full object-cover" />
<div v-else class="i-material-symbols-image text-xl text-on-surface-muted"></div>
</div>
<n-input :value="modelValue || ''" @update:value="$emit('update:modelValue', $event)" placeholder="支持 URL 或 file://..." />
<n-button @click="handlePickImage">选择文件</n-button>
<n-button quaternary @click="$emit('update:modelValue', '')">清空</n-button>
</div>
</template>
<script setup lang="ts">
import { useFilePicker } from '@/composables/useFilePicker'
defineProps<{
modelValue?: string
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void
}>()
const { pickImage } = useFilePicker()
async function handlePickImage() {
const url = await pickImage()
if (url) {
emit('update:modelValue', url)
}
}
</script>
+138
View File
@@ -0,0 +1,138 @@
<template>
<div class="w-full">
<!-- Single Line Mode (Comma separated) -->
<div v-if="mode === 'single'" class="flex flex-col w-full gap-1">
<n-input :value="singleValue" @update:value="handleSingleUpdate"
placeholder="例如:League of Legends.exe, LeagueClient.exe" />
<div class="flex gap-2">
<n-button @click="handlePickProcess" size="small" dashed>选择文件</n-button>
<n-button @click="handlePickFolder" size="small" dashed>选择目录</n-button>
</div>
<span class="text-xs text-on-surface-muted">{{ $t('games.process_name_tip') }}</span>
</div>
<!-- Multi Line Mode (List) -->
<div v-else class="w-full max-h-[200px] overflow-y-auto">
<div class="flex flex-col w-full gap-2">
<div v-for="(_, index) in listValue" :key="index" class="flex gap-2">
<n-input v-model:value="listValue[index]" :placeholder="placeholder || '例如:League of Legends.exe'"
@update:value="emitListUpdate" />
<n-button v-if="listValue.length > 1" @click="removeProcess(index)" type="error" quaternary circle>
<template #icon>
<div class="i-material-symbols-delete"></div>
</template>
</n-button>
</div>
<div class="flex gap-2 self-start">
<n-button @click="addProcess" dashed size="small">
<template #icon>
<div class="i-material-symbols-add"></div>
</template>
{{ $t('games.add_process') }}
</n-button>
<n-button @click="handlePickProcess" dashed size="small">
选择文件
</n-button>
<n-button @click="handlePickFolder" dashed size="small">
选择目录
</n-button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useFilePicker } from '@/composables/useFilePicker'
const props = withDefaults(defineProps<{
modelValue: string | string[]
mode?: 'single' | 'multi'
placeholder?: string
}>(), {
mode: 'single'
})
const emit = defineEmits<{
(e: 'update:modelValue', value: string | string[]): void
}>()
const { pickProcess, pickProcessFolderWithDialog } = useFilePicker()
// Helpers for Single Mode
const singleValue = computed(() => {
if (Array.isArray(props.modelValue)) {
return props.modelValue.join(', ')
}
return props.modelValue || ''
})
function handleSingleUpdate(val: string) {
if (props.mode === 'multi') return
emit('update:modelValue', val)
}
// Helpers for Multi Mode
const listValue = ref<string[]>([''])
watch(() => props.modelValue, (val) => {
if (props.mode === 'multi') {
if (Array.isArray(val)) {
listValue.value = val.length > 0 ? [...val] : ['']
} else if (val) {
listValue.value = [val]
} else {
listValue.value = ['']
}
}
}, { immediate: true })
function emitListUpdate() {
emit('update:modelValue', listValue.value)
}
function addProcess() {
listValue.value.push('')
emitListUpdate()
}
function removeProcess(index: number) {
listValue.value.splice(index, 1)
emitListUpdate()
}
// File Picking Logic
async function handlePickProcess() {
const files = await pickProcess()
if (files.length > 0) {
addFiles(files)
}
}
function handlePickFolder() {
pickProcessFolderWithDialog((files) => {
addFiles(files)
})
}
function addFiles(files: string[]) {
if (props.mode === 'single') {
// Merge into comma separated string
const current = singleValue.value
? singleValue.value.split(/[,,]/).map(s => s.trim()).filter(Boolean)
: []
const set = new Set(current)
files.forEach(f => set.add(f))
// Emit comma separated string
emit('update:modelValue', Array.from(set).join(', '))
} else {
// Multi mode
const current = new Set(listValue.value.filter(Boolean))
files.forEach(f => current.add(f))
listValue.value = Array.from(current)
if (listValue.value.length === 0) listValue.value.push('')
emitListUpdate()
}
}
</script>
+36 -12
View File
@@ -21,9 +21,8 @@
<button @click="cycleSort"
class="px-2 py-1.5 rounded-lg border border-transparent hover:border-border hover:bg-surface-overlay text-on-surface-muted hover:text-primary transition-colors flex items-center gap-1.5"
:title="$t('common.sort_by')">
<div class="i-material-symbols-sort text-lg"></div>
<span class="text-[10px] font-bold uppercase tracking-wider">{{ $t(`common.${sortBy === 'latency' ?
'latency_short' : sortBy}`) }}</span>
<div :class="getSortIcon(sortBy)" class="text-lg"></div>
<span class="text-[10px] font-bold uppercase tracking-wider">{{ getSortLabel(sortBy) }}</span>
</button>
</div>
@@ -108,6 +107,7 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { NodeConfig } from '@/utils/protocol'
import { useNodeStore } from '@/stores/nodes'
@@ -128,10 +128,12 @@ const emit = defineEmits<{
(e: 'update:modelValue', value: string): void
}>()
const { t } = useI18n()
// 响应式状态
const searchQuery = ref('')
const selectedTypes = ref<string[]>([])
const sortBy = ref<'latency' | 'load' | 'bandwidth'>('latency')
const sortBy = ref<'creation' | 'latency' | 'alphabetical'>('creation')
const nodeStore = useNodeStore()
@@ -177,16 +179,20 @@ const sortedNodes = computed(() => {
}
// 排序
result.sort((a, b) => {
if (sortBy.value === 'latency') {
if (sortBy.value === 'latency') {
result.sort((a, b) => {
const latA = a.latency <= 0 ? 999999 : a.latency
const latB = b.latency <= 0 ? 999999 : b.latency
return latA - latB
}
if (sortBy.value === 'load') return a.load - b.load
if (sortBy.value === 'bandwidth') return b.bandwidth - a.bandwidth
return 0
})
})
} else if (sortBy.value === 'alphabetical') {
result.sort((a, b) => {
const nameA = a.tag || a.server || ''
const nameB = b.tag || b.server || ''
return nameA.localeCompare(nameB)
})
}
// 'creation' 默认为列表顺序,不做额外排序
return result
})
@@ -203,12 +209,30 @@ function toggleTypeFilter(type: string) {
// 切换排序模式
function cycleSort() {
const modes: ('latency' | 'load' | 'bandwidth')[] = ['latency', 'load', 'bandwidth']
const modes: ('creation' | 'latency' | 'alphabetical')[] = ['creation', 'latency', 'alphabetical']
const currentIndex = modes.indexOf(sortBy.value)
const nextIndex = (currentIndex + 1) % modes.length
sortBy.value = modes[nextIndex]!
}
function getSortIcon(mode: string) {
switch (mode) {
case 'creation': return 'i-material-symbols-access-time'
case 'latency': return 'i-material-symbols-network-check'
case 'alphabetical': return 'i-material-symbols-sort-by-alpha'
default: return 'i-material-symbols-sort'
}
}
function getSortLabel(mode: string) {
switch (mode) {
case 'creation': return t('common.creation')
case 'latency': return t('common.latency_short')
case 'alphabetical': return t('common.alphabetical')
default: return mode
}
}
// 选择节点触发事件
function selectNode(node: ExtendedNode) {
const id = node.id || node.tag
+20 -142
View File
@@ -28,35 +28,8 @@
<template v-if="form.proxyMode === 'process'">
<n-form-item :label="$t('games.process_name')">
<div class="w-full max-h-[200px] overflow-y-auto">
<div class="flex flex-col w-full gap-2">
<div v-for="(_, index) in processList" :key="index" class="flex gap-2">
<n-input v-model:value="processList[index]" placeholder="例如:League of Legends.exe" />
<n-button v-if="processList.length > 1" @click="removeProcess(index)" type="error" quaternary circle>
<template #icon>
<div class="i-material-symbols-delete"></div>
</template>
</n-button>
</div>
<div class="flex gap-2 self-start">
<n-button @click="addProcess" dashed size="small">
<template #icon>
<div class="i-material-symbols-add"></div>
</template>
{{ $t('games.add_process') }}
</n-button>
<n-button @click="pickProcess" dashed size="small">
选择文件
</n-button>
<n-button @click="pickProcessFolder" dashed size="small">
选择目录
</n-button>
</div>
</div>
</div>
<ProcessSelector v-model="processList" mode="multi" />
</n-form-item>
</template>
<template v-if="form.proxyMode === 'routing'">
@@ -69,47 +42,13 @@
</n-form-item>
<n-form-item :label="`${$t('games.process_name')} (${$t('common.optional')})`">
<div class="w-full max-h-[200px] overflow-y-auto">
<div class="flex flex-col w-full gap-2">
<div v-for="(_, index) in processList" :key="index" class="flex gap-2">
<n-input v-model:value="processList[index]" placeholder="例如:Game.exe(用于启动检测)" />
<n-button v-if="processList.length > 1" @click="removeProcess(index)" type="error" quaternary circle>
<template #icon>
<div class="i-material-symbols-delete"></div>
</template>
</n-button>
</div>
<div class="flex gap-2 self-start">
<n-button @click="addProcess" dashed size="small">
<template #icon>
<div class="i-material-symbols-add"></div>
</template>
{{ $t('games.add_process') }}
</n-button>
<n-button @click="pickProcess" dashed size="small">
选择文件
</n-button>
<n-button @click="pickProcessFolder" dashed size="small">
选择目录
</n-button>
</div>
</div>
</div>
<ProcessSelector v-model="processList" mode="multi" placeholder="例如:Game.exe(用于启动检测)" />
</n-form-item>
</template>
<n-form-item :label="$t('games.icon')">
<div class="flex items-center gap-2 w-full">
<div
class="w-10 h-10 bg-surface rounded border border-border overflow-hidden flex items-center justify-center">
<img v-if="form.iconUrl" :src="form.iconUrl" class="w-full h-full object-cover" />
<div v-else class="i-material-symbols-image text-xl text-on-surface-muted"></div>
</div>
<n-input v-model:value="form.iconUrl" placeholder="支持 URL 或 file://..." />
<n-button @click="pickImage">选择文件</n-button>
<n-button quaternary @click="form.iconUrl = ''">清空</n-button>
</div>
</n-form-item>
<IconSelector v-model="form.iconUrl" />
</n-form-item>
</n-form>
<template #footer>
@@ -124,10 +63,12 @@
</template>
<script setup lang="ts">
import { ref, watch, computed, h } from 'vue'
import { useMessage, useDialog, NInputNumber } from 'naive-ui'
import { ref, watch, computed } from 'vue'
import type { Game } from '@/types'
import { useCategoryStore } from '@/stores/categories'
import { useMessage } from 'naive-ui'
import IconSelector from '@/components/common/IconSelector.vue'
import ProcessSelector from '@/components/common/ProcessSelector.vue'
const props = defineProps<{
modelValue: boolean
@@ -141,7 +82,6 @@ const emit = defineEmits<{
const categoryStore = useCategoryStore()
const message = useMessage()
const dialog = useDialog()
const isEdit = computed(() => !!props.editingGame)
const form = ref<Partial<Game>>({
@@ -189,92 +129,30 @@ watch(() => props.modelValue, (val) => {
}
})
function addProcess() {
processList.value.push('')
}
function removeProcess(index: number) {
processList.value.splice(index, 1)
}
function close() {
emit('update:modelValue', false)
}
async function pickImage() {
// @ts-ignore
const picker = window.electron?.pickImage
if (!picker) {
message.error('当前环境不支持选择文件')
return
}
const url = await picker()
if (url) {
form.value.iconUrl = url
}
}
async function pickProcess() {
const picker = window.electron?.pickProcess
if (!picker) {
message.error('当前环境不支持选择文件')
return
}
const files = await picker()
if (files && files.length > 0) {
// If we have existing empty slots, fill them first
// Or just append unique ones
const current = new Set(processList.value.filter(Boolean))
files.forEach((f: string) => current.add(f))
processList.value = Array.from(current)
if (processList.value.length === 0) processList.value.push('')
}
}
async function pickProcessFolder() {
const picker = window.electron?.pickProcessFolder
if (!picker) {
message.error('当前环境不支持选择文件')
return
}
const depth = ref(1)
dialog.create({
title: '选择目录扫描深度',
content: () => h('div', { class: 'flex flex-col gap-2' }, [
h('span', '请输入扫描深度(1 表示仅当前目录,-1 表示无限递归):'),
h(NInputNumber, {
value: depth.value,
onUpdateValue: (v: number | null) => { if (v !== null) depth.value = v },
min: -1
})
]),
positiveText: '确定选择目录',
negativeText: '取消',
onPositiveClick: async () => {
const files = await picker(depth.value)
if (files && files.length > 0) {
const current = new Set(processList.value.filter(Boolean))
files.forEach((f: string) => current.add(f))
processList.value = Array.from(current)
if (processList.value.length === 0) processList.value.push('')
message.success(`已添加 ${files.length} 个可执行文件`)
} else if (files) {
message.info('该目录下未找到 exe 文件')
}
}
})
}
function save() {
if (!form.value.name) return
const mode = form.value.proxyMode === 'routing' ? 'routing' : 'process'
const cleanProcesses = processList.value.map(p => p.trim()).filter(Boolean)
const cleanRules = routingRulesText.value.split('\n').map(r => r.trim()).filter(Boolean)
if (mode === 'process' && cleanProcesses.length === 0) {
message.warning('进程模式需要至少填写一个进程名')
return
}
if (mode === 'routing' && cleanRules.length === 0) {
message.warning('路由模式需要至少填写一条 IP/CIDR 规则')
return
}
const gameData: Game = {
...form.value,
proxyMode: mode,
iconUrl: form.value.iconUrl ? String(form.value.iconUrl).trim() : undefined,
processName: cleanProcesses,
routingRules: cleanRules
+7 -97
View File
@@ -7,27 +7,11 @@
</n-form-item>
<n-form-item :label="$t('games.process_name')">
<div class="flex flex-col w-full gap-1">
<n-input v-model:value="processNames" placeholder="例如:League of Legends.exe, LeagueClient.exe" />
<div class="flex gap-2">
<n-button @click="pickProcess" size="small" dashed>选择文件</n-button>
<n-button @click="pickProcessFolder" size="small" dashed>选择目录</n-button>
</div>
<span class="text-xs text-on-surface-muted">{{ $t('games.process_name_tip') }}</span>
</div>
<ProcessSelector v-model="processNames" mode="single" />
</n-form-item>
<n-form-item :label="$t('games.icon')">
<div class="flex items-center gap-2 w-full">
<div
class="w-10 h-10 bg-surface rounded border border-border overflow-hidden flex items-center justify-center">
<img v-if="form.iconUrl" :src="form.iconUrl" class="w-full h-full object-cover" />
<div v-else class="i-material-symbols-image text-xl text-on-surface-muted"></div>
</div>
<n-input v-model:value="form.iconUrl" placeholder="支持 URL 或 file://..." />
<n-button @click="pickImage">选择文件</n-button>
<n-button quaternary @click="form.iconUrl = ''">清空</n-button>
</div>
<IconSelector v-model="form.iconUrl" :name="form.name" />
</n-form-item>
<n-form-item :label="$t('games.category')">
@@ -50,9 +34,11 @@
</template>
<script setup lang="ts">
import { ref, watch, computed, h } from 'vue'
import { useMessage, useDialog, NInputNumber } from 'naive-ui'
import { ref, watch, computed } from 'vue'
import type { Game } from '@/types'
import { GAME_CATEGORIES } from '@/constants'
import IconSelector from '@/components/common/IconSelector.vue'
import ProcessSelector from '@/components/common/ProcessSelector.vue'
const props = defineProps<{
modelValue: boolean
@@ -66,7 +52,7 @@ const emit = defineEmits<{
const isEdit = computed(() => !!props.editingGame)
const categories: Game['category'][] = ['fps', 'moba', 'rpg', 'other']
const categories = GAME_CATEGORIES
const form = ref<Omit<Game, 'id'>>({
name: '',
@@ -76,9 +62,6 @@ const form = ref<Omit<Game, 'id'>>({
lastPlayed: 0
})
const message = useMessage()
const dialog = useDialog()
const processNames = computed({
get: () => {
if (Array.isArray(form.value.processName)) {
@@ -112,79 +95,6 @@ function close() {
emit('update:modelValue', false)
}
async function pickImage() {
const picker = window.electron?.pickImage
if (!picker) {
message.error('当前环境不支持选择文件')
return
}
const url = await picker()
if (url) {
form.value.iconUrl = url
}
}
async function pickProcess() {
const picker = window.electron?.pickProcess
if (!picker) {
message.error('当前环境不支持选择文件')
return
}
const files = await picker()
if (files && files.length > 0) {
let current: string[] = []
if (Array.isArray(form.value.processName)) {
current = [...form.value.processName]
} else if (form.value.processName) {
current = [form.value.processName]
}
const set = new Set(current)
files.forEach((f: string) => set.add(f))
form.value.processName = Array.from(set)
}
}
async function pickProcessFolder() {
const picker = window.electron?.pickProcessFolder
if (!picker) {
message.error('当前环境不支持选择文件')
return
}
// Ask for depth
const depth = ref(1)
dialog.create({
title: '选择目录扫描深度',
content: () => h('div', { class: 'flex flex-col gap-2' }, [
h('span', '请输入扫描深度(1 表示仅当前目录,-1 表示无限递归):'),
h(NInputNumber, {
value: depth.value,
onUpdateValue: (v: number | null) => { if (v !== null) depth.value = v },
min: -1
})
]),
positiveText: '确定选择目录',
negativeText: '取消',
onPositiveClick: async () => {
const files = await picker(depth.value)
if (files && files.length > 0) {
let current: string[] = []
if (Array.isArray(form.value.processName)) {
current = [...form.value.processName]
} else if (form.value.processName) {
current = [form.value.processName]
}
const set = new Set(current)
files.forEach((f: string) => set.add(f))
form.value.processName = Array.from(set)
message.success(`已添加 ${files.length} 个可执行文件`)
} else if (files) {
message.info('该目录下未找到 exe 文件')
}
}
})
}
function save() {
if (!form.value.name) return
+91
View File
@@ -0,0 +1,91 @@
<template>
<div class="flex flex-col items-center justify-center min-h-[400px] text-center animate-fade-in-up">
<div class="relative mb-8 group">
<div
class="absolute inset-0 bg-primary/20 blur-3xl rounded-full scale-150 group-hover:scale-175 transition-transform duration-700">
</div>
<img src="/logo.svg"
class="relative w-32 h-32 drop-shadow-2xl transition-transform duration-500 group-hover:scale-110"
alt="LagZero Logo" />
</div>
<h1
class=" h-14 text-4xl font-black bg-gradient-to-br from-primary via-purple-500 to-secondary bg-clip-text text-transparent mb-2">
{{ pkg.name }}
</h1>
<p class="text-on-surface-muted mb-8 tracking-wide">{{ $t('settings.tagline') }}</p>
<div class="grid gap-3 w-full max-w-xs">
<div
class="bg-surface-overlay/50 border border-border/50 rounded-xl p-3 flex justify-between items-center mb-4 backdrop-blur-sm">
<span class="text-sm font-medium text-on-surface-muted">{{ $t('settings.current_version') }}</span>
<span class="font-mono font-bold text-primary bg-primary/10 px-2 py-1 rounded">v{{ appVersion
}}</span>
</div>
<n-button type="primary" block secondary size="large" @click="() => checkUpdate()" :loading="checkingUpdate"
class="glass-button-primary">
<template #icon>
<div class="i-material-symbols-update"></div>
</template>
{{ $t('settings.check_update') }}
</n-button>
<div class="grid grid-cols-2 gap-3">
<n-button block secondary @click="openReleasesUrl" class="glass-button">
<template #icon>
<div class="i-material-symbols-history"></div>
</template>
{{ $t('settings.releases') }}
</n-button>
<n-button block secondary @click="openProjectUrl" class="glass-button">
<template #icon>
<div class="i-carbon-logo-github"></div>
</template>
GitHub
</n-button>
</div>
</div>
<div v-if="updateInfo"
class="mt-8 w-full max-w-md text-left bg-surface-panel/80 backdrop-blur-md border border-border p-5 rounded-2xl text-sm space-y-3 animate-scale-in shadow-xl">
<div v-if="updateInfo.error" class="text-red-500 flex items-center gap-2">
<div class="i-material-symbols-error text-xl"></div>
{{ $t('settings.update_error', { msg: updateInfo.error }) }}
</div>
<div v-else-if="updateInfo.available" class="space-y-3">
<div class="text-green-500 font-bold flex items-center gap-2 text-base">
<div class="i-material-symbols-rocket-launch"></div>
{{ $t('settings.update_available', { version: updateInfo.version }) }}
</div>
<div v-if="updateInfo.note"
class="text-on-surface-muted text-xs whitespace-pre-wrap max-h-40 overflow-y-auto bg-surface/50 p-3 rounded-lg border border-border/50 font-mono">
{{ updateInfo.note }}
</div>
<n-button type="primary" size="small" block class="glass-button-primary mt-2" @click="openReleasesUrl">
<template #icon>
<div class="i-material-symbols-download"></div>
</template>
{{ $t('settings.download_update') }}
</n-button>
<div class="text-xs text-on-surface-muted text-right">{{ updateInfo.date }}</div>
</div>
<div v-else class="text-on-surface-muted flex items-center justify-center gap-2 py-2">
<div class="i-material-symbols-check-circle text-green-500 text-lg"></div>
{{ $t('settings.no_update') }}
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { NButton } from 'naive-ui'
import pkg from '../../../package.json'
import { useAppUpdater } from '@/composables/useAppUpdater'
const { appVersion, checkingUpdate, updateInfo, checkUpdate, openProjectUrl, openReleasesUrl } = useAppUpdater()
</script>
@@ -0,0 +1,75 @@
<template>
<div class="space-y-8 max-w-3xl animate-fade-in-up">
<!-- Language Section -->
<section>
<h2 class="text-xl font-bold mb-4 flex items-center gap-2">
<div class="i-material-symbols-language text-primary"></div>
{{ $t('settings.language') }}
</h2>
<div class="bg-surface/50 border border-border/50 rounded-2xl p-1 flex gap-2 w-fit backdrop-blur-sm">
<button v-for="localeItem in availableLocales" :key="localeItem.code" @click="setLanguage(localeItem.code)"
class="px-6 py-2.5 rounded-xl transition-all duration-200 font-medium relative overflow-hidden" :class="currentLocale === localeItem.code
? 'bg-surface text-primary shadow-sm ring-1 ring-border/50'
: 'text-on-surface-muted hover:text-on-surface hover:bg-surface-overlay/50'">
{{ localeItem.name }}
</button>
</div>
</section>
<div class="w-full h-px bg-border/30"></div>
<!-- Theme Section -->
<section>
<h2 class="text-xl font-bold mb-4 flex items-center gap-2">
<div class="i-material-symbols-palette-outline text-primary"></div>
{{ $t('settings.theme_color') }}
</h2>
<div class="bg-surface-panel/50 border border-border/50 rounded-2xl p-6 backdrop-blur-sm">
<div class="flex flex-wrap gap-4">
<button v-for="color in themeColors" :key="color.value" @click="setThemeColor(color.value)"
class="group relative w-12 h-12 rounded-full flex items-center justify-center transition-all duration-300"
:class="[
color.bg,
themeColor === color.value ? 'scale-110 shadow-lg ring-2 ring-offset-2 ring-offset-surface ring-current' : 'opacity-70 hover:opacity-100 hover:scale-105'
]" :title="color.label">
<div v-if="themeColor === color.value"
class="i-material-symbols-check text-white font-bold text-xl animate-scale-in">
</div>
</button>
</div>
<p class="mt-4 text-sm text-on-surface-muted">
{{ $t('settings.theme_desc') }}
</p>
</div>
</section>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useTheme, type ThemeColor } from '@/composables/useTheme'
const { locale } = useI18n()
const { themeColor, setThemeColor } = useTheme()
const currentLocale = computed(() => locale.value)
const availableLocales = [
{ code: 'zh-CN', name: '简体中文' },
{ code: 'en-US', name: 'English' }
]
const themeColors: { value: ThemeColor, label: string, bg: string }[] = [
{ value: 'green', label: 'Green', bg: 'bg-[#3eaf7c]' },
{ value: 'blue', label: 'Blue', bg: 'bg-[#3b82f6]' },
{ value: 'purple', label: 'Purple', bg: 'bg-[#8b5cf6]' },
{ value: 'orange', label: 'Orange', bg: 'bg-[#f97316]' },
{ value: 'red', label: 'Red', bg: 'bg-[#ef4444]' }
]
function setLanguage(lang: string) {
locale.value = lang
}
</script>
+183
View File
@@ -0,0 +1,183 @@
<template>
<div class="space-y-6 animate-fade-in-up">
<section>
<h2 class="text-xl font-bold mb-4 flex items-center gap-2">
<div class="i-material-symbols-article-outline text-primary"></div>
{{ $t('settings.runtime_logs') }}
</h2>
<div class="bg-surface-panel/50 border border-border/50 rounded-2xl p-5 backdrop-blur-sm space-y-4">
<div class="grid grid-cols-1 lg:grid-cols-[1fr_1fr_2fr_auto] gap-3">
<n-select v-model:value="logCategory" :options="logCategoryOptions" />
<n-select v-model:value="logLevel" :options="logLevelOptions" />
<n-input v-model:value="logKeyword" :placeholder="$t('settings.logs_search_placeholder')" clearable />
<n-button secondary @click="clearLogs">{{ $t('settings.logs_clear') }}</n-button>
</div>
<div class="text-xs text-on-surface-muted">
{{ $t('settings.logs_count_info', { filtered: filteredLogs.length, total: logEntries.length }) }}
</div>
<div class="rounded-xl border border-border/60 bg-black/30 h-[520px] overflow-y-auto custom-scrollbar">
<div v-if="filteredLogs.length === 0"
class="h-full flex items-center justify-center text-sm text-on-surface-muted">
{{ $t('settings.logs_empty') }}
</div>
<div v-else class="divide-y divide-white/5">
<div v-for="row in filteredLogs" :key="row.id" class="flex gap-4 px-4 py-3 font-mono text-sm border-l-[4px]"
:class="[
row.level === 'error' ? 'border-[#ff4d4f] bg-[#ff4d4f]/10' :
row.level === 'warn' ? 'border-[#faad14] bg-[#faad14]/10' :
row.level === 'debug' ? 'border-[#8c8c8c] bg-[#8c8c8c]/5' :
'border-[#52c41a] bg-[#52c41a]/10'
]">
<!-- Left Side: Fixed Meta -->
<div class="flex-none flex flex-col items-center gap-2 w-20">
<span class="text-[11px] font-bold tracking-tighter ">
{{ formatLogTime(row.timestamp) }}
</span>
<span
class="w-full text-center py-0.5 rounded text-[10px] font-black uppercase tracking-widest shadow-sm"
:class="logLevelLabelClass(row.level)">
{{ row.level }}
</span>
</div>
<!-- Right Side: Content -->
<div class="flex-1 min-w-0 space-y-1">
<div class="flex items-center gap-2">
<span
class="text-[10px] font-black px-1.5 py-0.5 rounded bg-white/5 text-primary/80 uppercase tracking-tighter shadow-inner">
{{ $t('settings.logs_category_' + row.category) }}
</span>
<span class="text-[10px] text-white dark:text-gray-400 italic truncate">
{{ row.source }}
</span>
</div>
<div class="break-words leading-relaxed font-medium text-white dark:text-gray-400"
:class="logMessageTextClass(row.level)">
{{ row.message }}
</div>
<div v-if="row.detail"
class="mt-2 p-3 rounded-lg text-white dark:text-gray-400 bg-black/40 border border-white/5 text-xs break-all whitespace-pre-wrap leading-relaxed shadow-inner">
{{ row.detail }}
</div>
</div>
</div>
</div>
</div>
</div>
</section>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { NSelect, NInput, NButton } from 'naive-ui'
import { logsApi } from '@/api'
import type { LogEntry } from '@/types/electron'
const { t } = useI18n()
const logEntries = ref<LogEntry[]>([])
const logCategory = ref<'all' | 'frontend' | 'backend' | 'core'>('all')
const logLevel = ref<'all' | 'debug' | 'info' | 'warn' | 'error'>('all')
const logKeyword = ref('')
const logCategoryOptions = computed(() => [
{ label: t('settings.logs_all_categories'), value: 'all' },
{ label: t('settings.logs_category_frontend'), value: 'frontend' },
{ label: t('settings.logs_category_backend'), value: 'backend' },
{ label: t('settings.logs_category_core'), value: 'core' },
])
const logLevelOptions = computed(() => [
{ label: t('settings.logs_all_levels'), value: 'all' },
{ label: 'Debug', value: 'debug' },
{ label: 'Info', value: 'info' },
{ label: 'Warn', value: 'warn' },
{ label: 'Error', value: 'error' },
])
const filteredLogs = computed(() => {
const keyword = logKeyword.value.trim().toLowerCase()
return logEntries.value
.filter((row) => {
if (logCategory.value !== 'all' && row.category !== logCategory.value) return false
if (logLevel.value !== 'all' && row.level !== logLevel.value) return false
if (!keyword) return true
const text = `${row.message} ${row.detail || ''} ${row.source}`.toLowerCase()
return text.includes(keyword)
})
.slice()
.reverse()
})
const onLogNew = (row: LogEntry) => {
logEntries.value.push(row)
if (logEntries.value.length > 3000) {
logEntries.value.splice(0, logEntries.value.length - 3000)
}
}
async function loadLogs() {
try {
const rows = await logsApi.getAll()
// @ts-ignore
logEntries.value = Array.isArray(rows) ? rows : []
} catch {
logEntries.value = []
}
}
async function clearLogs() {
try {
await logsApi.clear()
} finally {
logEntries.value = []
}
}
function formatLogTime(ts: number) {
const date = new Date(ts)
const h = date.getHours().toString().padStart(2, '0')
const m = date.getMinutes().toString().padStart(2, '0')
const s = date.getSeconds().toString().padStart(2, '0')
return `${h}:${m}:${s}`
}
function logLevelLabelClass(level: LogEntry['level']) {
switch (level) {
case 'error': return 'bg-red-500 text-white'
case 'warn': return 'bg-amber-500 text-black'
case 'debug': return 'bg-zinc-700 text-zinc-300'
default: return 'bg-emerald-500 text-white'
}
}
function logMessageTextClass(level: LogEntry['level']) {
switch (level) {
case 'error': return 'text-[#ff4d4f] font-bold'
case 'warn': return 'text-[#fadb14] font-bold'
case 'debug': return 'text-[#bfbfbf]'
default: return 'text-[#95de64] font-bold'
}
}
onMounted(async () => {
await loadLogs()
try {
logsApi.onNew(onLogNew)
} catch (e) { }
})
onUnmounted(() => {
try {
logsApi.offNew(onLogNew)
} catch (e) { }
})
</script>
+291
View File
@@ -0,0 +1,291 @@
<template>
<div class="space-y-8 max-w-3xl animate-fade-in-up">
<!-- Latency Check -->
<section>
<h2 class="text-xl font-bold mb-4 flex items-center gap-2">
<div class="i-material-symbols-speed text-primary"></div>
{{ $t('settings.network') }}
</h2>
<div class="bg-surface-panel/50 border border-border/50 rounded-2xl p-6 backdrop-blur-sm space-y-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
<div class="space-y-3">
<label class="text-xs font-bold uppercase tracking-widest text-on-surface-muted">{{
$t('settings.check_method') }}</label>
<div class="flex bg-surface-overlay/50 p-1 rounded-xl border border-border/30">
<button v-for="method in ['ping', 'tcp']" :key="method" @click="setCheckMethod(method)"
class="flex-1 py-2 text-sm rounded-lg transition-all font-bold" :class="settingsStore.checkMethod === method
? 'bg-surface shadow-sm text-primary'
: 'text-on-surface-muted hover:text-on-surface'">
{{ method.toUpperCase() }}
</button>
</div>
</div>
<div class="space-y-3">
<label class="text-xs font-bold uppercase tracking-widest text-on-surface-muted">{{
$t('settings.check_interval') }}</label>
<n-select v-model:value="settingsStore.checkInterval" :options="intervalOptions" class="glass-select" />
</div>
</div>
</div>
</section>
<div class="w-full h-px bg-border/30"></div>
<!-- DNS Settings -->
<section>
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-bold flex items-center gap-2">
<div class="i-material-symbols-dns-outline text-primary"></div>
{{ $t('settings.dns_config') }}
</h2>
<div class="flex bg-surface-overlay/50 rounded-lg p-1 border border-border/30">
<button @click="settingsStore.dnsMode = 'secure'" :disabled="applyingDns"
class="px-3 py-1 text-xs rounded-md transition font-medium"
:class="settingsStore.dnsMode === 'secure'
? 'bg-surface shadow-sm text-primary'
: 'text-on-surface-muted hover:text-on-surface'">
{{ $t('settings.dns_mode_secure') }}
</button>
<button @click="settingsStore.dnsMode = 'system'" :disabled="applyingDns"
class="px-3 py-1 text-xs rounded-md transition font-medium"
:class="settingsStore.dnsMode === 'system'
? 'bg-surface shadow-sm text-primary'
: 'text-on-surface-muted hover:text-on-surface'">
{{ $t('settings.dns_mode_system') }}
</button>
</div>
</div>
<div
class="bg-surface-panel/50 border border-border/50 rounded-2xl p-6 backdrop-blur-sm transition-opacity duration-300"
:class="{ 'opacity-50 pointer-events-none grayscale': settingsStore.dnsMode === 'system' }">
<p v-if="settingsStore.dnsMode === 'system'" class="mb-3 text-xs text-warning">
{{ $t('settings.dns_mode_system_hint') }}
</p>
<div class="space-y-4">
<div class="space-y-2">
<label class="text-xs font-bold uppercase tracking-widest text-on-surface-muted">{{
$t('settings.dns_primary') }}</label>
<n-input v-model:value="settingsStore.dnsPrimary" placeholder="https://dns.google/dns-query"
class="glass-input" />
</div>
<div class="space-y-2">
<label class="text-xs font-bold uppercase tracking-widest text-on-surface-muted">{{
$t('settings.dns_secondary') }}</label>
<n-input v-model:value="settingsStore.dnsSecondary" placeholder="https://1.1.1.1/dns-query"
class="glass-input" />
</div>
</div>
</div>
</section>
<div class="w-full h-px bg-border/30"></div>
<!-- Local Proxy -->
<section>
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-bold flex items-center gap-2">
<div class="i-material-symbols-lan text-primary"></div>
{{ $t('settings.local_proxy') }}
</h2>
<n-switch v-model:value="settingsStore.localProxyEnabled">
<template #checked>{{ $t('common.enabled') }}</template>
<template #unchecked>{{ $t('common.disabled') }}</template>
</n-switch>
</div>
<div
class="bg-surface-panel/50 border border-border/50 rounded-2xl p-6 backdrop-blur-sm transition-opacity duration-300"
:class="{ 'opacity-50 pointer-events-none': !settingsStore.localProxyEnabled }">
<div class="space-y-2">
<label class="text-xs font-bold uppercase tracking-widest text-on-surface-muted">{{
$t('settings.local_proxy_port') }} (HTTP)</label>
<n-input-number v-model:value="settingsStore.localProxyPort" :min="1024" :max="65535" class="glass-select"
button-placement="both" />
<p class="text-xs text-on-surface-muted mt-1">{{ $t('settings.local_proxy_info', {
socks:
settingsStore.localProxyPort + 1
}) }}</p>
</div>
<div class="space-y-2 pt-2">
<div class="flex items-center justify-between">
<label class="text-xs font-bold uppercase tracking-widest text-on-surface-muted">
{{ $t('settings.local_proxy_node_strategy') }}
</label>
<n-switch v-model:value="settingsStore.localProxyNodeRecursiveTest" size="small" />
</div>
<p class="text-xs text-on-surface-muted">
{{ $t('settings.local_proxy_node_recursive') }}
</p>
</div>
<div class="space-y-2" :class="{ 'opacity-50 pointer-events-none': settingsStore.localProxyNodeRecursiveTest }">
<label class="text-xs font-bold uppercase tracking-widest text-on-surface-muted">
{{ $t('settings.local_proxy_fixed_node_index') }}
</label>
<n-input-number v-model:value="settingsStore.localProxyFixedNodeIndex" :min="1" :max="9999"
class="glass-select" button-placement="both" />
</div>
<div class="space-y-2 pt-2">
<label class="text-xs font-bold uppercase tracking-widest text-on-surface-muted">
{{ $t('common.status') }}
</label>
<div class="px-3 py-2 rounded-lg border text-xs" :class="localProxyStore.statusLevel === 'error'
? 'border-red-500/40 text-red-500 bg-red-500/10'
: localProxyStore.statusLevel === 'warning'
? 'border-yellow-500/40 text-yellow-500 bg-yellow-500/10'
: localProxyStore.statusLevel === 'success'
? 'border-green-500/40 text-green-500 bg-green-500/10'
: 'border-border/50 text-on-surface-muted bg-surface-overlay/40'">
{{ localProxyStore.statusText || (localProxyStore.starting ? $t('common.checking') : '-') }}
</div>
<p v-if="localProxyStore.testingTotal > 0" class="text-xs text-on-surface-muted">
{{ $t('common.checking') }} {{ localProxyStore.testingCurrent }}/{{ localProxyStore.testingTotal }}
<span v-if="localProxyStore.testingNodeLabel">- {{ localProxyStore.testingNodeLabel }}</span>
</p>
</div>
</div>
</section>
<div class="w-full h-px bg-border/30"></div>
<!-- Advanced Network -->
<section>
<h2 class="text-xl font-bold mb-4 flex items-center gap-2">
<div class="i-material-symbols-settings-ethernet text-primary"></div>
{{ $t('common.more') }}
</h2>
<div class="bg-surface-panel/50 border border-border/50 rounded-2xl p-6 backdrop-blur-sm space-y-6">
<div class="space-y-2">
<label class="text-xs font-bold uppercase tracking-widest text-on-surface-muted">{{
$t('settings.tun_interface') }}</label>
<n-input v-model:value="settingsStore.tunInterfaceName" placeholder="LagZero" class="glass-input" />
</div>
<div class="flex flex-wrap gap-4 pt-2">
<n-button secondary type="warning" @click="handleReinstallTun" :loading="working" :disabled="working"
class="glass-button">
<template #icon>
<div class="i-material-symbols-refresh"></div>
</template>
{{ $t('settings.tun_reinstall') }}
</n-button>
<n-button secondary type="info" @click="handleFlushDns" :loading="working" :disabled="working"
class="glass-button">
<template #icon>
<div class="i-material-symbols-cleaning-services"></div>
</template>
{{ $t('settings.dns_flush') }}
</n-button>
</div>
<div v-if="opMessage" class="px-4 py-3 rounded-xl text-sm flex items-center gap-3 border animate-scale-in"
:class="opOk ? 'bg-green-500/10 text-green-500 border-green-500/20' : 'bg-red-500/10 text-red-500 border-red-500/20'">
<div :class="opOk ? 'i-material-symbols-check-circle text-lg' : 'i-material-symbols-error text-lg'">
</div>
{{ opMessage }}
</div>
</div>
</section>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useSettingsStore } from '@/stores/settings'
import { useLocalProxyStore } from '@/stores/local-proxy'
import { useGameStore } from '@/stores/games'
import { NSelect, NInput, NButton, NSwitch, NInputNumber } from 'naive-ui'
import { systemApi } from '@/api'
const { t } = useI18n()
const settingsStore = useSettingsStore()
const localProxyStore = useLocalProxyStore()
const gameStore = useGameStore()
const working = ref(false)
const applyingDns = ref(false)
const opMessage = ref('')
const opOk = ref(true)
const intervalOptions = [
{ label: '1s', value: 1000 },
{ label: '2s', value: 2000 },
{ label: '5s', value: 5000 },
{ label: '10s', value: 10000 },
{ label: '30s', value: 30000 },
{ label: '1m', value: 60000 }
]
function setCheckMethod(method: string) {
// @ts-ignore
settingsStore.checkMethod = method
}
async function handleFlushDns() {
if (working.value) return
working.value = true
opMessage.value = ''
try {
const result = await systemApi.flushDnsCache()
opOk.value = !!result?.ok
opMessage.value = String(result?.message || '')
} catch (e: any) {
opOk.value = false
opMessage.value = String(e?.message || e || t('settings.dns_flush_failed'))
} finally {
working.value = false
}
}
async function handleReinstallTun() {
if (working.value) return
working.value = true
opMessage.value = ''
try {
const result = await systemApi.reinstallTunAdapter(settingsStore.tunInterfaceName || 'LagZero')
opOk.value = !!result?.ok
opMessage.value = String(result?.message || '')
} catch (e: any) {
opOk.value = false
opMessage.value = String(e?.message || e || t('settings.tun_reinstall_failed'))
} finally {
working.value = false
}
}
watch(
() => [
settingsStore.localProxyEnabled,
settingsStore.localProxyPort,
settingsStore.localProxyNodeRecursiveTest,
settingsStore.localProxyFixedNodeIndex
],
() => {
void localProxyStore.applySettingsChange()
}
)
watch(
() => settingsStore.dnsMode,
async (mode, prev) => {
if (mode === prev) return
if (applyingDns.value) return
const active = gameStore.getAcceleratingGame()
if (!active?.id) return
if (gameStore.operationState !== 'idle') return
applyingDns.value = true
try {
await gameStore.stopGame(active.id)
await gameStore.startGame(active.id)
} catch (e: any) {
opOk.value = false
opMessage.value = String(e?.message || e || t('dashboard.start_failed'))
} finally {
applyingDns.value = false
}
}
)
</script>
@@ -0,0 +1,68 @@
<template>
<aside class="w-64 flex-none flex flex-col gap-6">
<div class="px-2">
<h1 class="text-3xl font-bold bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent">
{{ $t('common.settings') }}
</h1>
<p class="text-xs text-on-surface-muted mt-1 uppercase tracking-widest font-bold opacity-60">
{{ $t('settings.preferences') }}
</p>
</div>
<nav class="flex flex-col gap-2">
<button v-for="tab in tabs" :key="tab.name" @click="$emit('update:modelValue', tab.name)"
class="flex items-center gap-3 px-4 py-3 rounded-xl transition-all duration-300 group relative overflow-hidden text-left"
:class="modelValue === tab.name
? 'bg-primary/10 text-primary font-bold shadow-sm'
: 'hover:bg-surface-overlay text-on-surface-muted hover:text-on-surface'">
<!-- Active Indicator -->
<div v-if="modelValue === tab.name"
class="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-8 bg-primary rounded-r-full"></div>
<div
:class="[tab.icon, modelValue === tab.name ? 'text-primary' : 'text-on-surface-muted group-hover:text-on-surface']"
class="text-xl transition-colors"></div>
<span>{{ tab.label }}</span>
</button>
</nav>
<!-- App Info Small -->
<div class="mt-auto px-4 py-4 rounded-xl bg-surface-panel/30 border border-border/50 backdrop-blur-sm">
<div class="flex items-center gap-3 mb-2">
<div class="w-8 h-8 rounded-lg bg-surface-panel flex items-center justify-center border border-border">
<img src="/logo.svg" class="w-5 h-5" alt="Logo" />
</div>
<div>
<div class="text-sm font-bold">{{ pkg.name }}</div>
<div class="text-[10px] text-on-surface-muted font-mono">v{{ appVersion }}</div>
</div>
</div>
</div>
</aside>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import pkg from '../../../package.json'
import { useAppUpdater } from '@/composables/useAppUpdater'
defineProps<{
modelValue: string
}>()
defineEmits<{
(e: 'update:modelValue', value: string): void
}>()
const { t } = useI18n()
const { appVersion } = useAppUpdater()
const tabs = computed(() => [
{ name: 'general', label: t('settings.general'), icon: 'i-material-symbols-tune' },
{ name: 'network', label: t('settings.network'), icon: 'i-material-symbols-wifi' },
{ name: 'logs', label: t('settings.logs'), icon: 'i-material-symbols-article' },
{ name: 'about', label: t('settings.about'), icon: 'i-material-symbols-info-outline' },
])
</script>
Whitespace-only changes.
+101
View File
@@ -0,0 +1,101 @@
import { ref } from 'vue'
import { useNotification, NButton } from 'naive-ui'
import { h } from 'vue'
import { useI18n } from 'vue-i18n'
import { appApi } from '@/api'
export function useAppUpdater() {
const appVersion = ref('0.0.0')
const checkingUpdate = ref(false)
const updateInfo = ref<{
available: boolean,
version?: string,
date?: string,
note?: string,
error?: string
} | null>(null)
const notification = useNotification()
const { t } = useI18n()
async function getVersion() {
try {
appVersion.value = await appApi.getVersion()
return appVersion.value
} catch (e) {
console.warn('App API not available', e)
return '0.0.0'
}
}
async function checkUpdate(silent = false) {
if (checkingUpdate.value) return
checkingUpdate.value = true
updateInfo.value = null
try {
const res = await appApi.checkUpdate()
if (res.error) {
updateInfo.value = { available: false, error: res.error }
} else {
updateInfo.value = {
available: res.updateAvailable,
version: res.version,
date: res.releaseDate,
note: res.releaseNotes
}
if (res.updateAvailable && !silent) {
showUpdateNotification(res)
}
}
return updateInfo.value
} catch (e: any) {
updateInfo.value = { available: false, error: e.message }
return updateInfo.value
} finally {
checkingUpdate.value = false
}
}
function showUpdateNotification(res: { version?: string, releaseNotes?: string, releaseDate?: string }) {
const n = notification.info({
title: t('settings.update_available', { version: res.version || '' }),
content: res.releaseNotes || t('settings.tagline'),
meta: res.releaseDate,
action: () =>
h(
NButton,
{
secondary: true,
type: 'primary',
size: 'small',
onClick: () => {
openReleasesUrl()
n.destroy()
}
},
{ default: () => t('settings.download_update') }
)
})
}
function openReleasesUrl() {
appApi.openUrl('https://github.com/ZenEcho/LagZero/releases')
}
function openProjectUrl() {
appApi.openUrl('https://github.com/ZenEcho/LagZero')
}
return {
appVersion,
checkingUpdate,
updateInfo,
getVersion,
checkUpdate,
openReleasesUrl,
openProjectUrl
}
}
+69
View File
@@ -0,0 +1,69 @@
import { ref } from 'vue'
import { useMessage, useDialog, NInputNumber } from 'naive-ui'
import { h } from 'vue'
import { electronApi } from '@/api'
export function useFilePicker() {
const message = useMessage()
const dialog = useDialog()
async function pickImage() {
try {
const url = await electronApi.pickImage()
return url || null
} catch (e) {
message.error('当前环境不支持选择文件')
return null
}
}
async function pickProcess() {
try {
const files = await electronApi.pickProcess()
if (files && files.length > 0) {
return files
}
return []
} catch (e) {
message.error('当前环境不支持选择文件')
return []
}
}
function pickProcessFolderWithDialog(callback: (files: string[]) => void) {
const depth = ref(1)
dialog.create({
title: '选择目录扫描深度',
content: () => h('div', { class: 'flex flex-col gap-2' }, [
h('span', '请输入扫描深度(1 表示仅当前目录,-1 表示无限递归):'),
h(NInputNumber, {
value: depth.value,
onUpdateValue: (v: number | null) => { if (v !== null) depth.value = v },
min: -1
})
]),
positiveText: '确定选择目录',
negativeText: '取消',
onPositiveClick: async () => {
try {
const files = await electronApi.pickProcessFolder(depth.value)
if (files && files.length > 0) {
callback(files)
message.success(`已添加 ${files.length} 个可执行文件`)
} else if (files) {
message.info('该目录下未找到 exe 文件')
}
} catch (e) {
message.error('当前环境不支持选择目录')
}
}
})
}
return {
pickImage,
pickProcess,
pickProcessFolderWithDialog
}
}
+78
View File
@@ -0,0 +1,78 @@
import { ref } from 'vue'
import { useMessage } from 'naive-ui'
import { systemApi } from '@/api'
import { useGameStore } from '@/stores/games'
import type { Game, LocalScanGame } from '@/types'
export function useGameScanner() {
const isScanning = ref(false)
const message = useMessage()
const gameStore = useGameStore()
// 临时使用 useCategoryStore 来获取默认分类,或者硬编码 'other'
// 为了解耦,这里简单硬编码,或者要求外部传入 fallbackCategoryId
const fallbackCategoryId = 'other'
async function scanGames() {
isScanning.value = true
try {
const localGames = await systemApi.scanLocalGames()
const added = await autoAddGamesFromLibraryScan(localGames)
const processes = await systemApi.scanProcesses()
const matched = gameStore.matchRunningGames(processes)
if (added > 0) {
message.success(`扫描完成:新增 ${added} 个游戏,当前运行匹配 ${matched.length} 个。`)
} else if (matched.length > 0) {
message.info(`扫描完成:未新增游戏,当前运行匹配 ${matched.length} 个。`)
} else {
message.info('扫描完成:未发现可新增的本地游戏。')
}
} catch (error) {
console.error('Failed to scan games:', error)
message.error('扫描失败,请重试。')
} finally {
setTimeout(() => { isScanning.value = false }, 500)
}
}
async function autoAddGamesFromLibraryScan(localGames: LocalScanGame[]) {
const existingGameKeys = new Set(
gameStore.gameLibrary.flatMap((game: Game) => {
const targets = Array.isArray(game.processName) ? game.processName : [game.processName]
return targets.map(name => `${String(game.name).toLowerCase()}|${String(name).toLowerCase()}`)
})
)
const seen = new Set<string>()
const candidates = localGames
.filter(g => !!g.name && !!g.processName)
.filter(g => {
const key = `${g.name.toLowerCase()}|${g.processName.toLowerCase()}`
if (seen.has(key)) return false
seen.add(key)
return !existingGameKeys.has(key)
})
let added = 0
for (const item of candidates) {
await gameStore.addGame({
name: item.name,
processName: [item.processName],
category: fallbackCategoryId,
status: 'idle',
latency: 0,
proxyMode: 'process',
chainProxy: true
})
added += 1
}
return added
}
return {
isScanning,
scanGames
}
}
+2 -2
View File
@@ -1,7 +1,7 @@
import { ref, computed, onMounted, onUnmounted } from 'vue'
import type { Theme, ThemeColor } from '@/types'
export type Theme = 'light' | 'dark' | 'auto'
export type ThemeColor = 'green' | 'blue' | 'purple' | 'orange' | 'red'
export type { Theme, ThemeColor }
const theme = ref<Theme>('auto')
const resolvedTheme = ref<'light' | 'dark'>('dark') // Default to dark
+11
View File
@@ -0,0 +1,11 @@
export const LOCAL_PROXY_HOSTS = ['www.google.com', 'www.apple.com']
export const LOCAL_PROXY_NODE_CHECK_RETRIES = 3
export const LOCAL_PROXY_NODE_CHECK_RETRY_DELAY_MS = 1200
export const LOCAL_PROXY_NODE_APPLY_WARMUP_MS = 800
export const LOCAL_PROXY_NODE_CHECK_TIMEOUT_MS = 5000
export const LOCAL_PROXY_RECHECK_INTERVAL_MS = 60000
export const GAME_CATEGORIES = ['fps', 'moba', 'rpg', 'other'] as const
export const DEFAULT_DNS_PRIMARY = 'https://dns.google/dns-query'
export const DEFAULT_DNS_SECONDARY = 'https://1.1.1.1/dns-query'
+54 -55
View File
@@ -6,7 +6,7 @@
<div class="text-xs font-bold text-on-surface-variant flex items-center gap-2">
<img src="/logo.svg" alt="log" class="h-6">
<span class="text-lg font-bold text-on-surface">{{ pkg.name }}</span>
<div @click="checkUpdate"
<div @click="() => checkUpdate()"
class="no-drag cursor-pointer flex items-center gap-1.5 px-2 py-0.5 rounded-md bg-surface-overlay border border-border/50 hover:border-primary/50 transition-colors translate-y-[1px]"
:title="$t('settings.check_update')">
<span class="text-[10px] font-mono text-on-surface-muted leading-none">v{{ appVersion }}</span>
@@ -116,66 +116,68 @@
</template>
<script setup lang="ts">
import { ref, watchEffect, onMounted, h } from 'vue'
import { ref, watchEffect, onMounted, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useWindowSize } from '@vueuse/core'
import { useNotification, NButton } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { useIntervalFn, useWindowSize } from '@vueuse/core'
import ThemeToggle from '@/components/common/ThemeToggle.vue'
import pkg from '../../package.json'
import { electronApi } from '@/api'
import { useAppUpdater } from '@/composables/useAppUpdater'
import { useGameStore } from '@/stores/games'
import { useNodeStore } from '@/stores/nodes'
import { useSettingsStore } from '@/stores/settings'
import { storeToRefs } from 'pinia'
const { width } = useWindowSize()
const isCollapsed = ref(true)
const $route = useRoute()
const router = useRouter()
const notification = useNotification()
const { t } = useI18n()
const gameStore = useGameStore()
const nodeStore = useNodeStore()
const settingsStore = useSettingsStore()
const { checkInterval } = storeToRefs(settingsStore)
const appVersion = ref('0.0.0')
const latestVersion = ref('')
const hasUpdate = ref(false)
async function checkUpdate() {
if (!window.app) return
try {
const res = await window.app.checkUpdate()
if (res && !res.error) {
latestVersion.value = res.version || ''
hasUpdate.value = res.updateAvailable
if (hasUpdate.value) {
const n = notification.info({
title: t('settings.update_available', { version: latestVersion.value }),
content: res.releaseNotes || t('settings.tagline'),
meta: res.releaseDate,
action: () =>
h(
NButton,
{
secondary: true,
type: 'primary',
size: 'small',
onClick: () => {
window.app.openUrl('https://github.com/ZenEcho/LagZero/releases')
n.destroy()
}
},
{ default: () => t('settings.download_update') }
)
})
}
}
} catch (e) {
console.error('Failed to check version:', e)
}
}
const { appVersion, updateInfo, checkUpdate, getVersion } = useAppUpdater()
const hasUpdate = computed(() => !!updateInfo.value?.available)
onMounted(async () => {
if (window.app) {
appVersion.value = await window.app.getVersion()
void checkUpdate()
}
await getVersion()
void checkUpdate()
void sampleLatencyForBackgroundPages()
resumeGlobalSampling()
})
async function sampleLatencyForBackgroundPages() {
// Dashboard already has its own live sampler.
if ($route.path === '/dashboard') return
const game = gameStore.getAcceleratingGame()
if (!game?.id || !game.nodeId) return
const node = nodeStore.nodes.find(n => n.id === game.nodeId || n.tag === game.nodeId)
if (!node) return
const startedAt = gameStore.getAccelerationStartedAt(game.id)
const accelerationSeconds = startedAt > 0 ? Math.floor((Date.now() - startedAt) / 1000) : 0
const sessionStats = await nodeStore.getGameLatencyStatsForSession(game.id)
const sessionLossRate = sessionStats.total > 0
? Math.round((sessionStats.lost / sessionStats.total) * 100)
: 0
const stats = await nodeStore.checkNode(node, undefined, {
recordLatency: true,
gameId: game.id,
accelerationSeconds,
sessionLossRate
})
if (!stats) return
gameStore.updateLatency(game.id, stats.latency)
}
const { resume: resumeGlobalSampling } = useIntervalFn(() => {
void sampleLatencyForBackgroundPages()
}, checkInterval, { immediate: false })
// 屏幕宽度小于 1024px 时自动收起侧边栏
watchEffect(() => {
if (width.value < 1024) {
@@ -208,12 +210,9 @@ function handleSidebarWheel(e: WheelEvent) {
}
}
// @ts-ignore
const minimize = () => window.electron.minimize()
// @ts-ignore
const maximize = () => window.electron.maximize()
// @ts-ignore
const close = () => window.electron.close()
const minimize = () => electronApi.minimize()
const maximize = () => electronApi.maximize()
const close = () => electronApi.close()
</script>
<style>
+23
View File
@@ -9,6 +9,7 @@
"stop": "Stop",
"start_acceleration": "Start Acceleration",
"stop_acceleration": "Stop Acceleration",
"checking": "Checking...",
"import": "Import",
"export": "Export",
"share": "Share",
@@ -20,6 +21,8 @@
"traffic": "Traffic",
"latency": "Real-time Latency",
"latency_short": "Latency",
"creation": "Creation",
"alphabetical": "Alphabetical",
"load": "Load",
"bandwidth": "Bandwidth",
"speed": "Speed",
@@ -169,6 +172,7 @@
"dns_config": "DNS Configuration",
"dns_mode_secure": "DoH (Proxy)",
"dns_mode_system": "System",
"dns_mode_system_hint": "System mode uses OS/ISP DNS and ignores the DoH addresses below; on polluted networks this may cause privacy/certificate warnings.",
"dns_primary": "Primary DNS",
"dns_secondary": "Secondary DNS",
"download_update": "Download Update Now",
@@ -209,5 +213,24 @@
"please_select_node": "Please select a node first",
"start_failed": "Failed to start acceleration",
"singbox_error": "sing-box error occurred"
},
"local_proxy": {
"name": "Local Proxy",
"status_disabled": "[LocalProxy] Disabled",
"status_no_nodes": "[LocalProxy] No nodes available",
"status_fixed_node": "[LocalProxy] Fixed node selected: {label}",
"status_checking": "[LocalProxy] {reason}: Checking nodes...",
"status_trying_remembered": "[LocalProxy] Trying remembered node: {label}",
"status_remembered_selected": "[LocalProxy] Remembered node selected: {label}",
"status_cached_selected": "[LocalProxy] Selected cached node: {label}",
"status_selected": "[LocalProxy] Selected node: {label}",
"status_no_available_recursive": "[LocalProxy] No available node after recursive test",
"status_stopped": "[LocalProxy] Stopped",
"status_recheck_ok": "[LocalProxy] Recheck OK: {label}",
"status_recheck_failed": "[LocalProxy] Recheck failed on {label}, switching node...",
"status_active_removed": "[LocalProxy] Active node removed, reselecting...",
"reason_startup": "Startup",
"reason_settings": "Settings",
"reason_recheck": "Auto Recheck"
}
}
+24 -1
View File
@@ -9,6 +9,7 @@
"stop": "停止",
"start_acceleration": "开始加速",
"stop_acceleration": "停止加速",
"checking": "正在检查...",
"import": "导入",
"export": "导出",
"share": "分享",
@@ -20,6 +21,8 @@
"traffic": "流量",
"latency": "实时延迟",
"latency_short": "延迟",
"creation": "创建时间",
"alphabetical": "字母排序",
"load": "负载",
"bandwidth": "带宽",
"speed": "速度",
@@ -106,7 +109,7 @@
"duration": "加速时长",
"configuration": "加速配置",
"mode": "加速模式",
"mode_process": "进程模式 (推荐)",
"mode_process": "进程模式",
"mode_process_desc": "仅加速游戏进程,不影响其他应用。",
"mode_process_subdesc": "仅加速指定的游戏进程",
"mode_routing": "路由模式",
@@ -170,6 +173,7 @@
"dns_config": "DNS 配置",
"dns_mode_secure": "DoH (代理)",
"dns_mode_system": "系统默认",
"dns_mode_system_hint": "系统默认模式会使用本机/运营商 DNS,下面的 DoH 地址不会生效;在受污染网络下可能出现“您的连接不是私密连接”。",
"dns_primary": "主 DNS",
"dns_secondary": "备用 DNS",
"download_update": "立即下载更新",
@@ -212,5 +216,24 @@
"please_select_node": "请先选择一个节点",
"start_failed": "启动加速失败",
"singbox_error": "sing-box 发生错误"
},
"local_proxy": {
"name": "本地混合代理",
"status_disabled": "[LocalProxy] 已禁用",
"status_no_nodes": "[LocalProxy] 无可用节点",
"status_fixed_node": "[LocalProxy] 已选择固定节点: {label}",
"status_checking": "[LocalProxy] {reason}: 正在检查节点...",
"status_trying_remembered": "[LocalProxy] 正在尝试记忆节点: {label}",
"status_remembered_selected": "[LocalProxy] 已选择记忆节点: {label}",
"status_cached_selected": "[LocalProxy] 已选择缓存节点: {label}",
"status_selected": "[LocalProxy] 已选择节点: {label}",
"status_no_available_recursive": "[LocalProxy] 递归测试后无可用节点",
"status_stopped": "[LocalProxy] 已停止",
"status_recheck_ok": "[LocalProxy] 重检正常: {label}",
"status_recheck_failed": "[LocalProxy] 重检失败于 {label},正在切换节点...",
"status_active_removed": "[LocalProxy] 当前激活节点被移除,正在重新选择...",
"reason_startup": "启动",
"reason_settings": "设置项目",
"reason_recheck": "自动重检"
}
}
+50 -3
View File
@@ -1,8 +1,7 @@
import { createApp } from 'vue'
import { createApp, watch } from 'vue'
import 'virtual:uno.css'
import '@unocss/reset/tailwind.css'
import './style.css'
import './styles/themes.css'
import './assets/main.css'
import App from './App.vue'
import router from './router'
import pinia from './stores'
@@ -10,6 +9,8 @@ import i18n from './i18n'
import { initLatencySessionStore } from './utils/latency-session'
import { setupRuntimeLogging } from './utils/runtime-logger'
import { useLocalProxyStore } from './stores/local-proxy'
import { useNodeStore } from './stores/nodes'
import { useSettingsStore } from './stores/settings'
setupRuntimeLogging()
@@ -23,8 +24,54 @@ app.use(pinia)
app.use(i18n)
const localProxyStore = useLocalProxyStore()
const nodeStore = useNodeStore()
const settingsStore = useSettingsStore()
let pendingStartAfterStarting = false
void localProxyStore.startLocalProxy('startup').catch((e) => {
console.error('Failed to start local proxy on startup:', e)
})
watch(
() => nodeStore.nodes.length,
(len) => {
if (len <= 0) {
pendingStartAfterStarting = false
if (localProxyStore.running || localProxyStore.starting) {
void localProxyStore.stopLocalProxy().catch((e) => {
console.error('Failed to stop local proxy after nodes became unavailable:', e)
})
}
return
}
if (!settingsStore.localProxyEnabled) return
if (localProxyStore.starting) {
pendingStartAfterStarting = true
return
}
void localProxyStore.handleNodeListChanged().catch((e) => {
console.error('Failed to handle node list change for local proxy:', e)
})
}
)
watch(
() => localProxyStore.starting,
(starting, prev) => {
if (!prev || starting) return
if (!pendingStartAfterStarting) return
pendingStartAfterStarting = false
if (!settingsStore.localProxyEnabled) return
if (localProxyStore.running) return
if (nodeStore.nodes.length <= 0) return
void localProxyStore.startLocalProxy('settings').catch((e) => {
console.error('Failed to resume local proxy auto-start after pending start:', e)
})
}
)
app.mount('#app')
+5 -8
View File
@@ -1,32 +1,29 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { Category } from '@/types'
import { categoryApi } from '@/api'
export const useCategoryStore = defineStore('categories', () => {
const categories = ref<Category[]>([])
async function loadCategories() {
try {
// @ts-ignore
categories.value = await window.categories.getAll()
categories.value = await categoryApi.getAll()
} catch (e) {
console.error('Failed to load categories:', e)
}
}
async function addCategory(category: Category) {
// @ts-ignore
categories.value = await window.categories.save(category)
categories.value = await categoryApi.save(category)
}
async function updateCategory(category: Category) {
// @ts-ignore
categories.value = await window.categories.save(category)
categories.value = await categoryApi.save(category)
}
async function removeCategory(id: string) {
// @ts-ignore
categories.value = await window.categories.delete(id)
categories.value = await categoryApi.delete(id)
}
loadCategories()
+122 -20
View File
@@ -5,9 +5,19 @@ import type { Game } from '@/types'
import { useNodeStore } from './nodes'
import { generateSingboxConfig } from '@/utils/singbox-config'
import { useSettingsStore } from './settings'
import { useLocalProxyStore } from './local-proxy'
import { gameApi, singboxApi, proxyMonitorApi } from '@/api'
export type { Game }
/**
* 判断预设游戏是否属于“仅路由模式(routing)”。
*
* 命中任一条件即返回 `true`:
* - `routingRules` 包含 `bypass_cn`
* - 名称包含 `global` 或“加速全部游戏”
* - 名称包含 `bypass cn` 或“加速海外游戏”
*/
export function isRoutingOnlyPresetGame(game: Pick<Game, 'name' | 'routingRules'> | null | undefined): boolean {
if (!game) return false
const name = String(game.name || '').toLowerCase()
@@ -19,29 +29,49 @@ export function isRoutingOnlyPresetGame(game: Pick<Game, 'name' | 'routingRules'
return false
}
/**
* 对预设游戏进行代理模式归一化:
* 命中“仅路由模式”时,强制 `proxyMode = 'routing'`。
*/
function normalizeProxyModeForPreset(game: Game): Game {
if (!isRoutingOnlyPresetGame(game)) return game
if (game.proxyMode === 'routing') return game
return { ...game, proxyMode: 'routing' }
}
/**
* 游戏仓库:
* - 管理游戏库与当前选中项
* - 管理运行态(加速状态、开始时间、延迟、运行进程匹配)
* - 与后端 API 同步游戏数据
* - 负责启动/停止 sing-box 与链式代理监控
*/
export const useGameStore = defineStore('games', () => {
// All games in local library
/** 本地游戏库(内存态)。 */
const gameLibrary = ref<Game[]>([])
// Current selected game ID (shared with dashboard)
/** 当前选中的游戏 ID(持久化到 localStorage)。 */
const currentGameId = useLocalStorage<string | null>('games-current-id', null)
/** 当前选中的游戏对象;未命中时返回 `null`。 */
const currentGame = computed(() =>
gameLibrary.value.find(g => g.id === currentGameId.value) || null
)
// Running game IDs matched by process names
/** 当前进程匹配到的“正在运行”游戏 ID 列表。 */
const runningGames = ref<string[]>([])
/** 每个游戏进入加速状态的时间戳(毫秒)。 */
const accelerationStartedAt = reactive<Record<string, number>>({})
/** 当前加速操作状态,避免重复点击导致并发启动/停止。 */
const operationState = ref<'idle' | 'starting' | 'stopping'>('idle')
/** 当前正在执行操作的游戏 ID。 */
const operationGameId = ref<string | null>(null)
/** 获取当前加速中的游戏(可排除某个 ID)。 */
function getAcceleratingGame(excludeId?: string): Game | null {
return gameLibrary.value.find(g => g.status === 'accelerating' && (!excludeId || g.id !== excludeId)) || null
}
/** 将所有游戏状态重置为空闲,并清理加速开始时间。 */
function resetAllAccelerationStatus() {
gameLibrary.value.forEach(g => {
if (g.status === 'accelerating') g.status = 'idle'
@@ -49,10 +79,16 @@ export const useGameStore = defineStore('games', () => {
})
}
/**
* 初始化游戏库:
* - 从后端加载数据
* - 补齐运行态默认字段
* - 归一化预设游戏代理模式
* - 修正当前选中 ID
*/
async function init() {
try {
// @ts-ignore
const games = await window.games.getAll()
const games = await gameApi.getAll()
gameLibrary.value = games.map((g: Game) => normalizeProxyModeForPreset({
...g,
status: 'idle',
@@ -72,13 +108,14 @@ export const useGameStore = defineStore('games', () => {
}
}
/** 新增游戏并同步本地状态。 */
async function addGame(game: Game) {
game = normalizeProxyModeForPreset(game)
// @ts-ignore
const updatedGames = await window.games.save(toIpcGame(game))
const updatedGames = await gameApi.save(toIpcGame(game))
syncGames(updatedGames)
}
/** 更新游戏(本地乐观更新后再与后端结果对齐)。 */
async function updateGame(game: Game) {
game = normalizeProxyModeForPreset(game)
// Optimistic local update
@@ -88,17 +125,16 @@ export const useGameStore = defineStore('games', () => {
}
try {
// @ts-ignore
const updatedGames = await window.games.save(toIpcGame(game))
const updatedGames = await gameApi.save(toIpcGame(game))
syncGames(updatedGames)
} catch (e) {
console.error('Failed to update game:', e)
}
}
/** 删除游戏并维护当前选中项回退逻辑。 */
async function removeGame(id: string) {
// @ts-ignore
const updatedGames = await window.games.delete(id)
const updatedGames = await gameApi.delete(id)
syncGames(updatedGames)
if (currentGameId.value === id) {
const firstGame = gameLibrary.value[0]
@@ -106,6 +142,11 @@ export const useGameStore = defineStore('games', () => {
}
}
/**
* 用远端列表同步本地游戏库,同时保留本地运行态:
* - `status` / `latency` 尽量复用本地已有值
* - `nodeId` 远端优先,缺失时回退本地值
*/
function syncGames(remoteGames: Game[]) {
gameLibrary.value = remoteGames.map((rg: Game) => {
const existing = gameLibrary.value.find(lg => lg.id === rg.id)
@@ -118,6 +159,10 @@ export const useGameStore = defineStore('games', () => {
})
}
/**
* 切换当前游戏。
* 若已有其他游戏处于加速中,则拒绝切换并返回 `false`。
*/
function setCurrentGame(id: string) {
const runningOther = getAcceleratingGame(id)
if (runningOther) return false
@@ -126,12 +171,14 @@ export const useGameStore = defineStore('games', () => {
const game = gameLibrary.value.find(g => g.id === id)
if (game) {
game.lastPlayed = Date.now()
// @ts-ignore
window.games.save(toIpcGame({ ...game, lastPlayed: Date.now() }))
gameApi.save(toIpcGame({ ...game, lastPlayed: Date.now() }))
}
return true
}
/**
* 设置游戏加速状态,并保证同一时刻仅一个游戏处于 `accelerating`。
*/
function setGameStatus(id: string, status: 'idle' | 'accelerating') {
const game = gameLibrary.value.find(g => g.id === id)
if (game) {
@@ -152,10 +199,12 @@ export const useGameStore = defineStore('games', () => {
}
}
/** 获取游戏进入加速状态的时间戳;无记录时返回 `0`。 */
function getAccelerationStartedAt(id: string) {
return accelerationStartedAt[id] || 0
}
/** 更新游戏延迟(毫秒)。 */
function updateLatency(id: string, ms: number) {
const game = gameLibrary.value.find(g => g.id === id)
if (game) {
@@ -163,6 +212,7 @@ export const useGameStore = defineStore('games', () => {
}
}
/** 通过进程名匹配正在运行的游戏,返回命中 ID 列表。 */
function matchRunningGames(processNames: string[]) {
const matchedIds: string[] = []
processNames.forEach(pName => {
@@ -181,7 +231,17 @@ export const useGameStore = defineStore('games', () => {
return matchedIds
}
/**
* 启动游戏加速:
* - 校验互斥状态与节点配置
* - 生成并重启 sing-box 配置
* - 按模式启动/停止进程链式代理监控
*/
async function startGame(id: string) {
if (operationState.value !== 'idle') {
throw new Error(operationState.value === 'starting' ? '正在启动中,请稍候。' : '正在暂停中,请稍候。')
}
const game = gameLibrary.value.find(g => g.id === id)
if (!game) return
if (game.status === 'accelerating') return
@@ -193,6 +253,7 @@ export const useGameStore = defineStore('games', () => {
const nodeStore = useNodeStore()
const settingsStore = useSettingsStore()
const localProxyStore = useLocalProxyStore()
const selectedNodeId = String(game.nodeId || '')
const node = nodeStore.nodes.find(n => n.id === selectedNodeId || n.tag === selectedNodeId)
@@ -202,6 +263,12 @@ export const useGameStore = defineStore('games', () => {
throw new Error(msg)
}
const localProxyNode = settingsStore.localProxyEnabled
? nodeStore.nodes.find(n => String(n.id || n.tag) === localProxyStore.activeNodeKey) || undefined
: undefined
operationState.value = 'starting'
operationGameId.value = id
try {
const rawGame = toRaw(game) as Game
const rawNode = toRaw(node) as any
@@ -210,39 +277,64 @@ export const useGameStore = defineStore('games', () => {
primary: settingsStore.dnsPrimary,
secondary: settingsStore.dnsSecondary,
tunInterfaceName: settingsStore.tunInterfaceName,
localProxyNode,
localProxy: {
enabled: settingsStore.localProxyEnabled,
port: settingsStore.localProxyPort
}
}))
// @ts-ignore
await window.singbox.restart(config)
await singboxApi.restart(config)
const procs = Array.isArray(rawGame.processName) ? rawGame.processName.map(p => String(p)) : [String(rawGame.processName)]
const shouldEnableChainProxy = rawGame.proxyMode === 'process' && rawGame.chainProxy !== false
// @ts-ignore
if (shouldEnableChainProxy) await window.proxyMonitor.start(String(rawGame.id), procs)
else await window.proxyMonitor.stop()
if (shouldEnableChainProxy) await proxyMonitorApi.start(String(rawGame.id), procs)
else await proxyMonitorApi.stop()
setGameStatus(id, 'accelerating')
} catch (e) {
console.error('Failed to start game acceleration:', e)
throw e
} finally {
operationState.value = 'idle'
operationGameId.value = null
}
}
/**
* 停止游戏加速:
* - 停止进程监控
* - 恢复本地代理或停止 sing-box
* - 重置全部加速状态
*/
async function stopGame(_id: string) {
if (operationState.value !== 'idle') {
throw new Error(operationState.value === 'starting' ? '正在启动中,请稍候。' : '正在暂停中,请稍候。')
}
operationState.value = 'stopping'
operationGameId.value = _id
try {
// @ts-ignore
await window.proxyMonitor.stop()
await proxyMonitorApi.stop()
const settingsStore = useSettingsStore()
const localProxyStore = useLocalProxyStore()
if (settingsStore.localProxyEnabled) {
await localProxyStore.startLocalProxy('settings')
} else {
await singboxApi.stop()
}
resetAllAccelerationStatus()
} catch (e) {
console.error('Failed to stop game acceleration:', e)
throw e
} finally {
operationState.value = 'idle'
operationGameId.value = null
}
}
/** 仓库创建后立即执行一次初始化。 */
init()
return {
@@ -250,6 +342,8 @@ export const useGameStore = defineStore('games', () => {
currentGameId,
currentGame,
runningGames,
operationState,
operationGameId,
init,
setCurrentGame,
setGameStatus,
@@ -266,6 +360,14 @@ export const useGameStore = defineStore('games', () => {
}
})
/**
* 将响应式 `Game` 对象转换为适合 IPC 持久化的纯对象。
*
* 处理要点:
* - 去除响应式包装(`toRaw`)
* - 统一字段类型(字符串/布尔/数组)
* - 提供安全默认值,避免后端落库时出现脏数据
*/
function toIpcGame(game: Game): Game {
const raw = toRaw(game) as any
+400 -30
View File
@@ -1,21 +1,40 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { useLocalStorage } from '@vueuse/core'
import type { Game } from '@/types'
import type { NodeConfig } from '@/utils/protocol'
import type { NodeConfig } from '@/types'
import { generateSingboxConfig } from '@/utils/singbox-config'
import { useNodeStore } from './nodes'
import { useSettingsStore } from './settings'
const LOCAL_PROXY_HOSTS = ['www.google.com', 'www.apple.com']
import { systemApi, singboxApi, proxyMonitorApi } from '@/api'
import i18n from '@/i18n'
import {
DEFAULT_DNS_PRIMARY,
DEFAULT_DNS_SECONDARY,
LOCAL_PROXY_HOSTS,
LOCAL_PROXY_NODE_CHECK_RETRIES,
LOCAL_PROXY_NODE_CHECK_RETRY_DELAY_MS,
LOCAL_PROXY_NODE_APPLY_WARMUP_MS,
LOCAL_PROXY_NODE_CHECK_TIMEOUT_MS,
LOCAL_PROXY_RECHECK_INTERVAL_MS
} from '@/constants'
function nodeKeyOf(node: NodeConfig): string {
return String(node.id || node.tag || '')
}
function nodeLabelOf(node: NodeConfig, index?: number): string {
const order = typeof index === 'number' ? `#${index + 1}` : ''
const tag = String(node.tag || '').trim()
const server = String(node.server || '').trim()
return [order, tag, server].filter(Boolean).join(' ')
}
function buildLocalProxyGame(): Game {
const { t } = i18n.global
return {
id: '__local_proxy__',
name: 'Local Proxy',
name: t('local_proxy.name'),
processName: [],
category: 'system',
proxyMode: 'routing',
@@ -28,36 +47,156 @@ export const useLocalProxyStore = defineStore('local-proxy', () => {
const running = ref(false)
const starting = ref(false)
const activeNodeKey = ref('')
const currentPort = ref(0)
const statusText = ref('')
const statusLevel = ref<'info' | 'success' | 'warning' | 'error'>('info')
const testingNodeLabel = ref('')
const testingCurrent = ref(0)
const testingTotal = ref(0)
const rememberedNodeKey = useLocalStorage('settings-local-proxy-last-node-key', '')
const rememberedNodesFingerprint = useLocalStorage('settings-local-proxy-last-nodes-fingerprint', '')
const nodeCheckCache = useLocalStorage<Record<string, { ok: boolean, detail: string, checkedAt: number }>>(
'settings-local-proxy-node-check-cache',
{}
)
let recheckTimer: ReturnType<typeof setInterval> | null = null
async function verifyHostsViaLocalHttpProxy(httpPort: number): Promise<boolean> {
for (const host of LOCAL_PROXY_HOSTS) {
const res = await window.system.testHttpProxyConnect(httpPort, host, 443, 5000)
if (!res?.ok) return false
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
function setStatus(
text: string,
level: 'info' | 'success' | 'warning' | 'error' = 'info'
) {
statusText.value = text
statusLevel.value = level
}
function resetTestingProgress() {
testingNodeLabel.value = ''
testingCurrent.value = 0
testingTotal.value = 0
}
function buildNodesFingerprint(nodes: NodeConfig[]): string {
return nodes
.map((node) => `${nodeKeyOf(node)}|${node.server}|${node.server_port}|${node.type}`)
.join('||')
}
function buildNodeFingerprint(node: NodeConfig): string {
const tls = node.tls
const utls = tls?.utls
const reality = tls?.reality
return [
nodeKeyOf(node),
node.type,
node.tag,
node.server,
node.server_port,
node.uuid,
node.password,
node.method,
node.plugin,
node.plugin_opts,
node.network,
node.security,
node.path,
node.host,
node.service_name,
node.alpn,
node.fingerprint,
tls?.enabled,
tls?.server_name,
tls?.insecure,
utls?.enabled,
utls?.fingerprint,
reality?.enabled,
reality?.public_key,
reality?.short_id,
node.flow,
node.packet_encoding,
node.username
].map((v) => String(v ?? '')).join('|')
}
function getCachedNodeCheck(node: NodeConfig) {
const fp = buildNodeFingerprint(node)
return { fingerprint: fp, result: nodeCheckCache.value[fp] }
}
function setCachedNodeCheck(node: NodeConfig, ok: boolean, detail: string) {
const fp = buildNodeFingerprint(node)
nodeCheckCache.value = {
...nodeCheckCache.value,
[fp]: {
ok,
detail,
checkedAt: Date.now()
}
}
return true
}
function rememberSelectedNode(node: NodeConfig, nodesFingerprint: string) {
rememberedNodeKey.value = nodeKeyOf(node)
rememberedNodesFingerprint.value = nodesFingerprint
}
async function verifyHostsViaLocalHttpProxy(httpPort: number): Promise<{ ok: boolean, detail: string }> {
const details: string[] = []
for (const host of LOCAL_PROXY_HOSTS) {
const res = await systemApi.testHttpProxyConnect(httpPort, host, 443, LOCAL_PROXY_NODE_CHECK_TIMEOUT_MS)
if (!res?.ok) {
details.push(`${host}=fail(${res?.error || res?.statusLine || 'unknown'})`)
return { ok: false, detail: details.join('; ') }
}
details.push(`${host}=ok`)
}
return { ok: true, detail: details.join('; ') }
}
async function verifyHostsWithRetry(httpPort: number): Promise<{ ok: boolean, detail: string }> {
let last = { ok: false, detail: 'not-tested' }
for (let i = 1; i <= LOCAL_PROXY_NODE_CHECK_RETRIES; i += 1) {
last = await verifyHostsViaLocalHttpProxy(httpPort)
if (last.ok) return last
if (i < LOCAL_PROXY_NODE_CHECK_RETRIES) {
await sleep(LOCAL_PROXY_NODE_CHECK_RETRY_DELAY_MS)
}
}
return last
}
async function ensurePortAvailable(preferredPort: number): Promise<number> {
const port = await window.system.findAvailablePort(preferredPort, 2)
if (running.value && currentPort.value > 0 && preferredPort === currentPort.value) {
return preferredPort
}
const port = await systemApi.findAvailablePort(preferredPort, 2)
return port
}
async function applyNodeConfig(node: NodeConfig): Promise<void> {
const settings = useSettingsStore()
const localGame = buildLocalProxyGame()
const primary = String(settings.dnsPrimary || '').trim()
const secondary = String(settings.dnsSecondary || '').trim()
const config = generateSingboxConfig(localGame, node, {
mode: settings.dnsMode,
primary: settings.dnsPrimary,
secondary: settings.dnsSecondary,
// Local browser/system proxy should always use secure DNS to avoid DNS pollution
// that can surface as certificate/privacy warnings across different node types.
mode: 'secure',
primary: primary.startsWith('https://') ? primary : DEFAULT_DNS_PRIMARY,
secondary: secondary.startsWith('https://') ? secondary : DEFAULT_DNS_SECONDARY,
tunInterfaceName: settings.tunInterfaceName,
disableTun: true,
localProxyStrictNode: true,
localProxy: {
enabled: true,
port: settings.localProxyPort
}
})
await window.proxyMonitor.stop()
await window.singbox.restart(config)
await proxyMonitorApi.stop()
await singboxApi.restart(config)
}
function resolveFixedNode(nodes: NodeConfig[], fixedIndex: number): NodeConfig | null {
@@ -66,15 +205,48 @@ export const useLocalProxyStore = defineStore('local-proxy', () => {
return nodes[idx] || nodes[0] || null
}
async function startLocalProxy(reason: 'startup' | 'settings' = 'startup'): Promise<boolean> {
function stopRecheckLoop() {
if (!recheckTimer) return
clearInterval(recheckTimer)
recheckTimer = null
}
function syncRecheckLoop() {
const settings = useSettingsStore()
const shouldEnable = settings.localProxyEnabled && settings.localProxyNodeRecursiveTest
if (!shouldEnable) {
stopRecheckLoop()
return
}
if (recheckTimer) return
recheckTimer = setInterval(() => {
void recheckLocalProxyHealth().catch((e) => {
console.error('[LocalProxy] periodic recheck failed:', e)
})
}, LOCAL_PROXY_RECHECK_INTERVAL_MS)
console.info(`[LocalProxy] periodic recheck enabled (${Math.floor(LOCAL_PROXY_RECHECK_INTERVAL_MS / 1000)}s)`)
}
function findActiveNode(nodes: NodeConfig[]): NodeConfig | null {
const active = activeNodeKey.value
if (!active) return null
return nodes.find((n) => nodeKeyOf(n) === active) || null
}
async function startLocalProxy(reason: 'startup' | 'settings' | 'recheck' = 'startup'): Promise<boolean> {
if (starting.value) return false
const { t } = i18n.global
const settings = useSettingsStore()
const nodeStore = useNodeStore()
syncRecheckLoop()
if (!settings.localProxyEnabled) {
running.value = false
activeNodeKey.value = ''
stopRecheckLoop()
setStatus(t('local_proxy.status_disabled'), 'info')
resetTestingProgress()
return false
}
@@ -87,8 +259,12 @@ export const useLocalProxyStore = defineStore('local-proxy', () => {
if (nodes.length === 0) {
running.value = false
activeNodeKey.value = ''
console.warn('[LocalProxy] startup skipped: no nodes available')
setStatus(t('local_proxy.status_no_nodes'), 'warning')
resetTestingProgress()
return false
}
const nodesFingerprint = buildNodesFingerprint(nodes)
const availablePort = await ensurePortAvailable(settings.localProxyPort)
settings.localProxyPort = availablePort
@@ -96,29 +272,145 @@ export const useLocalProxyStore = defineStore('local-proxy', () => {
if (!settings.localProxyNodeRecursiveTest) {
const fixed = resolveFixedNode(nodes, settings.localProxyFixedNodeIndex)
if (!fixed) return false
console.info(`[LocalProxy] fixed node mode: ${nodeLabelOf(fixed)}`)
await applyNodeConfig(fixed)
running.value = true
activeNodeKey.value = nodeKeyOf(fixed)
currentPort.value = settings.localProxyPort
setStatus(t('local_proxy.status_fixed_node', { label: nodeLabelOf(fixed) }), 'success')
resetTestingProgress()
return true
}
for (const node of nodes) {
try {
await applyNodeConfig(node)
const ok = await verifyHostsViaLocalHttpProxy(settings.localProxyPort)
if (!ok) continue
running.value = true
activeNodeKey.value = nodeKeyOf(node)
return true
} catch {
// Try next node.
const reasonLabel = t(`local_proxy.reason_${reason}`)
console.info(`[LocalProxy] ${reason}: recursive node check started (${nodes.length} node(s))`)
setStatus(t('local_proxy.status_checking', { reason: reasonLabel }), 'info')
testingTotal.value = nodes.length
const failures: string[] = []
if (rememberedNodesFingerprint.value === nodesFingerprint && rememberedNodeKey.value) {
const remembered = nodes.find((n) => nodeKeyOf(n) === rememberedNodeKey.value)
if (remembered) {
const rememberedLabel = nodeLabelOf(remembered)
testingCurrent.value = 1
testingNodeLabel.value = rememberedLabel
console.info(`[LocalProxy] trying remembered node ${rememberedLabel}`)
setStatus(t('local_proxy.status_trying_remembered', { label: rememberedLabel }), 'info')
try {
const { result: rememberedCached } = getCachedNodeCheck(remembered)
let rememberedCheck = rememberedCached || { ok: false, detail: 'not-tested', checkedAt: 0 }
if (rememberedCached) {
console.info(`[LocalProxy] remembered node cache hit ${rememberedLabel} -> ${rememberedCached.ok ? 'ok' : 'fail'}`)
if (!rememberedCached.ok) {
const fail = `[LocalProxy] remembered node skipped by cache ${rememberedLabel} -> ${rememberedCached.detail}`
failures.push(fail)
console.warn(fail)
rememberedCheck = rememberedCached
}
}
if (!rememberedCached || rememberedCached.ok) {
await applyNodeConfig(remembered)
}
if (!rememberedCached && rememberedCheck.ok === false) {
await sleep(LOCAL_PROXY_NODE_APPLY_WARMUP_MS)
const checkResult = await verifyHostsWithRetry(settings.localProxyPort)
rememberedCheck = { ...checkResult, checkedAt: Date.now() }
setCachedNodeCheck(remembered, rememberedCheck.ok, rememberedCheck.detail)
}
if (rememberedCheck.ok) {
running.value = true
activeNodeKey.value = nodeKeyOf(remembered)
currentPort.value = settings.localProxyPort
rememberSelectedNode(remembered, nodesFingerprint)
setStatus(t('local_proxy.status_remembered_selected', { label: rememberedLabel }), 'success')
resetTestingProgress()
console.info(`[LocalProxy] remembered node selected ${rememberedLabel} (${rememberedCheck.detail})`)
return true
}
const fail = `[LocalProxy] remembered node unavailable ${rememberedLabel} -> ${rememberedCheck.detail}`
failures.push(fail)
console.warn(fail)
} catch (e: any) {
const fail = `[LocalProxy] remembered node error ${rememberedLabel} -> ${String(e?.message || e)}`
failures.push(fail)
console.warn(fail)
}
}
}
await window.proxyMonitor.stop()
await window.singbox.stop()
for (let i = 0; i < nodes.length; i += 1) {
const node = nodes[i]
if (!node) continue
const label = nodeLabelOf(node, i)
if (nodeKeyOf(node) === rememberedNodeKey.value && rememberedNodesFingerprint.value === nodesFingerprint) continue
testingCurrent.value = i + 1
testingNodeLabel.value = label
try {
const { result: cached } = getCachedNodeCheck(node)
if (cached) {
console.info(`[LocalProxy] cache hit ${label} -> ${cached.ok ? 'ok' : 'fail'}`)
if (!cached.ok) {
const detail = `[LocalProxy] node skipped by cache ${label} -> ${cached.detail}`
failures.push(detail)
console.warn(detail)
continue
}
}
console.info(`[LocalProxy] testing node ${label}`)
setStatus(t('local_proxy.status_checking', { reason: `${i + 1}/${nodes.length} ${label}` }), 'info')
await applyNodeConfig(node)
if (cached) {
running.value = true
activeNodeKey.value = nodeKeyOf(node)
currentPort.value = settings.localProxyPort
rememberSelectedNode(node, nodesFingerprint)
setStatus(t('local_proxy.status_cached_selected', { label }), 'success')
resetTestingProgress()
console.info(`[LocalProxy] selected cached node ${label} (${cached.detail})`)
return true
}
await sleep(LOCAL_PROXY_NODE_APPLY_WARMUP_MS)
const check = await verifyHostsWithRetry(settings.localProxyPort)
setCachedNodeCheck(node, check.ok, check.detail)
if (!check.ok) {
const detail = `[LocalProxy] node unavailable ${label} -> ${check.detail}`
failures.push(detail)
console.warn(detail)
continue
}
running.value = true
activeNodeKey.value = nodeKeyOf(node)
currentPort.value = settings.localProxyPort
rememberSelectedNode(node, nodesFingerprint)
setStatus(t('local_proxy.status_selected', { label }), 'success')
resetTestingProgress()
console.info(`[LocalProxy] selected node ${label} (${check.detail})`)
return true
} catch (e: any) {
const detail = `[LocalProxy] node test error ${label} -> ${String(e?.message || e)}`
setCachedNodeCheck(node, false, String(e?.message || e || 'node-test-error'))
failures.push(detail)
console.warn(detail)
}
}
failures.forEach((line) => console.warn(line))
console.error('[LocalProxy] no available node after recursive test; local proxy will be stopped')
setStatus(t('local_proxy.status_no_available_recursive'), 'error')
resetTestingProgress()
await proxyMonitorApi.stop()
await singboxApi.stop()
running.value = false
activeNodeKey.value = ''
currentPort.value = 0
return false
} finally {
starting.value = false
@@ -126,14 +418,85 @@ export const useLocalProxyStore = defineStore('local-proxy', () => {
}
async function stopLocalProxy(): Promise<void> {
await window.proxyMonitor.stop()
await window.singbox.stop()
const { t } = i18n.global
stopRecheckLoop()
await proxyMonitorApi.stop()
await singboxApi.stop()
running.value = false
activeNodeKey.value = ''
currentPort.value = 0
setStatus(t('local_proxy.status_stopped'), 'info')
resetTestingProgress()
console.info('[LocalProxy] stopped')
}
async function recheckLocalProxyHealth(): Promise<void> {
const { t } = i18n.global
const settings = useSettingsStore()
const nodeStore = useNodeStore()
if (!settings.localProxyEnabled || !settings.localProxyNodeRecursiveTest) return
if (starting.value) return
if (nodeStore.nodes.length === 0) {
await nodeStore.loadNodes()
}
const nodes = nodeStore.nodes.slice()
if (nodes.length === 0) return
if (!running.value) {
console.info('[LocalProxy] periodic recheck: proxy not running, trying to start')
await startLocalProxy('recheck')
return
}
const active = findActiveNode(nodes)
const activeLabel = active ? nodeLabelOf(active) : '(unknown)'
const check = await verifyHostsViaLocalHttpProxy(settings.localProxyPort)
if (check.ok) {
setStatus(t('local_proxy.status_recheck_ok', { label: activeLabel }), 'success')
console.info(`[LocalProxy] periodic recheck ok: ${activeLabel}`)
return
}
console.warn(`[LocalProxy] periodic recheck failed on active node ${activeLabel}: ${check.detail}`)
setStatus(t('local_proxy.status_recheck_failed', { label: activeLabel }), 'warning')
await startLocalProxy('recheck')
}
async function handleNodeListChanged(): Promise<void> {
const { t } = i18n.global
const settings = useSettingsStore()
const nodeStore = useNodeStore()
if (!settings.localProxyEnabled) return
if (starting.value) return
const nodes = nodeStore.nodes.slice()
if (nodes.length === 0) {
console.error('[LocalProxy] node list changed: no nodes available, stopping local proxy')
setStatus(t('local_proxy.status_no_nodes'), 'error')
await stopLocalProxy()
return
}
if (!running.value) {
console.info('[LocalProxy] node list changed: proxy not running, starting...')
await startLocalProxy('settings')
return
}
const active = findActiveNode(nodes)
if (active) return
console.warn('[LocalProxy] active node was removed/changed, reselecting available node')
setStatus(t('local_proxy.status_active_removed'), 'warning')
await startLocalProxy('settings')
}
async function applySettingsChange(): Promise<void> {
const settings = useSettingsStore()
syncRecheckLoop()
if (!settings.localProxyEnabled) {
await stopLocalProxy()
return
@@ -150,8 +513,15 @@ export const useLocalProxyStore = defineStore('local-proxy', () => {
running,
starting,
activeNodeKey,
statusText,
statusLevel,
testingNodeLabel,
testingCurrent,
testingTotal,
startLocalProxy,
stopLocalProxy,
recheckLocalProxyHealth,
handleNodeListChanged,
applySettingsChange,
setActiveNode
}
+46 -27
View File
@@ -1,8 +1,10 @@
import { defineStore } from 'pinia'
import { ref, reactive } from 'vue'
import { parseShareLink, parseBatchLinks, normalizeNodeType } from '@/utils/protocol'
import type { NodeConfig } from '@/utils/protocol'
import type { NodeConfig, CheckMethod, CheckRecordContext } from '@/types'
import { useSettingsStore } from './settings'
import { useGameStore } from './games'
import { nodeApi, systemApi } from '@/api'
import {
appendLatencyRecord,
getGameLatencyStats,
@@ -10,14 +12,6 @@ import {
initLatencySessionStore
} from '@/utils/latency-session'
type CheckMethod = 'ping' | 'tcp' | 'http'
interface CheckRecordContext {
recordLatency?: boolean
gameId?: string
accelerationSeconds?: number
sessionLossRate?: number
}
export const useNodeStore = defineStore('nodes', () => {
const nodes = ref<NodeConfig[]>([])
const nodeStats = reactive<Record<string, { latency: number; loss: number }>>({})
@@ -35,6 +29,16 @@ export const useNodeStore = defineStore('nodes', () => {
}
}
function resolveLatency(value: unknown, source: 'ping' | 'tcpPing'): number {
if (typeof value === 'number') return value
if (value && typeof value === 'object' && 'latency' in (value as Record<string, unknown>)) {
const latency = (value as { latency?: unknown }).latency
if (typeof latency === 'number') return latency
}
console.warn(`[NodeStore] Unexpected ${source} result payload:`, value)
return -1
}
async function checkNode(node: NodeConfig, methodOverride?: CheckMethod, context?: CheckRecordContext) {
if (!node.server) return
@@ -43,20 +47,20 @@ export const useNodeStore = defineStore('nodes', () => {
try {
if (method === 'tcp') {
// @ts-ignore
if (window.system?.tcpPing) {
// @ts-ignore
result = await window.system.tcpPing(node.server, node.server_port)
if (window.system) {
const response = await systemApi.tcpPing(node.server, node.server_port)
const latency = resolveLatency(response, 'tcpPing')
result = { latency, loss: latency === -1 ? 100 : 0 }
} else {
// Mock if not available
result = await mockPing()
}
} else {
// Ping
// @ts-ignore
if (window.system?.ping) {
// @ts-ignore
result = await window.system.ping(node.server)
if (window.system) {
const response = await systemApi.ping(node.server)
const latency = resolveLatency(response, 'ping')
result = { latency, loss: latency === -1 ? 100 : 0 }
} else {
// Mock
result = await mockPing()
@@ -131,8 +135,7 @@ export const useNodeStore = defineStore('nodes', () => {
async function loadNodes() {
try {
// @ts-ignore
const loaded = await window.nodes.getAll()
const loaded = await nodeApi.getAll()
nodes.value = (loaded as NodeConfig[]).map(normalizeNode)
} catch (e) {
console.error('Failed to load nodes:', e)
@@ -144,8 +147,7 @@ export const useNodeStore = defineStore('nodes', () => {
if (node) {
try {
const normalized = normalizeNode(node)
// @ts-ignore
const saved = await window.nodes.save(normalized)
const saved = await nodeApi.save(normalized)
nodes.value = (saved as NodeConfig[]).map(normalizeNode)
return true
} catch (e) {
@@ -159,8 +161,7 @@ export const useNodeStore = defineStore('nodes', () => {
async function saveNode(node: NodeConfig) {
try {
const normalized = normalizeNode(node)
// @ts-ignore
const saved = await window.nodes.save(normalized)
const saved = await nodeApi.save(normalized)
nodes.value = (saved as NodeConfig[]).map(normalizeNode)
return true
} catch (e) {
@@ -174,8 +175,7 @@ export const useNodeStore = defineStore('nodes', () => {
if (newNodes.length === 0) return 0
try {
// @ts-ignore
const imported = await window.nodes.import(newNodes)
const imported = await nodeApi.import(newNodes)
nodes.value = (imported as NodeConfig[]).map(normalizeNode)
// The import method on backend handles duplicates and returns all nodes
// We can't easily count how many were added unless backend returns that info
@@ -189,9 +189,28 @@ export const useNodeStore = defineStore('nodes', () => {
async function removeNode(id: string) {
try {
// @ts-ignore
const remain = await window.nodes.delete(id)
const removed = nodes.value.find((n) => n.id === id)
const remain = await nodeApi.delete(id)
nodes.value = (remain as NodeConfig[]).map(normalizeNode)
// If the accelerating game is bound to the removed node, stop acceleration immediately.
const removedKeys = new Set<string>([
String(id || '').trim(),
String(removed?.tag || '').trim()
].filter(Boolean))
if (removedKeys.size === 0) return
const gameStore = useGameStore()
const accelerating = gameStore.getAcceleratingGame()
if (!accelerating?.id) return
const selectedNodeId = String(accelerating.nodeId || '').trim()
if (!selectedNodeId || !removedKeys.has(selectedNodeId)) return
const stillExists = nodes.value.some((n) => n.id === selectedNodeId || n.tag === selectedNodeId)
if (stillExists) return
await gameStore.stopGame(accelerating.id)
} catch (e) {
console.error('Failed to delete node:', e)
}
+4 -6
View File
@@ -1,18 +1,16 @@
import { defineStore } from 'pinia'
import { useLocalStorage } from '@vueuse/core'
export type CheckMethod = 'ping' | 'tcp' | 'http'
export type DnsMode = 'secure' | 'system'
import { DEFAULT_DNS_PRIMARY, DEFAULT_DNS_SECONDARY } from '@/constants'
import type { CheckMethod, DnsMode } from '@/types'
export const useSettingsStore = defineStore('settings', () => {
const checkInterval = useLocalStorage('settings-check-interval', 5000)
const checkMethod = useLocalStorage<CheckMethod>('settings-check-method', 'ping')
const checkUrl = useLocalStorage('settings-check-url', 'http://www.gstatic.com/generate_204')
const dnsMode = useLocalStorage<DnsMode>('settings-dns-mode', 'secure')
const dnsPrimary = useLocalStorage('settings-dns-primary', 'https://dns.google/dns-query')
const dnsSecondary = useLocalStorage('settings-dns-secondary', 'https://1.1.1.1/dns-query')
const dnsPrimary = useLocalStorage('settings-dns-primary', DEFAULT_DNS_PRIMARY)
const dnsSecondary = useLocalStorage('settings-dns-secondary', DEFAULT_DNS_SECONDARY)
const localProxyEnabled = useLocalStorage('settings-local-proxy-enabled', true)
const localProxyPort = useLocalStorage('settings-local-proxy-port', 10860)
const localProxyNodeRecursiveTest = useLocalStorage('settings-local-proxy-node-recursive-test', true)
-159
View File
@@ -1,159 +0,0 @@
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
background-color: var(--color-background);
color: var(--color-on-surface);
}
#app {
width: 100%;
height: 100vh;
overflow: hidden;
}
/* Scrollbar customization */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--color-border);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--color-secondary);
}
/*
Fix for Naive UI Primary Button transparency issue in Light Mode
caused by Tailwind Preflight or style conflicts.
*/
/* Green Theme (Default) */
html.theme-green .n-button.n-button--primary-type,
html:not([class*="theme-"]) .n-button.n-button--primary-type {
background-color: #3eaf7c !important;
color: #ffffff !important;
border: 1px solid #3eaf7c !important;
}
html.theme-green .n-button.n-button--primary-type:hover,
html:not([class*="theme-"]) .n-button.n-button--primary-type:hover {
background-color: #10b981 !important;
border-color: #10b981 !important;
}
/* Blue Theme */
html.theme-blue .n-button.n-button--primary-type {
background-color: #3b82f6 !important;
color: #ffffff !important;
border: 1px solid #3b82f6 !important;
}
html.theme-blue .n-button.n-button--primary-type:hover {
background-color: #2563eb !important;
border-color: #2563eb !important;
}
/* Purple Theme */
html.theme-purple .n-button.n-button--primary-type {
background-color: #8b5cf6 !important;
color: #ffffff !important;
border: 1px solid #8b5cf6 !important;
}
html.theme-purple .n-button.n-button--primary-type:hover {
background-color: #7c3aed !important;
border-color: #7c3aed !important;
}
/* Orange Theme */
html.theme-orange .n-button.n-button--primary-type {
background-color: #f97316 !important;
color: #ffffff !important;
border: 1px solid #f97316 !important;
}
html.theme-orange .n-button.n-button--primary-type:hover {
background-color: #ea580c !important;
border-color: #ea580c !important;
}
/* Red Theme */
html.theme-red .n-button.n-button--primary-type {
background-color: #ef4444 !important;
color: #ffffff !important;
border: 1px solid #ef4444 !important;
}
html.theme-red .n-button.n-button--primary-type:hover {
background-color: #dc2626 !important;
border-color: #dc2626 !important;
}
/* Fix for Error and Warning buttons transparency issue */
.n-button.n-button--error-type:not(.n-button--quaternary):not(
.n-button--tertiary
):not(.n-button--secondary):not(.n-button--ghost) {
background-color: rgb(var(--rgb-error)) !important;
color: #ffffff !important;
border: 1px solid rgb(var(--rgb-error)) !important;
}
.n-button.n-button--error-type:not(.n-button--quaternary):not(
.n-button--tertiary
):not(.n-button--secondary):not(.n-button--ghost):hover {
filter: brightness(0.9);
}
.n-button.n-button--warning-type:not(.n-button--quaternary):not(
.n-button--tertiary
):not(.n-button--secondary):not(.n-button--ghost) {
background-color: rgb(var(--rgb-warning)) !important;
color: #ffffff !important;
border: 1px solid rgb(var(--rgb-warning)) !important;
}
.n-button.n-button--warning-type:not(.n-button--quaternary):not(
.n-button--tertiary
):not(.n-button--secondary):not(.n-button--ghost):hover {
filter: brightness(0.9);
}
/* Electron Drag Region Support */
.app-drag-region {
-webkit-app-region: drag;
}
.no-drag {
-webkit-app-region: no-drag;
}
/* Ensure Naive UI modals and overlays are not draggable and thus clickable */
.n-modal-container,
.n-modal-mask,
.n-modal-body-wrapper,
.n-dialog,
.n-notification-container,
.n-message-container,
.n-dropdown-menu,
.n-popover {
-webkit-app-region: no-drag;
}
/* Disabled state for Primary Button */
.n-button.n-button--primary-type.n-button--disabled {
opacity: 0.6 !important;
cursor: not-allowed !important;
}
-125
View File
@@ -1,125 +0,0 @@
:root {
/* Primitive Colors (Semantic Base) */
--rgb-white: 255, 255, 255;
--rgb-black: 0, 0, 0;
/* Status Colors (Modern Zinc/Tailwind Palette) */
--rgb-success: 22, 163, 74; /* Green-600 */
--rgb-success-bg: 220, 252, 231; /* Green-100 */
--rgb-on-success: 255, 255, 255; /* White */
--rgb-warning: 202, 138, 4; /* Yellow-600 */
--rgb-warning-bg: 254, 249, 195; /* Yellow-100 */
--rgb-on-warning: 255, 255, 255; /* White */
--rgb-error: 225, 29, 72; /* Rose-600 */
--rgb-error-bg: 255, 228, 230; /* Rose-100 */
--rgb-on-error: 255, 255, 255; /* White */
--rgb-info: 37, 99, 235; /* Blue-600 */
--rgb-info-bg: 219, 234, 254; /* Blue-100 */
--rgb-on-info: 255, 255, 255; /* White */
/* Theme: Light (Sophisticated Modern) */
--rgb-primary: 16, 185, 129; /* Emerald-500 */
--rgb-primary-hover: 5, 150, 105; /* Emerald-600 */
--rgb-on-primary: 255, 255, 255;
--rgb-secondary: 100, 116, 139; /* Slate-500 */
--rgb-secondary-hover: 71, 85, 105; /* Slate-600 */
--rgb-on-secondary: 255, 255, 255;
/* Backgrounds - Refined Off-white */
--rgb-background: 250, 250, 251; /* Softer than pure white */
--rgb-surface: 255, 255, 255; /* Pure white for cards */
--rgb-surface-panel: 244, 245, 247; /* Panel background */
--rgb-surface-overlay: 238, 240, 243; /* Hover/Input background */
/* Text - Slate Palette for better legibility */
--rgb-on-surface: 15, 23, 42; /* Slate-900 */
--rgb-on-surface-variant: 71, 85, 105; /* Slate-600 */
--rgb-on-surface-muted: 148, 163, 184; /* Slate-400 */
/* Borders - Soft Slate */
--rgb-border: 226, 232, 240; /* Slate-200 */
--rgb-border-hover: 203, 213, 225; /* Slate-300 */
--rgb-overlay: 0, 0, 0;
/* CSS Variables for direct usage */
--color-primary: rgb(var(--rgb-primary));
--color-background: rgb(var(--rgb-background));
--color-surface: rgb(var(--rgb-surface));
--color-on-surface: rgb(var(--rgb-on-surface));
--color-border: rgb(var(--rgb-border));
}
/* Theme Colors Override */
html.theme-green {
--rgb-primary: 16, 185, 129; /* Emerald-500 */
--rgb-primary-hover: 5, 150, 105; /* Emerald-600 */
}
html.theme-blue {
--rgb-primary: 59, 130, 246; /* Blue-500 */
--rgb-primary-hover: 37, 99, 235; /* Blue-600 */
}
html.theme-purple {
--rgb-primary: 139, 92, 246; /* Violet-500 */
--rgb-primary-hover: 124, 58, 237; /* Violet-600 */
}
html.theme-orange {
--rgb-primary: 249, 115, 22; /* Orange-500 */
--rgb-primary-hover: 234, 88, 12; /* Orange-600 */
}
html.theme-red {
--rgb-primary: 239, 68, 68; /* Red-500 */
--rgb-primary-hover: 220, 38, 38; /* Red-600 */
}
html.dark {
/* Theme: Dark (Deep Midnight Noir) */
--rgb-on-primary: 255, 255, 255;
--rgb-secondary: 148, 163, 184; /* Slate-400 */
--rgb-secondary-hover: 226, 232, 240; /* Slate-200 */
--rgb-on-secondary: 15, 23, 42; /* Slate-900 */
/* Backgrounds - DEEP NOIR */
--rgb-background: 10, 10, 12; /* Midnight Black */
--rgb-surface: 18, 18, 22; /* Ebony surface */
--rgb-surface-panel: 28, 28, 32; /* Obsidian panel */
--rgb-surface-overlay: 38, 38, 44; /* Hover state */
/* Text - High Contrast for Dark Mode */
--rgb-on-surface: 248, 250, 252; /* Slate-50 */
--rgb-on-surface-variant: 148, 163, 184; /* Slate-400 */
--rgb-on-surface-muted: 71, 85, 105; /* Slate-600 */
/* Borders - Subdued Obsidian */
--rgb-border: 38, 38, 44; /* Quiet borders */
--rgb-border-hover: 63, 63, 70; /* Visible hover borders */
/* Adjusted Status Colors for Dark Mode (Vibrant but readable) */
--rgb-success: 52, 211, 153; /* Emerald-400 */
--rgb-warning: 251, 191, 36; /* Amber-400 */
--rgb-error: 251, 113, 133; /* Rose-400 */
--rgb-info: 56, 189, 248; /* Sky-400 */
}
/* Transitions */
*,
*::before,
*::after {
transition:
background-color 0.2s cubic-bezier(0.4, 0, 0.2, 1),
border-color 0.2s cubic-bezier(0.4, 0, 0.2, 1),
color 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
transition: none !important;
}
}
+109
View File
@@ -0,0 +1,109 @@
import type { Game, Category, NodeConfig } from './index'
export interface ElectronAPI {
minimize: () => Promise<void>
maximize: () => Promise<void>
close: () => Promise<void>
pickImage: () => Promise<string>
pickProcess: () => Promise<string[]>
pickProcessFolder: (maxDepth?: number) => Promise<string[]>
on: (channel: string, callback: (...args: any[]) => void) => void
off: (channel: string, callback: (...args: any[]) => void) => void
}
export interface SingboxAPI {
start: (config: string) => Promise<void>
stop: () => Promise<void>
restart: (config: string) => Promise<void>
}
export interface SystemAPI {
scanProcesses: () => Promise<string[]>
scanLocalGames: () => Promise<Array<{
name: string
processName: string
source: 'steam' | 'microsoft' | 'epic' | 'ea'
installDir: string
}>>
getProcessTree: () => Promise<any>
ping: (host: string) => Promise<number>
tcpPing: (host: string, port: number) => Promise<number>
flushDnsCache: () => Promise<{ ok: boolean, code: number, output: string, message: string }>
reinstallTunAdapter: (interfaceName?: string) => Promise<{ ok: boolean, code: number, output: string, message: string }>
findAvailablePort: (port: number, count?: number) => Promise<number>
testHttpProxyConnect: (
proxyPort: number,
targetHost: string,
targetPort?: number,
timeoutMs?: number
) => Promise<{ ok: boolean; statusLine: string; error?: string }>
}
export interface ProxyMonitorAPI {
start: (gameId: string, processNames: string[]) => Promise<void>
stop: () => Promise<void>
}
export interface NodesAPI {
getAll: () => Promise<NodeConfig[]>
save: (node: NodeConfig) => Promise<NodeConfig[]>
delete: (id: string) => Promise<NodeConfig[]>
import: (nodes: NodeConfig[]) => Promise<NodeConfig[]>
}
export interface GamesAPI {
getAll: () => Promise<Game[]>
save: (game: Game) => Promise<Game[]>
delete: (id: string) => Promise<Game[]>
}
export interface CategoriesAPI {
getAll: () => Promise<Category[]>
save: (category: Category) => Promise<Category[]>
delete: (id: string) => Promise<Category[]>
}
export interface AppAPI {
getVersion: () => Promise<string>
checkUpdate: () => Promise<{
updateAvailable: boolean
version?: string
releaseNotes?: string
releaseDate?: string
error?: string
}>
openUrl: (url: string) => Promise<void>
}
export interface LogEntry {
id: string
timestamp: number
level: 'debug' | 'info' | 'warn' | 'error'
category: 'frontend' | 'backend' | 'core'
source: string
message: string
detail?: string
}
export interface LogsAPI {
getAll: () => Promise<LogEntry[]>
clear: () => Promise<void>
getFilePath: () => Promise<string>
pushFrontend: (entry: Partial<LogEntry>) => Promise<void>
onNew: (callback: (entry: LogEntry) => void) => void
offNew: (callback: (entry: LogEntry) => void) => void
}
declare global {
interface Window {
electron: ElectronAPI
singbox: SingboxAPI
system: SystemAPI
proxyMonitor: ProxyMonitorAPI
nodes: NodesAPI
games: GamesAPI
categories: CategoriesAPI
app: AppAPI
logs: LogsAPI
}
}
+127
View File
@@ -23,3 +23,130 @@ export interface Category {
icon?: string
order?: number
}
export interface NodeConfig {
id?: string // Added ID for management
type: string
tag: string
server: string
server_port: number
uuid?: string
password?: string
method?: string
plugin?: string
plugin_opts?: string
network?: string
security?: string
path?: string
host?: string
service_name?: string
alpn?: string
fingerprint?: string
tls?: {
enabled: boolean
server_name?: string
insecure?: boolean
utls?: {
enabled: boolean
fingerprint: string
}
reality?: {
enabled: boolean
public_key?: string
short_id?: string
}
}
flow?: string
packet_encoding?: string
username?: string
}
// Settings Types
export type CheckMethod = 'ping' | 'tcp' | 'http'
export type DnsMode = 'secure' | 'system'
// Theme Types
export type Theme = 'light' | 'dark' | 'auto'
export type ThemeColor = 'green' | 'blue' | 'purple' | 'orange' | 'red'
// Singbox Config Types
export interface DnsConfigOptions {
mode?: 'secure' | 'system' // DNS 模式:安全模式 (DoH/DoT) 或 系统解析
primary?: string // 主 DNS 服务器地址
secondary?: string // 备用 DNS 服务器地址
tunInterfaceName?: string
disableTun?: boolean
localProxyNode?: NodeConfig
localProxyStrictNode?: boolean
localProxy?: {
enabled: boolean
port: number
}
}
// Latency Session Types
export interface LatencyRecord {
id?: number
nodeKey: string
server: string
port: number
method: 'ping' | 'tcp' | 'http'
latency: number
loss: number
timestamp: number
gameId?: string
accelerationSeconds?: number
sessionLossRate?: number
}
// Singbox Internal Config
export interface SingboxConfig {
log: {
level: string
timestamp: boolean
}
dns: {
servers: any[]
rules: any[]
final?: string
}
inbounds: any[]
outbounds: any[]
route: {
rules: any[]
rule_set?: any[]
auto_detect_interface: boolean
final?: string
}
experimental?: {
cache_file?: {
enabled: boolean
}
}
}
// System/Scanner Types
export type LocalScanGame = {
name: string
processName: string
source: 'steam' | 'microsoft' | 'epic' | 'ea'
installDir: string
}
// Node Check Context
export interface CheckRecordContext {
recordLatency?: boolean
gameId?: string
accelerationSeconds?: number
sessionLossRate?: number
}
// Logger Types
export type FrontendLogLevel = 'debug' | 'info' | 'warn' | 'error'
export type FrontendLogPayload = {
level: FrontendLogLevel
source: string
message: string
detail?: string
}
+1 -13
View File
@@ -1,16 +1,4 @@
export interface LatencyRecord {
id?: number
nodeKey: string
server: string
port: number
method: 'ping' | 'tcp' | 'http'
latency: number
loss: number
timestamp: number
gameId?: string
accelerationSeconds?: number
sessionLossRate?: number
}
import type { LatencyRecord } from '@/types'
const DB_NAME = 'lagzero-latency-session'
const DB_VERSION = 2
+98
View File
@@ -25,6 +25,104 @@ describe('Protocol Parsing', () => {
expect(result?.tls?.enabled).toBe(true)
})
it('should parse VMess insecure alias field', () => {
const config = {
v: '2',
ps: 'Alias Insecure',
add: 'example.com',
port: '443',
id: 'uuid',
aid: '0',
scy: 'auto',
net: 'tcp',
tls: 'tls',
insecure: '1'
}
const link = 'vmess://' + Base64.encode(JSON.stringify(config))
const result = parseShareLink(link)
expect(result).not.toBeNull()
expect(result?.tls?.insecure).toBe(true)
})
it('should parse link when trailing annotation text exists', () => {
const config = {
v: '2',
ps: 'With note',
add: 'example.com',
port: '443',
id: 'uuid',
aid: '0',
scy: 'auto',
net: 'tcp',
tls: ''
}
const link = 'vmess://' + Base64.encode(JSON.stringify(config)) + ' 这个格式'
const result = parseShareLink(link)
expect(result).not.toBeNull()
expect(result?.type).toBe('vmess')
expect(result?.server).toBe('example.com')
})
it('should parse provided vmess sample from user', () => {
const link = 'vmess://ew0KICAidiI6ICIyIiwNCiAgInBzIjogIuaXpeacrC3kvJjljJYiLA0KICAiYWRkIjogInBsYW5iLm1vamNuLmNvbSIsDQogICJwb3J0IjogIjE2NjE3IiwNCiAgImlkIjogIjM4ZTFjMjlhLTc3MTYtNDA5MC1iM2Q5LTBmMzhkOWYyNzEyNiIsDQogICJhaWQiOiAiMCIsDQogICJzY3kiOiAiYXV0byIsDQogICJuZXQiOiAid3MiLA0KICAidHlwZSI6ICJub25lIiwNCiAgImhvc3QiOiAiOGM5NGUwMGIwMWEwMDBkYmE2ZDNiMTc3MjYzOWJmNzUubW9iZ3NsYi50YmNhY2hlLmNvbSIsDQogICJwYXRoIjogIi8iLA0KICAidGxzIjogIiIsDQogICJzbmkiOiAiIiwNCiAgImFscG4iOiAiIiwNCiAgImZwIjogIiIsDQogICJpbnNlY3VyZSI6ICIwIg0KfQ=='
const result = parseShareLink(link)
expect(result).not.toBeNull()
expect(result?.type).toBe('vmess')
expect(result?.server).toBe('planb.mojcn.com')
expect(result?.server_port).toBe(16617)
expect(result?.network).toBe('ws')
expect(result?.host).toContain('mobgslb.tbcache.com')
})
it('should parse VLESS reality link with pbk/sid/sni/fp', () => {
const link = 'vless://13a3abd8-6315-4735-b3a2-39f38a2e4f3d@156.246.93.26:38073?encryption=none&security=reality&flow=xtls-rprx-vision&type=tcp&sni=www.paypal.com&pbk=DrpIgSOtaEHqJywmydYjljWB9FD_1PlFjQIAlbHiOgk&fp=chrome#233boy-reality-156.246.93.26'
const result = parseShareLink(link)
expect(result).not.toBeNull()
expect(result?.type).toBe('vless')
expect(result?.server).toBe('156.246.93.26')
expect(result?.server_port).toBe(38073)
expect(result?.security).toBe('reality')
expect(result?.flow).toBe('xtls-rprx-vision')
expect(result?.tls?.enabled).toBe(true)
expect(result?.tls?.server_name).toBe('www.paypal.com')
expect(result?.tls?.reality?.enabled).toBe(true)
expect(result?.tls?.reality?.public_key).toBe('DrpIgSOtaEHqJywmydYjljWB9FD_1PlFjQIAlbHiOgk')
expect(result?.fingerprint).toBe('chrome')
})
it('should parse Trojan params aliases', () => {
const link = 'trojan://pass@example.com:443?network=ws&service_name=svc&serverName=example.com&allowInsecure=true&fingerprint=firefox#trojan'
const result = parseShareLink(link)
expect(result).not.toBeNull()
expect(result?.type).toBe('trojan')
expect(result?.network).toBe('ws')
expect(result?.service_name).toBe('svc')
expect(result?.tls?.server_name).toBe('example.com')
expect(result?.tls?.insecure).toBe(true)
expect(result?.fingerprint).toBe('firefox')
})
it('should parse socks5h as socks', () => {
const result = parseShareLink('socks5h://user:pass@127.0.0.1:1080#socks')
expect(result).not.toBeNull()
expect(result?.type).toBe('socks')
expect(result?.server_port).toBe(1080)
})
it('should parse Shadowsocks SIP002 with plugin', () => {
const userInfo = Base64.encode('aes-256-gcm:pwd')
const link = `ss://${userInfo}@1.2.3.4:443/?plugin=${encodeURIComponent('v2ray-plugin;mode=websocket;host=example.com')}#ss`
const result = parseShareLink(link)
expect(result).not.toBeNull()
expect(result?.type).toBe('shadowsocks')
expect(result?.method).toBe('aes-256-gcm')
expect(result?.plugin).toBe('v2ray-plugin')
expect(result?.plugin_opts).toContain('mode=websocket')
})
it('should return null for invalid link', () => {
expect(parseShareLink('invalid://link')).toBeNull()
})
+90 -75
View File
@@ -1,41 +1,7 @@
import { Base64 } from 'js-base64'
import type { NodeConfig } from '../types'
export interface NodeConfig {
id?: string // Added ID for management
type: string
tag: string
server: string
server_port: number
uuid?: string
password?: string
method?: string
plugin?: string
plugin_opts?: string
network?: string
security?: string
path?: string
host?: string
service_name?: string
alpn?: string
fingerprint?: string
tls?: {
enabled: boolean
server_name?: string
insecure?: boolean
utls?: {
enabled: boolean
fingerprint: string
}
reality?: {
enabled: boolean
public_key?: string
short_id?: string
}
}
flow?: string
packet_encoding?: string
username?: string
}
export type { NodeConfig }
export function normalizeNodeType(type: string | null | undefined): string {
const t = String(type ?? '').trim().toLowerCase()
@@ -46,7 +12,9 @@ export function normalizeNodeType(type: string | null | undefined): string {
}
export function parseShareLink(link: string): NodeConfig | null {
link = link.trim()
link = link.trim().replace(/[\u200B-\u200D\uFEFF]/g, '')
if (!link) return null
link = extractFirstLinkToken(link)
if (!link) return null
// Try to decode if it looks like a base64 string (no protocol prefix, no spaces)
@@ -69,17 +37,18 @@ export function parseShareLink(link: string): NodeConfig | null {
}
}
if (link.startsWith('vmess://')) {
const lower = link.toLowerCase()
if (lower.startsWith('vmess://')) {
return parseVMess(link)
} else if (link.startsWith('vless://')) {
} else if (lower.startsWith('vless://')) {
return parseVLESS(link)
} else if (link.startsWith('ss://')) {
} else if (lower.startsWith('ss://')) {
return parseShadowsocks(link)
} else if (link.startsWith('trojan://')) {
} else if (lower.startsWith('trojan://')) {
return parseTrojan(link)
} else if (link.startsWith('socks://') || link.startsWith('socks5://')) {
} else if (lower.startsWith('socks://') || lower.startsWith('socks5://') || lower.startsWith('socks5h://')) {
return parseSocks(link)
} else if (link.startsWith('http://') || link.startsWith('https://')) {
} else if (lower.startsWith('http://') || lower.startsWith('https://')) {
return parseHttp(link)
}
return null
@@ -117,9 +86,13 @@ export function parseBatchLinks(content: string): NodeConfig[] {
function parseVMess(link: string): NodeConfig | null {
try {
const base64 = link.replace('vmess://', '')
const jsonStr = Base64.decode(base64)
const base64 = link.replace(/^vmess:\/\//i, '')
const jsonStr = decodeBase64MaybeUrl(base64)
const config = JSON.parse(jsonStr)
const tlsType = String(config.tls || '').toLowerCase()
const isTlsEnabled = tlsType === 'tls' || tlsType === 'reality' || tlsType === '1'
const fingerprint = config.fp || config.fingerprint || undefined
const sni = config.sni || config.serverName || config.server_name || config.host
return {
type: 'vmess',
tag: config.ps || 'VMess Node',
@@ -132,14 +105,14 @@ function parseVMess(link: string): NodeConfig | null {
host: config.host,
service_name: config.net === 'grpc' ? (config.path || config.serviceName || '') : (config.serviceName || ''),
alpn: config.alpn || undefined,
fingerprint: config.fp || undefined,
fingerprint,
tls: {
enabled: config.tls === 'tls',
server_name: config.sni || config.host,
insecure: String(config.allowInsecure || '0') === '1',
enabled: isTlsEnabled,
server_name: sni || undefined,
insecure: isTruthy(config.allowInsecure ?? config.insecure),
utls: {
enabled: !!config.fp,
fingerprint: config.fp || 'chrome'
enabled: !!fingerprint,
fingerprint: fingerprint || 'chrome'
}
}
}
@@ -153,33 +126,41 @@ function parseVLESS(link: string): NodeConfig | null {
try {
const url = new URL(link)
const params = url.searchParams
const security = String(pickParam(params, ['security', 'tls']) || 'none').toLowerCase()
const fp = pickParam(params, ['fp', 'fingerprint', 'client-fingerprint']) || undefined
const network = pickParam(params, ['type', 'network']) || 'tcp'
const packetEncoding = pickParam(params, ['packetEncoding', 'packet_encoding']) || undefined
const realityPublicKey = pickParam(params, ['pbk', 'publicKey', 'public_key']) || undefined
const realityShortId = pickParam(params, ['sid', 'shortId', 'short_id']) || undefined
const tlsEnabled = security === 'tls' || security === 'reality' || !!realityPublicKey
const realityEnabled = security === 'reality' || !!realityPublicKey
return {
type: 'vless',
tag: decodeURIComponent(url.hash.slice(1)) || 'VLESS Node',
server: url.hostname,
server_port: Number(url.port),
uuid: url.username,
network: params.get('type') || 'tcp',
security: params.get('security') || 'none',
network,
security: realityEnabled ? 'reality' : security,
path: params.get('path') || undefined,
host: params.get('host') || undefined,
service_name: params.get('serviceName') || undefined,
service_name: pickParam(params, ['serviceName', 'service_name']) || undefined,
flow: params.get('flow') || undefined,
packet_encoding: params.get('packetEncoding') || undefined,
packet_encoding: packetEncoding,
alpn: params.get('alpn') || undefined,
fingerprint: params.get('fp') || undefined,
fingerprint: fp,
tls: {
enabled: params.get('security') === 'tls' || params.get('security') === 'reality',
server_name: params.get('sni') || undefined,
insecure: params.get('allowInsecure') === '1',
enabled: tlsEnabled,
server_name: pickParam(params, ['sni', 'serverName', 'server_name']) || undefined,
insecure: isTruthy(pickParam(params, ['allowInsecure', 'insecure'])),
utls: {
enabled: !!params.get('fp'),
fingerprint: params.get('fp') || 'chrome'
enabled: !!fp,
fingerprint: fp || 'chrome'
},
reality: {
enabled: params.get('security') === 'reality',
public_key: params.get('pbk') || undefined,
short_id: params.get('sid') || undefined
enabled: realityEnabled,
public_key: realityPublicKey,
short_id: realityShortId
}
}
}
@@ -215,7 +196,7 @@ function parseShadowsocks(link: string): NodeConfig | null {
const at = decodedMain.lastIndexOf('@')
if (at <= 0) return null
const userPart = decodedMain.slice(0, at)
const serverPart = decodedMain.slice(at + 1)
const serverPart = decodedMain.slice(at + 1).replace(/\/+$/, '')
const colon = userPart.indexOf(':')
if (colon <= 0) return null
@@ -258,30 +239,40 @@ function decodeBase64Url(input: string): string {
return Base64.decode(padded)
}
function decodeBase64MaybeUrl(input: string): string {
try {
return Base64.decode(input)
} catch {
return decodeBase64Url(input)
}
}
function parseTrojan(link: string): NodeConfig | null {
try {
const url = new URL(link)
const params = url.searchParams
const security = String(pickParam(params, ['security', 'tls']) || 'tls').toLowerCase()
const fp = pickParam(params, ['fp', 'fingerprint', 'client-fingerprint']) || undefined
return {
type: 'trojan',
tag: decodeURIComponent(url.hash.slice(1)) || 'Trojan Node',
server: url.hostname,
server_port: Number(url.port),
password: url.username,
network: params.get('type') || 'tcp',
network: pickParam(params, ['type', 'network']) || 'tcp',
path: params.get('path') || undefined,
host: params.get('host') || undefined,
service_name: params.get('serviceName') || undefined,
security: params.get('security') || 'tls',
service_name: pickParam(params, ['serviceName', 'service_name']) || undefined,
security,
alpn: params.get('alpn') || undefined,
fingerprint: params.get('fp') || undefined,
fingerprint: fp,
tls: {
enabled: params.get('security') !== 'none',
server_name: params.get('sni') || undefined,
insecure: params.get('allowInsecure') === '1',
enabled: security !== 'none',
server_name: pickParam(params, ['sni', 'serverName', 'server_name']) || undefined,
insecure: isTruthy(pickParam(params, ['allowInsecure', 'insecure'])),
utls: {
enabled: !!params.get('fp'),
fingerprint: params.get('fp') || 'chrome'
enabled: !!fp,
fingerprint: fp || 'chrome'
}
}
}
@@ -293,7 +284,8 @@ function parseTrojan(link: string): NodeConfig | null {
function parseSocks(link: string): NodeConfig | null {
try {
const url = new URL(link)
const type = normalizeNodeType(url.protocol.replace(':', ''))
const proto = url.protocol.replace(':', '').toLowerCase()
const type = proto === 'socks5h' ? 'socks' : normalizeNodeType(proto)
const port = Number(url.port)
if (!Number.isFinite(port) || port <= 0) return null
return {
@@ -373,6 +365,7 @@ function generateVLESS(node: NodeConfig): string {
const params = new URLSearchParams()
if (node.network) params.set('type', node.network)
params.set('encryption', 'none')
if (node.security) params.set('security', node.security)
if (node.flow) params.set('flow', node.flow)
if (node.path) params.set('path', node.path)
@@ -433,6 +426,7 @@ function generateTrojan(node: NodeConfig): string {
if (node.host) params.set('host', node.host)
if (node.service_name) params.set('serviceName', node.service_name)
if (node.security) params.set('security', node.security)
else if (node.tls?.enabled) params.set('security', 'tls')
if (node.alpn) params.set('alpn', node.alpn)
if (node.tls?.server_name) params.set('sni', node.tls.server_name)
if (node.tls?.insecure) params.set('allowInsecure', '1')
@@ -444,6 +438,27 @@ function generateTrojan(node: NodeConfig): string {
return `trojan://${password}@${node.server}:${node.server_port}?${params.toString()}${hash}`
}
function pickParam(params: URLSearchParams, keys: string[]): string | null {
for (const key of keys) {
const value = params.get(key)
if (value != null && String(value).trim() !== '') return value
}
return null
}
function extractFirstLinkToken(input: string): string {
const value = String(input || '').trim()
if (!value) return ''
const protocolMatch = value.match(/^([a-z][a-z0-9+.-]*):\/\//i)
if (!protocolMatch) return value
return value.split(/\s+/)[0] || value
}
function isTruthy(value: unknown): boolean {
const normalized = String(value ?? '').trim().toLowerCase()
return normalized === '1' || normalized === 'true' || normalized === 'yes' || normalized === 'on'
}
function generateSocks(node: NodeConfig): string {
const username = encodeURIComponent(node.username || '')
const password = encodeURIComponent(node.password || '')
+3 -9
View File
@@ -1,11 +1,5 @@
type FrontendLogLevel = 'debug' | 'info' | 'warn' | 'error'
type FrontendLogPayload = {
level: FrontendLogLevel
source: string
message: string
detail?: string
}
import { logsApi } from '@/api'
import type { FrontendLogLevel, FrontendLogPayload } from '@/types'
const MAX_TEXT_LENGTH = 2000
@@ -38,7 +32,7 @@ function makePayload(level: FrontendLogLevel, args: unknown[]): FrontendLogPaylo
function push(payload: FrontendLogPayload) {
try {
window.logs?.pushFrontend(payload)
logsApi.pushFrontend(payload)
} catch {
// ignore logging channel errors
}
+197 -77
View File
@@ -3,51 +3,14 @@
* 用于将游戏规则和节点配置转换为 Sing-box 可识别的 JSON 格式
*/
import type { Game } from '@/types'
import type { Game, DnsConfigOptions, SingboxConfig } from '@/types'
import type { NodeConfig } from '@/utils/protocol'
import pkg from '../../package.json'
/**
* Singbox 核心配置结构接口
*/
interface SingboxConfig {
log: {
level: string
timestamp: boolean
}
dns: {
servers: any[]
rules: any[]
final?: string
}
inbounds: any[]
outbounds: any[]
route: {
rules: any[]
auto_detect_interface: boolean
final?: string
}
experimental?: {
cache_file?: {
enabled: boolean
}
}
}
/**
* DNS 配置选项
*/
export interface DnsConfigOptions {
mode?: 'secure' | 'system' // DNS 模式:安全模式 (DoH/DoT) 或 系统解析
primary?: string // 主 DNS 服务器地址
secondary?: string // 备用 DNS 服务器地址
tunInterfaceName?: string
disableTun?: boolean
localProxy?: {
enabled: boolean
port: number
}
}
const GEOIP_CN_RULE_SET_TAG = 'geoip-cn'
const LOYALSOLDIER_GEOIP_CN_SRS_URL = 'https://cdn.jsdelivr.net/gh/Loyalsoldier/geoip@release/srs/cn.srs'
const GEOSITE_CN_RULE_SET_TAG = 'geosite-cn'
const SAGERNET_GEOSITE_CN_SRS_URL = 'https://cdn.jsdelivr.net/gh/SagerNet/sing-geosite@rule-set/geosite-cn.srs'
/**
* 生成 Sing-box 配置文件
@@ -60,12 +23,19 @@ export interface DnsConfigOptions {
export function generateSingboxConfig(game: Game, node: NodeConfig, dnsOptions?: DnsConfigOptions): string {
const isRoutingMode = game.proxyMode === 'routing'
const processes = normalizeProcessNames(Array.isArray(game.processName) ? game.processName : [game.processName])
const routingRules = Array.isArray(game.routingRules) ? game.routingRules.map(r => String(r).trim()).filter(Boolean) : []
const hasBypassCn = routingRules.some(r => r.toLowerCase() === 'bypass_cn')
const hasGlobal = routingRules.some(r => r.toLowerCase() === 'global')
const routingIpCidrs = normalizeRoutingIpCidrs(routingRules)
const hasRoutingProcessScope = isRoutingMode && processes.length > 0
const dnsMode = dnsOptions?.mode || 'secure'
const dnsPrimary = String(dnsOptions?.primary || 'https://dns.google/dns-query').trim()
const dnsSecondary = String(dnsOptions?.secondary || 'https://1.1.1.1/dns-query').trim()
const tunInterfaceName = String(dnsOptions?.tunInterfaceName || pkg.productName).trim() || pkg.productName
const useSecureDns = dnsMode === 'secure'
const disableTun = !!dnsOptions?.disableTun
const localProxyNode = dnsOptions?.localProxyNode
const localProxyStrictNode = !!dnsOptions?.localProxyStrictNode
// 基础配置结构
const config: SingboxConfig = {
@@ -107,7 +77,7 @@ export function generateSingboxConfig(game: Game, node: NodeConfig, dnsOptions?:
{
type: 'selector',
tag: 'proxy',
outbounds: ['node-out', 'direct'],
outbounds: localProxyStrictNode ? ['node-out'] : ['node-out', 'direct'],
default: 'node-out'
},
{
@@ -128,7 +98,9 @@ export function generateSingboxConfig(game: Game, node: NodeConfig, dnsOptions?:
{ protocol: 'dns', outbound: 'dns-out' }
],
auto_detect_interface: true,
final: isRoutingMode ? 'proxy' : 'direct'
final: isRoutingMode
? ((hasBypassCn || hasGlobal || routingIpCidrs.length === 0) && !hasRoutingProcessScope ? 'proxy' : 'direct')
: 'direct'
}
}
@@ -169,14 +141,35 @@ export function generateSingboxConfig(game: Game, node: NodeConfig, dnsOptions?:
sniff: true
})
// Local mixed proxy inbounds should always tunnel through selected node.
// Otherwise process mode falls back to route.final=direct, which may timeout.
const localProxyOutboundTag = localProxyNode ? 'local-proxy' : 'proxy'
if (localProxyNode) {
config.outbounds.push({
type: 'selector',
tag: 'local-proxy',
outbounds: ['local-node-out', 'direct'],
default: 'local-node-out'
})
config.outbounds.push(convertNodeToOutbound(localProxyNode, 'local-node-out'))
}
// Local mixed proxy inbounds should always tunnel through selected local proxy outbound.
config.route.rules.push({
inbound: ['http-in', 'socks-in'],
outbound: 'proxy'
outbound: localProxyOutboundTag
})
}
if (useSecureDns) {
const bootstrapDomains = collectBootstrapDomains(node, localProxyNode)
if (bootstrapDomains.length > 0) {
// Avoid DNS bootstrap loop: node server domains must be resolved directly first.
config.dns.rules.unshift({
domain: bootstrapDomains,
server: 'local'
})
}
}
// 1. 将节点配置转换为 Outbound 格式并添加到配置中
const nodeOutbound = convertNodeToOutbound(node)
config.outbounds.push(nodeOutbound)
@@ -191,33 +184,62 @@ export function generateSingboxConfig(game: Game, node: NodeConfig, dnsOptions?:
// 3. 配置路由规则
if (isRoutingMode) {
// 路由模式:处理“绕过中国大陆”或“全局代理”
const routingRules = game.routingRules || []
if (routingRules.includes('bypass_cn')) {
// 绕过中国大陆规则
config.route.rules.push({
if (hasBypassCn) {
const privateRule: Record<string, any> = {
ip_is_private: true,
outbound: 'direct'
}
if (hasRoutingProcessScope) privateRule.process_name = processes
config.route.rules.push(privateRule)
ensureRemoteRuleSet(config, {
tag: GEOIP_CN_RULE_SET_TAG,
type: 'remote',
format: 'binary',
url: LOYALSOLDIER_GEOIP_CN_SRS_URL,
download_detour: 'proxy'
})
config.route.rules.push({
domain_suffix: ['cn'],
ensureRemoteRuleSet(config, {
tag: GEOSITE_CN_RULE_SET_TAG,
type: 'remote',
format: 'binary',
url: SAGERNET_GEOSITE_CN_SRS_URL,
download_detour: 'proxy'
})
const geoipRule: Record<string, any> = {
rule_set: [GEOIP_CN_RULE_SET_TAG],
outbound: 'direct'
})
config.route.rules.push({
domain_suffix: [
'qq.com', 'baidu.com', 'bilibili.com', 'taobao.com', 'tmall.com',
'jd.com', 'douyin.com', 'douban.com', 'weibo.com', 'zhihu.com',
'youku.com', 'iqiyi.com', '163.com', 'sina.com.cn', 'alicdn.com'
],
}
if (hasRoutingProcessScope) geoipRule.process_name = processes
config.route.rules.push(geoipRule)
const geositeRule: Record<string, any> = {
rule_set: [GEOSITE_CN_RULE_SET_TAG],
outbound: 'direct'
})
// 默认出口在 config.route.final 中已设置为 'proxy'
} else {
// 全局模式(或默认路由)
// 默认出口为 'proxy'
}
if (hasRoutingProcessScope) geositeRule.process_name = processes
config.route.rules.push(geositeRule)
if (hasRoutingProcessScope) {
config.route.rules.push({
process_name: processes,
outbound: 'proxy'
})
}
} else if (hasGlobal) {
if (hasRoutingProcessScope) {
config.route.rules.push({
process_name: processes,
outbound: 'proxy'
})
}
} else if (routingIpCidrs.length > 0) {
const rule: Record<string, any> = {
ip_cidr: routingIpCidrs,
outbound: 'proxy'
}
if (processes.length > 0) {
rule.process_name = processes
}
config.route.rules.push(rule)
}
} else {
// 进程模式:仅指定的游戏进程走代理,其余默认直连
if (processes.length > 0) {
@@ -237,9 +259,9 @@ export function generateSingboxConfig(game: Game, node: NodeConfig, dnsOptions?:
* @param node 原始节点配置
* @returns Sing-box outbound 对象
*/
function convertNodeToOutbound(node: NodeConfig): any {
function convertNodeToOutbound(node: NodeConfig, tag: string = 'node-out'): any {
const base = {
tag: 'node-out',
tag,
server: node.server,
server_port: node.server_port
}
@@ -285,8 +307,8 @@ function convertNodeToOutbound(node: NodeConfig): any {
transport,
tls: tls || {
enabled: true,
server_name: node.tls?.server_name || node.host || node.server,
insecure: node.tls?.insecure
server_name: resolveTlsServerName(node),
insecure: !!node.tls?.insecure
}
}
case 'socks':
@@ -357,8 +379,8 @@ function buildTls(node: NodeConfig): any {
const tls: any = {
enabled: true,
server_name: node.tls.server_name || node.host || node.server,
insecure: node.tls.insecure,
server_name: resolveTlsServerName(node),
insecure: !!node.tls.insecure,
alpn: alpn.length > 0 ? alpn : undefined,
utls: fingerprint ? {
enabled: true,
@@ -378,6 +400,44 @@ function buildTls(node: NodeConfig): any {
return tls
}
function resolveTlsServerName(node: NodeConfig): string | undefined {
const candidates = [
node.tls?.server_name,
node.host,
node.server
]
.map((v) => String(v ?? '').trim())
.filter(Boolean)
for (const candidate of candidates) {
if (!isIpLiteral(candidate)) return candidate
}
return undefined
}
function isIpLiteral(value: string): boolean {
const v = String(value ?? '').trim()
if (!v) return false
if (v.includes(':')) return true // IPv6 literal
const parts = v.split('.')
if (parts.length !== 4) return false
return parts.every((part) => {
if (!/^\d+$/.test(part)) return false
const n = Number(part)
return n >= 0 && n <= 255
})
}
function collectBootstrapDomains(...nodes: Array<NodeConfig | undefined>): string[] {
const set = new Set<string>()
for (const node of nodes) {
const domain = String(node?.server || '').trim().toLowerCase()
if (!domain || isIpLiteral(domain)) continue
set.add(domain)
}
return Array.from(set)
}
/**
* 标准化进程名称列表
* 去除路径只保留文件名,并添加小写版本以提高兼容性
@@ -389,9 +449,11 @@ function normalizeProcessNames(processes: string[]): string[] {
const set = new Set<string>()
items.forEach(p => {
set.add(p)
const lower = p.toLowerCase()
if (lower !== p) set.add(lower)
for (const alias of expandProcessAliases(p)) {
set.add(alias)
const lower = alias.toLowerCase()
if (lower !== alias) set.add(lower)
}
})
return Array.from(set)
@@ -407,3 +469,61 @@ function toProcessBaseName(processName: string): string {
const base = normalized.split('/').pop() || normalized
return base.trim()
}
function expandProcessAliases(processName: string): string[] {
const value = String(processName || '').trim()
if (!value) return []
const aliases = new Set<string>([value])
// Tolerate users entering "chrome" instead of "chrome.exe" on Windows.
if (!value.includes('.') && !value.includes('/') && !value.includes('\\')) {
aliases.add(`${value}.exe`)
}
return Array.from(aliases)
}
function ensureRemoteRuleSet(config: SingboxConfig, ruleSet: Record<string, any>) {
const route = config.route as any
if (!Array.isArray(route.rule_set)) {
route.rule_set = []
}
if (route.rule_set.some((item: any) => String(item?.tag || '') === String(ruleSet.tag || ''))) {
return
}
route.rule_set.push(ruleSet)
}
function normalizeRoutingIpCidrs(rules: string[]): string[] {
const set = new Set<string>()
for (const raw of rules) {
const value = String(raw || '').trim()
if (!value) continue
const lower = value.toLowerCase()
if (lower === 'bypass_cn' || lower === 'global') continue
if (isIpLiteral(value)) {
set.add(value.includes(':') ? `${value}/128` : `${value}/32`)
continue
}
if (isValidCidr(value)) {
set.add(value)
}
}
return Array.from(set)
}
function isValidCidr(value: string): boolean {
const s = String(value || '').trim()
if (!s.includes('/')) return false
const [ip, prefix] = s.split('/')
if (!ip || !prefix || !/^\d+$/.test(prefix)) return false
const bits = Number(prefix)
if (ip.includes(':')) {
return bits >= 0 && bits <= 128
}
if (!isIpLiteral(ip)) return false
return bits >= 0 && bits <= 32
}
+43 -41
View File
@@ -85,14 +85,14 @@
<span class="text-[10px] uppercase font-bold text-on-surface-muted tracking-widest mb-1">{{
$t('games.packet_loss') }}</span>
<span class="text-2xl font-mono font-bold text-on-surface tabular-nums">{{ currentLoss
}}%</span>
}}%</span>
</div>
<div class="w-px h-8 bg-border"></div>
<div class="flex flex-col items-center">
<span class="text-[10px] uppercase font-bold text-on-surface-muted tracking-widest mb-1">{{
$t('games.duration') }}</span>
<span class="text-2xl font-mono font-bold text-on-surface tabular-nums">{{ durationFormatted
}}</span>
}}</span>
</div>
</div>
</div>
@@ -125,9 +125,12 @@
class="absolute inset-0 rounded-full bg-primary/20 animate-ping opacity-20 duration-[3s]">
</div>
<button @click="toggleAccelerator"
<button @click="toggleAccelerator" :disabled="isActionPending"
class="relative w-40 h-40 md:w-48 md:h-48 rounded-full flex items-center justify-center group outline-none transition-all duration-500 ease-out hover:scale-105 active:scale-95 z-10"
:class="isRunning ? 'shadow-[0_0_60px_-10px_rgba(var(--rgb-error),0.3)]' : 'shadow-[0_0_60px_-10px_rgba(var(--rgb-primary),0.3)]'">
:class="[
isRunning ? 'shadow-[0_0_60px_-10px_rgba(var(--rgb-error),0.3)]' : 'shadow-[0_0_60px_-10px_rgba(var(--rgb-primary),0.3)]',
isActionPending ? 'opacity-80 cursor-not-allowed pointer-events-none' : ''
]">
<!-- Button Background -->
<div
@@ -145,11 +148,13 @@
<div class="flex flex-col items-center gap-3 relative z-20">
<div
class="text-5xl md:text-6xl transition-all duration-300 transform group-hover:scale-110"
:class="isRunning ? 'i-carbon-stop-filled text-error drop-shadow-[0_2px_10px_rgba(var(--rgb-error),0.4)]' : 'i-carbon-play-filled-alt text-primary drop-shadow-[0_2px_10px_rgba(var(--rgb-primary),0.4)]'">
:class="isActionPending
? 'i-carbon-circle-dash animate-spin text-primary'
: (isRunning ? 'i-carbon-stop-filled text-error drop-shadow-[0_2px_10px_rgba(var(--rgb-error),0.4)]' : 'i-carbon-play-filled-alt text-primary drop-shadow-[0_2px_10px_rgba(var(--rgb-primary),0.4)]')">
</div>
<span class="text-xs font-black uppercase tracking-[0.25em] transition-colors"
:class="isRunning ? 'text-error' : 'text-primary'">
{{ isRunning ? $t('common.stop') : $t('common.start') }}
{{ actionLabel }}
</span>
</div>
</button>
@@ -211,25 +216,19 @@
</div>
</div>
<!-- 2. Proxy Mode (Bottom of Sidebar) -->
<!-- 2. Proxy Mode (Bottom of Sidebar, read-only) -->
<div class="p-5 border-t border-border/50 bg-surface/30">
<label class="text-[10px] font-black uppercase tracking-widest text-on-surface-muted mb-3 block">
{{ $t('games.mode_routing') }}
{{ $t('games.mode') }}
</label>
<div class="flex bg-surface-overlay p-1 rounded-xl border border-border">
<button v-for="mode in ['process', 'routing']" :key="mode"
@click="proxyMode = (mode as 'process' | 'routing')"
class="flex-1 py-2 rounded-lg text-xs font-bold flex items-center justify-center gap-2 transition-all relative overflow-hidden"
:class="proxyMode === mode ? 'bg-primary text-on-primary shadow-md' : 'text-on-surface-muted hover:text-on-surface'"
:disabled="mode === 'process' && !canSelectProcessMode">
<div :class="mode === 'process' ? 'i-carbon-application-web' : 'i-carbon-network-overlay'">
</div>
{{ mode === 'process' ? $t('games.mode_process') : $t('games.mode_routing') }}
</button>
<div class="rounded-xl border border-border bg-surface-overlay px-3 py-2 text-xs font-bold flex items-center gap-2">
<div :class="activeProxyMode === 'process' ? 'i-carbon-application-web' : 'i-carbon-network-overlay'"></div>
<span>
{{ activeProxyMode === 'process' ? $t('games.mode_process') : $t('games.mode_routing') }}
</span>
</div>
<p class="text-[10px] text-on-surface-muted/60 mt-3 text-center">
{{ proxyMode === 'process' ? $t('games.mode_process_desc') : $t('games.mode_routing_desc') }}
{{ activeProxyMode === 'process' ? $t('games.mode_process_desc') : $t('games.mode_routing_desc') }}
</p>
</div>
</aside>
@@ -240,7 +239,7 @@
<script setup lang="ts">
import { ref, computed, onUnmounted, onMounted, onActivated, watch } from 'vue'
import { useGameStore, isRoutingOnlyPresetGame } from '@/stores/games'
import { useGameStore } from '@/stores/games'
import { useCategoryStore } from '@/stores/categories'
import { useNodeStore } from '@/stores/nodes'
import { useSettingsStore } from '@/stores/settings'
@@ -250,6 +249,7 @@ import { useIntervalFn } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { useMessage } from 'naive-ui'
import i18n from '@/i18n'
import { electronApi } from '@/api'
// Stores
const gameStore = useGameStore()
@@ -262,12 +262,18 @@ const message = useMessage()
// State
const game = computed(() => gameStore.currentGame)
const isRunning = computed(() => game.value?.status === 'accelerating')
const isSwitchingNode = ref(false)
const isActionPending = computed(() => isSwitchingNode.value || gameStore.operationState !== 'idle')
const actionLabel = computed(() => {
if (gameStore.operationState === 'starting') return '启动中'
if (gameStore.operationState === 'stopping') return '暂停中'
return isRunning.value ? String(i18n.global.t('common.stop')) : String(i18n.global.t('common.start'))
})
const currentLatency = ref(0)
const currentLoss = ref(0)
const startTime = ref(0)
const durationSeconds = ref(0)
const lastConnection = ref('')
const isSwitchingNode = ref(false)
const pendingNodeRestart = ref(false)
const totalSamples = ref(0)
const lostSamples = ref(0)
@@ -281,17 +287,7 @@ const categoryLabel = computed(() => {
const selectedNode = computed(() => game.value?.nodeId || null)
const proxyMode = computed({
get: () => game.value?.proxyMode || 'process',
set: (val) => {
if (game.value && val) {
if (val === 'process' && !canSelectProcessMode.value) return
gameStore.updateGame({ ...game.value, proxyMode: val })
}
}
})
const canSelectProcessMode = computed(() => !isRoutingOnlyPresetGame(game.value))
const activeProxyMode = computed<'process' | 'routing'>(() => game.value?.proxyMode === 'routing' ? 'routing' : 'process')
const durationFormatted = computed(() => {
const s = durationSeconds.value
@@ -404,7 +400,7 @@ const { pause: pauseDuration, resume: resumeDuration } = useIntervalFn(() => {
async function toggleAccelerator() {
if (!game.value) return
if (isSwitchingNode.value) return
if (isActionPending.value) return
if (isRunning.value) {
try {
@@ -472,10 +468,12 @@ onMounted(() => {
resumeSampling()
resumeDuration()
}
if (window.electron?.on) {
window.electron.on('singbox-log', (d: any) => onSingboxEvent('singbox-log', d))
window.electron.on('singbox-error', (d: any) => onSingboxEvent('singbox-error', d))
window.electron.on('singbox-status', (d: any) => onSingboxEvent('singbox-status', d))
try {
electronApi.on('singbox-log', (d: any) => onSingboxEvent('singbox-log', d))
electronApi.on('singbox-error', (d: any) => onSingboxEvent('singbox-error', d))
electronApi.on('singbox-status', (d: any) => onSingboxEvent('singbox-status', d))
} catch (e) {
console.warn('Electron API events not available', e)
}
})
@@ -505,10 +503,14 @@ watch(isRunning, (running) => {
onUnmounted(() => {
pauseSampling()
pauseDuration()
// @ts-ignore
if (window.electron?.off) {
try {
// Logic for cleanup if necessary
}
// electronApi.off is defined as function in types, so no need to check existence if we trust types.
// However, if we want to be safe at runtime:
if (typeof electronApi.off === 'function') {
// ...
}
} catch (e) { }
})
</script>
+175 -129
View File
@@ -50,86 +50,177 @@
</div>
<!-- Filters -->
<div class="flex gap-2 mb-6 overflow-x-auto pb-2">
<div class="flex gap-2 mb-6 overflow-x-auto pb-2 no-scrollbar">
<button v-for="cat in categories" :key="cat.value" @click="activeCategory = cat.value"
class="px-4 py-1 rounded-full text-sm border transition whitespace-nowrap"
:class="activeCategory === cat.value ? 'bg-primary border-primary text-on-primary' : 'border-border text-on-surface-muted hover:border-border-hover hover:text-on-surface'">
class="px-4 py-1.5 rounded-full text-sm border transition whitespace-nowrap font-medium"
:class="activeCategory === cat.value ? 'bg-primary border-primary text-on-primary shadow-md shadow-primary/20' : 'bg-surface border-border text-on-surface-muted hover:border-primary/50 hover:text-on-surface'">
{{ cat.label }}
</button>
</div>
<div v-if="acceleratingGame"
class="mb-4 px-4 py-3 rounded-xl border border-success/30 bg-success/10 text-success flex items-center gap-2">
<div class="w-2 h-2 rounded-full bg-success animate-pulse"></div>
<span class="text-sm font-semibold">正在加速:{{ acceleratingGame.name }}</span>
<span class="text-xs text-success/80">加速期间不可切换其它游戏</span>
class="mb-6 rounded-xl border border-success/20 bg-gradient-to-r from-success/10 to-transparent p-4 flex items-center justify-between shadow-sm backdrop-blur-sm relative overflow-hidden group">
<div class="absolute inset-0 bg-success/5 opacity-0 group-hover:opacity-100 transition-opacity"></div>
<div class="flex items-center gap-4 relative z-10">
<div class="w-10 h-10 rounded-full bg-success/20 flex items-center justify-center text-success">
<div class="i-carbon-rocket text-xl animate-pulse"></div>
</div>
<div>
<div class="text-xs text-success/80 font-bold uppercase tracking-wider mb-0.5">当前正在加速</div>
<div class="text-lg font-bold text-on-surface flex items-center gap-2">
{{ acceleratingGame.name }}
<n-tag size="small" type="success" :bordered="false" class="text-xs">
{{ acceleratingGame.latency ? acceleratingGame.latency + ' ms' : '已连接' }}
</n-tag>
</div>
</div>
</div>
<n-button type="error" size="small" secondary round @click="stopAcceleration(acceleratingGame.id!)"
:loading="gameStore.operationState === 'stopping'" :disabled="isActionPending">
停止加速
</n-button>
</div>
<!-- Game Grid/List -->
<div class="flex-1 overflow-y-auto min-h-0 pr-2">
<div class="flex-1 overflow-y-auto min-h-0 pr-2 custom-scrollbar">
<div v-if="filteredGames.length === 0"
class="h-full flex flex-col items-center justify-center text-on-surface-muted">
<div class="i-carbon-game-console text-4xl mb-2"></div>
<p>{{ $t('games.no_games_found') }}</p>
<div class="i-carbon-game-console text-6xl mb-4 opacity-50"></div>
<p class="text-lg">{{ $t('games.no_games_found') }}</p>
</div>
<div v-else
:class="viewMode === 'grid' ? 'grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4' : 'flex flex-col gap-2'">
<div v-for="game in filteredGames" :key="game.id" @click="game.id && selectGame(game.id!)"
class="bg-surface-panel border rounded-lg transition group relative"
:class="[
isAccelerationLockedFor(game) ? 'border-border cursor-not-allowed opacity-60' : 'cursor-pointer hover:border-primary',
game.status === 'accelerating' ? 'ring-2 ring-success/40 border-success/50 shadow-[0_0_0_1px_rgba(var(--rgb-success),0.2)]' : 'border-transparent',
viewMode === 'grid' ? 'p-4 flex flex-col items-center text-center' : 'p-3 pr-24 flex items-center gap-4'
<div v-else class="mt-1"
:class="viewMode === 'grid' ? 'grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4' : 'flex flex-col gap-2'">
<div v-for="game in filteredGames" :key="game.id" @click="game.id && handleGameClick(game)"
class="group relative bg-surface border rounded-xl transition-all duration-300 overflow-hidden" :class="[
isAccelerationLockedFor(game) ? 'opacity-50 grayscale cursor-not-allowed border-border' : 'cursor-pointer hover:-translate-y-1 hover:shadow-xl hover:border-primary/50',
game.status === 'accelerating' ? 'border-success shadow-[0_0_15px_rgba(var(--rgb-success),0.2)] ring-1 ring-success/30' : 'border-border/50',
viewMode === 'grid' ? 'flex flex-col' : 'flex items-center p-3 gap-4'
]">
<!-- Action Buttons (Hover) -->
<div class="absolute flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity"
:class="viewMode === 'grid' ? 'top-2 right-2' : 'right-3 top-1/2 -translate-y-1/2'">
<n-button quaternary circle size="small" @click.stop="editGame(game)" :title="$t('common.edit')">
<template #icon>
<div class="i-carbon-edit text-xs"></div>
</template>
</n-button>
<n-button quaternary circle size="small" type="error" @click.stop="deleteGame(game)"
:title="$t('common.delete')">
<template #icon>
<div class="i-carbon-trash-can text-xs"></div>
</template>
</n-button>
</div>
<div
class="bg-surface rounded-full flex items-center justify-center text-on-surface-muted group-hover:text-primary transition"
:class="viewMode === 'grid' ? 'w-16 h-16 text-3xl mb-3' : 'w-10 h-10 text-xl'">
<img v-if="game.iconUrl" :src="game.iconUrl" class="w-full h-full object-cover rounded-full" />
<div v-else class="i-carbon-game-console"></div>
</div>
<!-- Grid View Image Area -->
<div v-if="viewMode === 'grid'" class="aspect-[16/9] relative bg-surface-variant/50 overflow-hidden">
<!-- Background Image/Icon -->
<div class="absolute inset-0 flex items-center justify-center">
<img v-if="game.iconUrl" :src="game.iconUrl"
class="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110" />
<div v-else class="i-carbon-game-console text-5xl text-on-surface-muted/30"></div>
</div>
<div class="flex-1 min-w-0">
<h3 class="font-bold truncate text-on-surface" :class="viewMode === 'grid' ? 'text-base' : 'text-sm'">{{
game.name }}</h3>
<div class="text-xs text-on-surface-muted mt-1 flex items-center justify-center gap-2"
:class="viewMode === 'list' && '!justify-start'">
<span class="bg-surface px-2 rounded">{{ getCategoryLabel(game.category) }}</span>
<span v-if="game.lastPlayed" class="text-on-surface-variant">{{ formatTime(game.lastPlayed) }}</span>
<!-- Gradient Overlay -->
<div class="absolute inset-0 bg-gradient-to-t from-surface via-transparent to-transparent opacity-80"></div>
<!-- Hover Overlay with Actions -->
<div
class="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-all duration-300 flex items-center justify-center gap-3 backdrop-blur-[2px]"
v-if="!isAccelerationLockedFor(game)">
<template v-if="game.status !== 'accelerating'">
<button
class="w-10 h-10 rounded-full bg-primary text-on-primary flex items-center justify-center hover:scale-110 transition shadow-lg shadow-primary/40"
@click.stop="tryStartGame(game)" title="开始加速" :disabled="isActionPending">
<div class="i-carbon-play-filled text-xl"></div>
</button>
<button
class="w-8 h-8 rounded-full bg-surface text-on-surface hover:text-primary flex items-center justify-center hover:scale-110 transition"
@click.stop="editGame(game)" title="设置">
<div class="i-carbon-settings"></div>
</button>
<button
class="w-8 h-8 rounded-full bg-surface text-on-surface hover:text-error flex items-center justify-center hover:scale-110 transition"
@click.stop="deleteGame(game)" title="删除">
<div class="i-carbon-trash-can"></div>
</button>
</template>
<template v-else>
<button
class="px-4 py-1.5 rounded-full bg-error text-on-error font-bold text-sm hover:bg-error-hover transition shadow-lg"
@click.stop="stopAcceleration(game.id!)" :disabled="isActionPending">
停止加速
</button>
</template>
</div>
<!-- Status Badge (Top Right) -->
<div class="absolute top-2 right-2 flex gap-1">
<div v-if="game.status === 'accelerating'"
class="px-2 py-0.5 rounded text-[10px] font-bold bg-success text-on-success shadow-sm flex items-center gap-1">
<div class="w-1.5 h-1.5 bg-white rounded-full animate-pulse"></div>
加速中
</div>
<div class="px-2 py-0.5 rounded text-[10px] font-bold shadow-sm uppercase backdrop-blur-md"
:class="game.proxyMode === 'routing' ? 'bg-info/20 text-info border border-info/30' : 'bg-warning/20 text-warning border border-warning/30'">
{{ game.proxyMode === 'routing' ? '路由模式' : '进程模式' }}
</div>
</div>
</div>
<div v-if="game.status === 'accelerating'"
class="text-success text-xs font-extrabold flex items-center gap-1">
<div class="w-2 h-2 rounded-full bg-success animate-pulse"></div>
正在加速
<!-- List View Image -->
<div v-else
class="w-12 h-12 rounded-lg bg-surface-variant/50 flex-shrink-0 overflow-hidden relative group-hover:ring-2 ring-primary/20">
<img v-if="game.iconUrl" :src="game.iconUrl" class="w-full h-full object-cover" />
<div v-else class="w-full h-full flex items-center justify-center">
<div class="i-carbon-game-console text-xl text-on-surface-muted"></div>
</div>
</div>
<div v-else-if="game.id === gameStore.currentGameId"
class="text-primary text-xs font-bold flex items-center gap-1">
<div class="w-2 h-2 rounded-full bg-primary"></div>
{{ $t('games.selected') }}
<!-- Content -->
<div class="flex-1 min-w-0" :class="viewMode === 'grid' ? 'p-3' : ''">
<div class="flex items-center justify-between mb-1">
<h3 class="font-bold text-on-surface truncate" :class="viewMode === 'grid' ? 'text-base' : 'text-sm'">
{{ game.name }}
</h3>
<!-- List View Badges -->
<div v-if="viewMode === 'list'" class="flex gap-2">
<n-tag size="small" :type="game.proxyMode === 'routing' ? 'info' : 'warning'" :bordered="false"
class="text-[10px] h-5">
{{ game.proxyMode === 'routing' ? '路由模式' : '进程模式' }}
</n-tag>
<n-tag v-if="game.status === 'accelerating'" type="success" size="small" :bordered="false" class="h-5">
加速中
</n-tag>
</div>
</div>
<div class="flex items-center justify-between text-xs text-on-surface-muted">
<div class="flex items-center gap-2">
<span class="bg-surface-variant px-1.5 py-0.5 rounded text-[10px]">{{ getCategoryLabel(game.category)
}}</span>
<span v-if="game.lastPlayed">{{ formatTime(game.lastPlayed) }}</span>
</div>
<div v-if="game.status === 'accelerating' && game.latency !== undefined"
class="font-mono text-success font-bold">
{{ game.latency }} ms
</div>
</div>
</div>
<div v-else-if="game.id && runningGames.includes(game.id)"
class="text-success text-xs font-bold flex items-center gap-1">
<div class="w-2 h-2 rounded-full bg-success animate-pulse"></div>
{{ $t('games.running') }}
<!-- List View Actions -->
<div v-if="viewMode === 'list'"
class="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity px-2">
<template v-if="game.status !== 'accelerating'">
<n-button circle type="primary" size="small" @click.stop="tryStartGame(game)" :disabled="isActionPending">
<template #icon>
<div class="i-carbon-play-filled"></div>
</template>
</n-button>
<n-button circle quaternary size="small" @click.stop="editGame(game)" title="设置">
<template #icon>
<div class="i-carbon-settings"></div>
</template>
</n-button>
<n-button circle quaternary size="small" @click.stop="deleteGame(game)" title="删除">
<template #icon>
<div class="i-carbon-trash-can"></div>
</template>
</n-button>
</template>
<n-button v-else type="error" size="small" round @click.stop="stopAcceleration(game.id!)"
:loading="gameStore.operationState === 'stopping'" :disabled="isActionPending">
停止加速
</n-button>
</div>
</div>
</div>
</div>
@@ -150,6 +241,7 @@ import { useTimeAgo } from '@vueuse/core'
import { useMessage, useDialog } from 'naive-ui'
import AdvancedConfigEditor from '@/components/library/AdvancedConfigEditor.vue'
import CategoryManager from '@/components/library/CategoryManager.vue'
import { useGameScanner } from '@/composables/useGameScanner'
const { t } = useI18n()
const message = useMessage()
@@ -161,15 +253,10 @@ const categoryStore = useCategoryStore()
const viewMode = ref<'grid' | 'list'>('grid')
const searchQuery = ref('')
const activeCategory = ref('all')
const isScanning = ref(false)
const { isScanning, scanGames } = useGameScanner()
const runningGames = computed(() => gameStore.runningGames)
const acceleratingGame = computed(() => gameStore.getAcceleratingGame())
type LocalScanGame = {
name: string
processName: string
source: 'steam' | 'microsoft' | 'epic' | 'ea'
installDir: string
}
const isActionPending = computed(() => gameStore.operationState !== 'idle')
const showEditModal = ref(false)
const showCategoryManager = ref(false)
@@ -212,70 +299,6 @@ const filteredGames = computed(() => {
})
})
async function scanGames() {
isScanning.value = true
try {
const localGames = await window.system.scanLocalGames()
const added = await autoAddGamesFromLibraryScan(localGames)
const processes = await window.system.scanProcesses()
const matched = gameStore.matchRunningGames(processes)
if (added > 0) {
message.success(`扫描完成:新增 ${added} 个游戏,当前运行匹配 ${matched.length} 个。`)
} else if (matched.length > 0) {
message.info(`扫描完成:未新增游戏,当前运行匹配 ${matched.length} 个。`)
} else {
message.info('扫描完成:未发现可新增的本地游戏。')
}
} catch (error) {
console.error('Failed to scan games:', error)
message.error('扫描失败,请重试。')
} finally {
setTimeout(() => { isScanning.value = false }, 500)
}
}
function getFallbackCategoryId() {
return categoryStore.categories[0]?.id || 'other'
}
async function autoAddGamesFromLibraryScan(localGames: LocalScanGame[]) {
const existingGameKeys = new Set(
gameStore.gameLibrary.flatMap((game: Game) => {
const targets = Array.isArray(game.processName) ? game.processName : [game.processName]
return targets.map(name => `${String(game.name).toLowerCase()}|${String(name).toLowerCase()}`)
})
)
const seen = new Set<string>()
const candidates = localGames
.filter(g => !!g.name && !!g.processName)
.filter(g => {
const key = `${g.name.toLowerCase()}|${g.processName.toLowerCase()}`
if (seen.has(key)) return false
seen.add(key)
return !existingGameKeys.has(key)
})
const categoryId = getFallbackCategoryId()
let added = 0
for (const item of candidates) {
await gameStore.addGame({
name: item.name,
processName: [item.processName],
category: categoryId,
status: 'idle',
latency: 0,
proxyMode: 'process',
chainProxy: true
})
added += 1
}
return added
}
function selectGame(id: string) {
const ok = gameStore.setCurrentGame(id)
if (!ok) {
@@ -286,6 +309,29 @@ function selectGame(id: string) {
router.push('/dashboard')
}
function tryStartGame(game: Game) {
if (isActionPending.value) return
if (game.id) selectGame(game.id)
}
function handleGameClick(game: Game) {
if (game.status === 'accelerating') {
router.push('/dashboard')
} else {
tryStartGame(game)
}
}
async function stopAcceleration(id: string) {
if (isActionPending.value) return
try {
await gameStore.stopGame(id)
message.success('已停止加速')
} catch (e) {
message.error('停止失败')
}
}
function isAccelerationLockedFor(game: Game) {
const active = acceleratingGame.value
return !!active && game.id !== active.id
+15 -764
View File
@@ -14,47 +14,7 @@
<div class="relative z-10 flex w-full h-full max-w-7xl mx-auto p-4 md:p-8 gap-8">
<!-- Sidebar Navigation -->
<aside class="w-64 flex-none flex flex-col gap-6">
<div class="px-2">
<h1 class="text-3xl font-bold bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent">
{{ $t('common.settings') }}
</h1>
<p class="text-xs text-on-surface-muted mt-1 uppercase tracking-widest font-bold opacity-60">
{{ $t('settings.preferences') }}
</p>
</div>
<nav class="flex flex-col gap-2">
<button v-for="tab in tabs" :key="tab.name" @click="activeTab = tab.name"
class="flex items-center gap-3 px-4 py-3 rounded-xl transition-all duration-300 group relative overflow-hidden text-left"
:class="activeTab === tab.name
? 'bg-primary/10 text-primary font-bold shadow-sm'
: 'hover:bg-surface-overlay text-on-surface-muted hover:text-on-surface'">
<!-- Active Indicator -->
<div v-if="activeTab === tab.name"
class="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-8 bg-primary rounded-r-full"></div>
<div
:class="[tab.icon, activeTab === tab.name ? 'text-primary' : 'text-on-surface-muted group-hover:text-on-surface']"
class="text-xl transition-colors"></div>
<span>{{ tab.label }}</span>
</button>
</nav>
<!-- App Info Small -->
<div class="mt-auto px-4 py-4 rounded-xl bg-surface-panel/30 border border-border/50 backdrop-blur-sm">
<div class="flex items-center gap-3 mb-2">
<div class="w-8 h-8 rounded-lg bg-surface-panel flex items-center justify-center border border-border">
<img src="/logo.svg" class="w-5 h-5" alt="Logo" />
</div>
<div>
<div class="text-sm font-bold">{{ pkg.name }}</div>
<div class="text-[10px] text-on-surface-muted font-mono">v{{ appVersion }}</div>
</div>
</div>
</div>
</aside>
<SettingsSidebar v-model="activeTab" />
<!-- Main Content Area -->
<main
@@ -62,380 +22,16 @@
<div class="absolute inset-0 overflow-y-auto custom-scrollbar p-8">
<Transition name="fade-slide" mode="out-in">
<!-- General Settings -->
<div v-if="activeTab === 'general'" class="space-y-8 max-w-3xl animate-fade-in-up">
<!-- Language Section -->
<section>
<h2 class="text-xl font-bold mb-4 flex items-center gap-2">
<div class="i-material-symbols-language text-primary"></div>
{{ $t('settings.language') }}
</h2>
<div class="bg-surface/50 border border-border/50 rounded-2xl p-1 flex gap-2 w-fit backdrop-blur-sm">
<button v-for="localeItem in availableLocales" :key="localeItem.code"
@click="setLanguage(localeItem.code)"
class="px-6 py-2.5 rounded-xl transition-all duration-200 font-medium relative overflow-hidden"
:class="currentLocale === localeItem.code
? 'bg-surface text-primary shadow-sm ring-1 ring-border/50'
: 'text-on-surface-muted hover:text-on-surface hover:bg-surface-overlay/50'">
{{ localeItem.name }}
</button>
</div>
</section>
<div class="w-full h-px bg-border/30"></div>
<!-- Theme Section -->
<section>
<h2 class="text-xl font-bold mb-4 flex items-center gap-2">
<div class="i-material-symbols-palette-outline text-primary"></div>
{{ $t('settings.theme_color') }}
</h2>
<div class="bg-surface-panel/50 border border-border/50 rounded-2xl p-6 backdrop-blur-sm">
<div class="flex flex-wrap gap-4">
<button v-for="color in themeColors" :key="color.value" @click="setThemeColor(color.value)"
class="group relative w-12 h-12 rounded-full flex items-center justify-center transition-all duration-300"
:class="[
color.bg,
themeColor === color.value ? 'scale-110 shadow-lg ring-2 ring-offset-2 ring-offset-surface ring-current' : 'opacity-70 hover:opacity-100 hover:scale-105'
]" :title="color.label">
<div v-if="themeColor === color.value"
class="i-material-symbols-check text-white font-bold text-xl animate-scale-in">
</div>
</button>
</div>
<p class="mt-4 text-sm text-on-surface-muted">
{{ $t('settings.theme_desc') }}
</p>
</div>
</section>
</div>
<GeneralSettings v-if="activeTab === 'general'" />
<!-- Network Settings -->
<div v-else-if="activeTab === 'network'" class="space-y-8 max-w-3xl animate-fade-in-up">
<NetworkSettings v-else-if="activeTab === 'network'" />
<!-- Latency Check -->
<section>
<h2 class="text-xl font-bold mb-4 flex items-center gap-2">
<div class="i-material-symbols-speed text-primary"></div>
{{ $t('settings.network') }}
</h2>
<div class="bg-surface-panel/50 border border-border/50 rounded-2xl p-6 backdrop-blur-sm space-y-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
<div class="space-y-3">
<label class="text-xs font-bold uppercase tracking-widest text-on-surface-muted">{{
$t('settings.check_method') }}</label>
<div class="flex bg-surface-overlay/50 p-1 rounded-xl border border-border/30">
<button v-for="method in ['ping', 'tcp']" :key="method" @click="setCheckMethod(method)"
class="flex-1 py-2 text-sm rounded-lg transition-all font-bold" :class="settingsStore.checkMethod === method
? 'bg-surface shadow-sm text-primary'
: 'text-on-surface-muted hover:text-on-surface'">
{{ method.toUpperCase() }}
</button>
</div>
</div>
<div class="space-y-3">
<label class="text-xs font-bold uppercase tracking-widest text-on-surface-muted">{{
$t('settings.check_interval') }}</label>
<n-select v-model:value="settingsStore.checkInterval" :options="intervalOptions"
class="glass-select" />
</div>
</div>
</div>
</section>
<div class="w-full h-px bg-border/30"></div>
<!-- DNS Settings -->
<section>
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-bold flex items-center gap-2">
<div class="i-material-symbols-dns-outline text-primary"></div>
{{ $t('settings.dns_config') }}
</h2>
<div class="flex bg-surface-overlay/50 rounded-lg p-1 border border-border/30">
<button @click="settingsStore.dnsMode = 'secure'"
class="px-3 py-1 text-xs rounded-md transition font-medium" :class="settingsStore.dnsMode === 'secure'
? 'bg-surface shadow-sm text-primary'
: 'text-on-surface-muted hover:text-on-surface'">
{{ $t('settings.dns_mode_secure') }}
</button>
<button @click="settingsStore.dnsMode = 'system'"
class="px-3 py-1 text-xs rounded-md transition font-medium" :class="settingsStore.dnsMode === 'system'
? 'bg-surface shadow-sm text-primary'
: 'text-on-surface-muted hover:text-on-surface'">
{{ $t('settings.dns_mode_system') }}
</button>
</div>
</div>
<div
class="bg-surface-panel/50 border border-border/50 rounded-2xl p-6 backdrop-blur-sm transition-opacity duration-300"
:class="{ 'opacity-50 pointer-events-none grayscale': settingsStore.dnsMode === 'system' }">
<div class="space-y-4">
<div class="space-y-2">
<label class="text-xs font-bold uppercase tracking-widest text-on-surface-muted">{{
$t('settings.dns_primary') }}</label>
<n-input v-model:value="settingsStore.dnsPrimary" placeholder="https://dns.google/dns-query"
class="glass-input" />
</div>
<div class="space-y-2">
<label class="text-xs font-bold uppercase tracking-widest text-on-surface-muted">{{
$t('settings.dns_secondary') }}</label>
<n-input v-model:value="settingsStore.dnsSecondary" placeholder="https://1.1.1.1/dns-query"
class="glass-input" />
</div>
</div>
</div>
</section>
<div class="w-full h-px bg-border/30"></div>
<!-- Local Proxy -->
<section>
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-bold flex items-center gap-2">
<div class="i-material-symbols-lan text-primary"></div>
{{ $t('settings.local_proxy') }}
</h2>
<n-switch v-model:value="settingsStore.localProxyEnabled">
<template #checked>{{ $t('common.enabled') }}</template>
<template #unchecked>{{ $t('common.disabled') }}</template>
</n-switch>
</div>
<div
class="bg-surface-panel/50 border border-border/50 rounded-2xl p-6 backdrop-blur-sm transition-opacity duration-300"
:class="{ 'opacity-50 pointer-events-none': !settingsStore.localProxyEnabled }">
<div class="space-y-2">
<label class="text-xs font-bold uppercase tracking-widest text-on-surface-muted">{{
$t('settings.local_proxy_port') }} (HTTP)</label>
<n-input-number v-model:value="settingsStore.localProxyPort" :min="1024" :max="65535"
class="glass-select" button-placement="both" />
<p class="text-xs text-on-surface-muted mt-1">{{ $t('settings.local_proxy_info', {
socks:
settingsStore.localProxyPort + 1 }) }}</p>
</div>
<div class="space-y-2 pt-2">
<div class="flex items-center justify-between">
<label class="text-xs font-bold uppercase tracking-widest text-on-surface-muted">
{{ $t('settings.local_proxy_node_strategy') }}
</label>
<n-switch v-model:value="settingsStore.localProxyNodeRecursiveTest" size="small" />
</div>
<p class="text-xs text-on-surface-muted">
{{ $t('settings.local_proxy_node_recursive') }}
</p>
</div>
<div class="space-y-2" :class="{ 'opacity-50 pointer-events-none': settingsStore.localProxyNodeRecursiveTest }">
<label class="text-xs font-bold uppercase tracking-widest text-on-surface-muted">
{{ $t('settings.local_proxy_fixed_node_index') }}
</label>
<n-input-number v-model:value="settingsStore.localProxyFixedNodeIndex" :min="1" :max="9999"
class="glass-select" button-placement="both" />
</div>
</div>
</section>
<div class="w-full h-px bg-border/30"></div>
<!-- Advanced Network -->
<section>
<h2 class="text-xl font-bold mb-4 flex items-center gap-2">
<div class="i-material-symbols-settings-ethernet text-primary"></div>
{{ $t('common.more') }}
</h2>
<div class="bg-surface-panel/50 border border-border/50 rounded-2xl p-6 backdrop-blur-sm space-y-6">
<div class="space-y-2">
<label class="text-xs font-bold uppercase tracking-widest text-on-surface-muted">{{
$t('settings.tun_interface') }}</label>
<n-input v-model:value="settingsStore.tunInterfaceName" placeholder="LagZero" class="glass-input" />
</div>
<div class="flex flex-wrap gap-4 pt-2">
<n-button secondary type="warning" @click="handleReinstallTun" :loading="working"
:disabled="working" class="glass-button">
<template #icon>
<div class="i-material-symbols-refresh"></div>
</template>
{{ $t('settings.tun_reinstall') }}
</n-button>
<n-button secondary type="info" @click="handleFlushDns" :loading="working" :disabled="working"
class="glass-button">
<template #icon>
<div class="i-material-symbols-cleaning-services"></div>
</template>
{{ $t('settings.dns_flush') }}
</n-button>
</div>
<div v-if="opMessage"
class="px-4 py-3 rounded-xl text-sm flex items-center gap-3 border animate-scale-in"
:class="opOk ? 'bg-green-500/10 text-green-500 border-green-500/20' : 'bg-red-500/10 text-red-500 border-red-500/20'">
<div :class="opOk ? 'i-material-symbols-check-circle text-lg' : 'i-material-symbols-error text-lg'">
</div>
{{ opMessage }}
</div>
</div>
</section>
</div>
<div v-else-if="activeTab === 'logs'" class="space-y-6 animate-fade-in-up">
<section>
<h2 class="text-xl font-bold mb-4 flex items-center gap-2">
<div class="i-material-symbols-article-outline text-primary"></div>
{{ $t('settings.runtime_logs') }}
</h2>
<div class="bg-surface-panel/50 border border-border/50 rounded-2xl p-5 backdrop-blur-sm space-y-4">
<div class="grid grid-cols-1 lg:grid-cols-[1fr_1fr_2fr_auto] gap-3">
<n-select v-model:value="logCategory" :options="logCategoryOptions" />
<n-select v-model:value="logLevel" :options="logLevelOptions" />
<n-input v-model:value="logKeyword" :placeholder="$t('settings.logs_search_placeholder')"
clearable />
<n-button secondary @click="clearLogs">{{ $t('settings.logs_clear') }}</n-button>
</div>
<div class="text-xs text-on-surface-muted">
{{ $t('settings.logs_count_info', { filtered: filteredLogs.length, total: logEntries.length }) }}
</div>
<div
class="rounded-xl border border-border/60 bg-black/30 h-[520px] overflow-y-auto custom-scrollbar">
<div v-if="filteredLogs.length === 0"
class="h-full flex items-center justify-center text-sm text-on-surface-muted">
{{ $t('settings.logs_empty') }}
</div>
<div v-else class="divide-y divide-white/5">
<div v-for="row in filteredLogs" :key="row.id"
class="flex gap-4 px-4 py-3 font-mono text-sm border-l-[4px]" :class="[
row.level === 'error' ? 'border-[#ff4d4f] bg-[#ff4d4f]/10' :
row.level === 'warn' ? 'border-[#faad14] bg-[#faad14]/10' :
row.level === 'debug' ? 'border-[#8c8c8c] bg-[#8c8c8c]/5' :
'border-[#52c41a] bg-[#52c41a]/10'
]">
<!-- Left Side: Fixed Meta -->
<div class="flex-none flex flex-col items-center gap-2 w-20">
<span class="text-[11px] font-bold tracking-tighter ">
{{ formatLogTime(row.timestamp) }}
</span>
<span
class="w-full text-center py-0.5 rounded text-[10px] font-black uppercase tracking-widest shadow-sm"
:class="logLevelLabelClass(row.level)">
{{ row.level }}
</span>
</div>
<!-- Right Side: Content -->
<div class="flex-1 min-w-0 space-y-1">
<div class="flex items-center gap-2">
<span
class="text-[10px] font-black px-1.5 py-0.5 rounded bg-white/5 text-primary/80 uppercase tracking-tighter shadow-inner">
{{ $t('settings.logs_category_' + row.category) }}
</span>
<span class="text-[10px] text-white dark:text-gray-400 italic truncate">
{{ row.source }}
</span>
</div>
<div class="break-words leading-relaxed font-medium text-white dark:text-gray-400"
:class="logMessageTextClass(row.level)">
{{ row.message }}
</div>
<div v-if="row.detail"
class="mt-2 p-3 rounded-lg text-white dark:text-gray-400 bg-black/40 border border-white/5 text-xs break-all whitespace-pre-wrap leading-relaxed shadow-inner">
{{ row.detail }}
</div>
</div>
</div>
</div>
</div>
</div>
</section>
</div>
<!-- Logs -->
<LogsSettings v-else-if="activeTab === 'logs'" />
<!-- About Tab -->
<div v-else-if="activeTab === 'about'"
class="flex flex-col items-center justify-center min-h-[400px] text-center animate-fade-in-up">
<div class="relative mb-8 group">
<div
class="absolute inset-0 bg-primary/20 blur-3xl rounded-full scale-150 group-hover:scale-175 transition-transform duration-700">
</div>
<img src="/logo.svg"
class="relative w-32 h-32 drop-shadow-2xl transition-transform duration-500 group-hover:scale-110"
alt="LagZero Logo" />
</div>
<h1
class=" h-14 text-4xl font-black bg-gradient-to-br from-primary via-purple-500 to-secondary bg-clip-text text-transparent mb-2">
{{ pkg.name }}
</h1>
<p class="text-on-surface-muted mb-8 tracking-wide">{{ $t('settings.tagline') }}</p>
<div class="grid gap-3 w-full max-w-xs">
<div
class="bg-surface-overlay/50 border border-border/50 rounded-xl p-3 flex justify-between items-center mb-4 backdrop-blur-sm">
<span class="text-sm font-medium text-on-surface-muted">{{ $t('settings.current_version') }}</span>
<span class="font-mono font-bold text-primary bg-primary/10 px-2 py-1 rounded">v{{ appVersion
}}</span>
</div>
<n-button type="primary" block secondary size="large" @click="checkUpdate" :loading="checkingUpdate"
class="glass-button-primary">
<template #icon>
<div class="i-material-symbols-update"></div>
</template>
{{ $t('settings.check_update') }}
</n-button>
<div class="grid grid-cols-2 gap-3">
<n-button block secondary @click="openReleasesUrl" class="glass-button">
<template #icon>
<div class="i-material-symbols-history"></div>
</template>
{{ $t('settings.releases') }}
</n-button>
<n-button block secondary @click="openProjectUrl" class="glass-button">
<template #icon>
<div class="i-carbon-logo-github"></div>
</template>
GitHub
</n-button>
</div>
</div>
<div v-if="updateInfo"
class="mt-8 w-full max-w-md text-left bg-surface-panel/80 backdrop-blur-md border border-border p-5 rounded-2xl text-sm space-y-3 animate-scale-in shadow-xl">
<div v-if="updateInfo.error" class="text-red-500 flex items-center gap-2">
<div class="i-material-symbols-error text-xl"></div>
{{ $t('settings.update_error', { msg: updateInfo.error }) }}
</div>
<div v-else-if="updateInfo.available" class="space-y-3">
<div class="text-green-500 font-bold flex items-center gap-2 text-base">
<div class="i-material-symbols-rocket-launch"></div>
{{ $t('settings.update_available', { version: updateInfo.version }) }}
</div>
<div v-if="updateInfo.note"
class="text-on-surface-muted text-xs whitespace-pre-wrap max-h-40 overflow-y-auto bg-surface/50 p-3 rounded-lg border border-border/50 font-mono">
{{ updateInfo.note }}
</div>
<n-button type="primary" size="small" block class="glass-button-primary mt-2"
@click="openReleasesUrl">
<template #icon>
<div class="i-material-symbols-download"></div>
</template>
{{ $t('settings.download_update') }}
</n-button>
<div class="text-xs text-on-surface-muted text-right">{{ updateInfo.date }}</div>
</div>
<div v-else class="text-on-surface-muted flex items-center justify-center gap-2 py-2">
<div class="i-material-symbols-check-circle text-green-500 text-lg"></div>
{{ $t('settings.no_update') }}
</div>
</div>
</div>
<AboutSettings v-else-if="activeTab === 'about'" />
</Transition>
</div>
</main>
@@ -444,364 +40,19 @@
</template>
<script setup lang="ts">
import { computed, ref, onMounted, onUnmounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useTheme, type ThemeColor } from '@/composables/useTheme'
import { useSettingsStore } from '@/stores/settings'
import { useLocalProxyStore } from '@/stores/local-proxy'
import { NSelect, NInput, NButton, NSwitch, NInputNumber } from 'naive-ui'
import pkg from '../../../package.json'
const { locale, t } = useI18n()
const { themeColor, setThemeColor } = useTheme()
const settingsStore = useSettingsStore()
const localProxyStore = useLocalProxyStore()
import { ref, onMounted } from 'vue'
import { useAppUpdater } from '@/composables/useAppUpdater'
import SettingsSidebar from '@/components/settings/SettingsSidebar.vue'
import GeneralSettings from '@/components/settings/GeneralSettings.vue'
import NetworkSettings from '@/components/settings/NetworkSettings.vue'
import LogsSettings from '@/components/settings/LogsSettings.vue'
import AboutSettings from '@/components/settings/AboutSettings.vue'
const activeTab = ref('general')
const tabs = computed(() => [
{ name: 'general', label: t('settings.general'), icon: 'i-material-symbols-tune' },
{ name: 'network', label: t('settings.network'), icon: 'i-material-symbols-wifi' },
{ name: 'logs', label: t('settings.logs'), icon: 'i-material-symbols-article' },
{ name: 'about', label: t('settings.about'), icon: 'i-material-symbols-info-outline' },
])
const working = ref(false)
const opMessage = ref('')
const opOk = ref(true)
const appVersion = ref('0.0.0')
const checkingUpdate = ref(false)
const updateInfo = ref<{ available: boolean, version?: string, date?: string, note?: string, error?: string } | null>(null)
type AppLogEntry = {
id: string
timestamp: number
level: 'debug' | 'info' | 'warn' | 'error'
category: 'frontend' | 'backend' | 'core'
source: string
message: string
detail?: string
}
const logEntries = ref<AppLogEntry[]>([])
const logCategory = ref<'all' | 'frontend' | 'backend' | 'core'>('all')
const logLevel = ref<'all' | 'debug' | 'info' | 'warn' | 'error'>('all')
const logKeyword = ref('')
const logCategoryOptions = computed(() => [
{ label: t('settings.logs_all_categories'), value: 'all' },
{ label: t('settings.logs_category_frontend'), value: 'frontend' },
{ label: t('settings.logs_category_backend'), value: 'backend' },
{ label: t('settings.logs_category_core'), value: 'core' },
])
const logLevelOptions = computed(() => [
{ label: t('settings.logs_all_levels'), value: 'all' },
{ label: 'Debug', value: 'debug' },
{ label: 'Info', value: 'info' },
{ label: 'Warn', value: 'warn' },
{ label: 'Error', value: 'error' },
])
const filteredLogs = computed(() => {
const keyword = logKeyword.value.trim().toLowerCase()
return logEntries.value
.filter((row) => {
if (logCategory.value !== 'all' && row.category !== logCategory.value) return false
if (logLevel.value !== 'all' && row.level !== logLevel.value) return false
if (!keyword) return true
const text = `${row.message} ${row.detail || ''} ${row.source}`.toLowerCase()
return text.includes(keyword)
})
.slice()
.reverse()
})
const onLogNew = (row: AppLogEntry) => {
logEntries.value.push(row)
if (logEntries.value.length > 3000) {
logEntries.value.splice(0, logEntries.value.length - 3000)
}
}
const currentLocale = computed(() => locale.value)
const availableLocales = [
{ code: 'zh-CN', name: '简体中文' },
{ code: 'en-US', name: 'English' }
]
const themeColors: { value: ThemeColor, label: string, bg: string }[] = [
{ value: 'green', label: 'Green', bg: 'bg-[#3eaf7c]' },
{ value: 'blue', label: 'Blue', bg: 'bg-[#3b82f6]' },
{ value: 'purple', label: 'Purple', bg: 'bg-[#8b5cf6]' },
{ value: 'orange', label: 'Orange', bg: 'bg-[#f97316]' },
{ value: 'red', label: 'Red', bg: 'bg-[#ef4444]' }
]
const intervalOptions = [
{ label: '1s', value: 1000 },
{ label: '2s', value: 2000 },
{ label: '5s', value: 5000 },
{ label: '10s', value: 10000 },
{ label: '30s', value: 30000 },
{ label: '1m', value: 60000 }
]
function setCheckMethod(method: string) {
// @ts-ignore
settingsStore.checkMethod = method
}
function setLanguage(lang: string) {
locale.value = lang
}
async function handleFlushDns() {
if (working.value) return
working.value = true
opMessage.value = ''
try {
const result = await window.system.flushDnsCache()
opOk.value = !!result?.ok
opMessage.value = String(result?.message || '')
} catch (e: any) {
opOk.value = false
opMessage.value = String(e?.message || e || t('settings.dns_flush_failed'))
} finally {
working.value = false
}
}
async function handleReinstallTun() {
if (working.value) return
working.value = true
opMessage.value = ''
try {
const result = await window.system.reinstallTunAdapter(settingsStore.tunInterfaceName || 'LagZero')
opOk.value = !!result?.ok
opMessage.value = String(result?.message || '')
} catch (e: any) {
opOk.value = false
opMessage.value = String(e?.message || e || t('settings.tun_reinstall_failed'))
} finally {
working.value = false
}
}
async function checkUpdate() {
if (checkingUpdate.value) return
checkingUpdate.value = true
updateInfo.value = null
try {
const res = await window.app.checkUpdate()
if (res.error) {
updateInfo.value = { available: false, error: res.error }
} else {
updateInfo.value = {
available: res.updateAvailable,
version: res.version,
date: res.releaseDate,
note: res.releaseNotes
}
}
} catch (e: any) {
updateInfo.value = { available: false, error: e.message }
} finally {
checkingUpdate.value = false
}
}
function openProjectUrl() {
window.app.openUrl('https://github.com/ZenEcho/LagZero')
}
function openReleasesUrl() {
window.app.openUrl('https://github.com/ZenEcho/LagZero/releases')
}
async function loadLogs() {
if (!window.logs) return
try {
const rows = await window.logs.getAll()
logEntries.value = Array.isArray(rows) ? rows : []
} catch {
logEntries.value = []
}
}
async function clearLogs() {
try {
await window.logs.clear()
} finally {
logEntries.value = []
}
}
function formatLogTime(ts: number) {
const date = new Date(ts)
const h = date.getHours().toString().padStart(2, '0')
const m = date.getMinutes().toString().padStart(2, '0')
const s = date.getSeconds().toString().padStart(2, '0')
return `${h}:${m}:${s}`
}
function logLevelLabelClass(level: AppLogEntry['level']) {
switch (level) {
case 'error': return 'bg-red-500 text-white'
case 'warn': return 'bg-amber-500 text-black'
case 'debug': return 'bg-zinc-700 text-zinc-300'
default: return 'bg-emerald-500 text-white'
}
}
function logMessageTextClass(level: AppLogEntry['level']) {
switch (level) {
case 'error': return 'text-[#ff4d4f] font-bold'
case 'warn': return 'text-[#fadb14] font-bold'
case 'debug': return 'text-[#bfbfbf]'
default: return 'text-[#95de64] font-bold'
}
}
const { getVersion } = useAppUpdater()
onMounted(async () => {
if (window.app) {
appVersion.value = await window.app.getVersion()
}
await loadLogs()
window.logs?.onNew(onLogNew)
})
watch(
() => [
settingsStore.localProxyEnabled,
settingsStore.localProxyPort,
settingsStore.localProxyNodeRecursiveTest,
settingsStore.localProxyFixedNodeIndex
],
() => {
void localProxyStore.applySettingsChange()
}
)
onUnmounted(() => {
window.logs?.offNew(onLogNew)
await getVersion()
})
</script>
<style scoped>
/* Animations copied from Dashboard for consistency */
@keyframes pulse-slow {
0%,
100% {
opacity: 0.3;
transform: scale(1);
}
50% {
opacity: 0.5;
transform: scale(1.1);
}
}
.animate-pulse-slow {
animation: pulse-slow 8s infinite ease-in-out;
}
@keyframes fade-in-up {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in-up {
animation: fade-in-up 0.5s ease-out;
}
@keyframes scale-in {
from {
opacity: 0;
transform: scale(0.9);
}
to {
opacity: 1;
transform: scale(1);
}
}
.animate-scale-in {
animation: scale-in 0.4s cubic-bezier(0.16, 1, 0.3, 1);
}
/* Transitions */
.fade-slide-enter-active,
.fade-slide-leave-active {
transition: all 0.3s ease;
}
.fade-slide-enter-from {
opacity: 0;
transform: translateX(10px);
}
.fade-slide-leave-to {
opacity: 0;
transform: translateX(-10px);
}
/* Custom Scrollbar */
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: rgba(var(--rgb-border), 0.5);
border-radius: 10px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: rgba(var(--rgb-border), 0.8);
}
/* Glass overrides for generic inputs/buttons if needed */
.glass-input :deep(.n-input) {
background-color: rgba(255, 255, 255, 0.03);
backdrop-filter: blur(10px);
}
.glass-select :deep(.n-base-selection) {
background-color: rgba(255, 255, 255, 0.03) !important;
backdrop-filter: blur(10px);
}
.glass-button,
.glass-button-primary {
background-color: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(5px);
border: 1px solid rgba(255, 255, 255, 0.1);
transition: all 0.3s ease;
}
.glass-button:hover,
.glass-button-primary:hover {
background-color: rgba(255, 255, 255, 0.1);
transform: translateY(-1px);
}
.glass-button-primary {
background-color: rgba(var(--rgb-primary), 0.1);
border-color: rgba(var(--rgb-primary), 0.3);
color: rgb(var(--rgb-primary));
}
.glass-button-primary:hover {
background-color: rgba(var(--rgb-primary), 0.2);
box-shadow: 0 4px 12px rgba(var(--rgb-primary), 0.2);
}
</style>
-101
View File
@@ -5,104 +5,3 @@ declare module '*.vue' {
const component: DefineComponent<{}, {}, any>
export default component
}
interface Window {
logs: {
getAll: () => Promise<Array<{
id: string
timestamp: number
level: 'debug' | 'info' | 'warn' | 'error'
category: 'frontend' | 'backend' | 'core'
source: string
message: string
detail?: string
}>>
clear: () => Promise<void>
getFilePath: () => Promise<string>
pushFrontend: (entry: {
level?: 'debug' | 'info' | 'warn' | 'error'
source?: string
message?: string
detail?: string
}) => Promise<void>
onNew: (callback: (entry: {
id: string
timestamp: number
level: 'debug' | 'info' | 'warn' | 'error'
category: 'frontend' | 'backend' | 'core'
source: string
message: string
detail?: string
}) => void) => void
offNew: (callback: (entry: {
id: string
timestamp: number
level: 'debug' | 'info' | 'warn' | 'error'
category: 'frontend' | 'backend' | 'core'
source: string
message: string
detail?: string
}) => void) => void
}
electron: {
minimize: () => Promise<void>
maximize: () => Promise<void>
close: () => Promise<void>
pickImage: () => Promise<string>
pickProcess: () => Promise<string[]>
pickProcessFolder: (maxDepth?: number) => Promise<string[]>
on: (channel: string, callback: (...args: any[]) => void) => void
off: (channel: string, callback: (...args: any[]) => void) => void
}
singbox: {
start: (config: string) => Promise<void>
stop: () => Promise<void>
restart: (config: string) => Promise<void>
}
system: {
scanProcesses: () => Promise<string[]>
scanLocalGames: () => Promise<Array<{
name: string
processName: string
source: 'steam' | 'microsoft' | 'epic' | 'ea'
installDir: string
}>>
getProcessTree: () => Promise<any>
ping: (host: string) => Promise<number>
tcpPing: (host: string, port: number) => Promise<number>
flushDnsCache: () => Promise<{ ok: boolean, code: number, output: string, message: string }>
reinstallTunAdapter: (interfaceName?: string) => Promise<{ ok: boolean, code: number, output: string, message: string }>
findAvailablePort: (port: number, count?: number) => Promise<number>
testHttpProxyConnect: (
proxyPort: number,
targetHost: string,
targetPort?: number,
timeoutMs?: number
) => Promise<{ ok: boolean; statusLine: string; error?: string }>
}
proxyMonitor: {
start: (gameId: string, processNames: string[]) => Promise<void>
stop: () => Promise<void>
}
nodes: {
getAll: () => Promise<any[]>
save: (node: any) => Promise<any[]>
delete: (id: string) => Promise<any[]>
import: (nodes: any[]) => Promise<any[]>
}
games: {
getAll: () => Promise<any[]>
save: (game: any) => Promise<any[]>
delete: (id: string) => Promise<any[]>
}
categories: {
getAll: () => Promise<any[]>
save: (category: any) => Promise<any[]>
delete: (id: string) => Promise<any[]>
}
app: {
getVersion: () => Promise<string>
checkUpdate: () => Promise<{ updateAvailable: boolean; version?: string; releaseDate?: string; releaseNotes?: string; error?: string }>
openUrl: (url: string) => Promise<void>
}
}
+198 -3
View File
@@ -1,7 +1,7 @@
import { describe, it, expect } from 'vitest'
import { generateSingboxConfig } from '../../src/utils/singbox-config'
import type { Game } from '../../src/types'
import type { NodeConfig } from '../../src/utils/protocol'
import { parseShareLink, type NodeConfig } from '../../src/utils/protocol'
describe('generateSingboxConfig', () => {
const mockGame: Game = {
@@ -53,15 +53,151 @@ describe('generateSingboxConfig', () => {
const routingGame = { ...mockGame, proxyMode: 'routing' as const }
const config = JSON.parse(generateSingboxConfig(routingGame, mockNode))
expect(config.route.final).toBe('proxy')
expect(config.route.final).toBe('direct')
expect(config.dns.final).toBe('google')
expect(config.dns.final).toBe('remote-primary')
const tunInbound = config.inbounds.find((i: any) => i.tag === 'tun-in')
expect(tunInbound).toBeDefined()
expect(tunInbound.inet4_address).toBeDefined()
})
it('should only proxy configured CIDR rules in routing mode', () => {
const routingGame = {
...mockGame,
proxyMode: 'routing' as const,
routingRules: ['216.58.220.1/24']
}
const config = JSON.parse(generateSingboxConfig(routingGame, mockNode))
expect(config.route.final).toBe('direct')
const cidrRule = config.route.rules.find((r: any) =>
Array.isArray(r?.ip_cidr) && r.ip_cidr.includes('216.58.220.1/24')
)
expect(cidrRule).toBeDefined()
expect(cidrRule.outbound).toBe('proxy')
})
it('should only proxy configured CIDR for specified processes in routing mode', () => {
const routingGame = {
...mockGame,
proxyMode: 'routing' as const,
processName: ['chrome.exe', 'msedge.exe'],
routingRules: ['216.58.220.1/24']
}
const config = JSON.parse(generateSingboxConfig(routingGame, mockNode))
expect(config.route.final).toBe('direct')
const cidrRule = config.route.rules.find((r: any) =>
Array.isArray(r?.ip_cidr) && r.ip_cidr.includes('216.58.220.1/24')
)
expect(cidrRule).toBeDefined()
expect(cidrRule.outbound).toBe('proxy')
expect(Array.isArray(cidrRule.process_name)).toBe(true)
expect(cidrRule.process_name).toContain('chrome.exe')
expect(cidrRule.process_name).toContain('msedge.exe')
})
it('should include exe alias for bare process names in routing mode', () => {
const routingGame = {
...mockGame,
proxyMode: 'routing' as const,
processName: ['chrome'],
routingRules: ['1.1.1.1']
}
const config = JSON.parse(generateSingboxConfig(routingGame, mockNode))
const cidrRule = config.route.rules.find((r: any) =>
Array.isArray(r?.ip_cidr) && r.ip_cidr.includes('1.1.1.1/32')
)
expect(cidrRule).toBeDefined()
expect(Array.isArray(cidrRule.process_name)).toBe(true)
expect(cidrRule.process_name).toContain('chrome')
expect(cidrRule.process_name).toContain('chrome.exe')
})
it('should use remote geoip-cn and geosite-cn rulesets for bypass_cn mode', () => {
const routingGame = {
...mockGame,
proxyMode: 'routing' as const,
routingRules: ['bypass_cn']
}
const config = JSON.parse(generateSingboxConfig(routingGame, mockNode))
expect(config.route.final).toBe('direct')
const geoipRuleSet = config.route.rule_set?.find((r: any) => r?.tag === 'geoip-cn')
expect(geoipRuleSet).toBeDefined()
expect(geoipRuleSet.type).toBe('remote')
expect(geoipRuleSet.format).toBe('binary')
expect(String(geoipRuleSet.url)).toContain('Loyalsoldier/geoip')
expect(geoipRuleSet.download_detour).toBe('proxy')
const geositeRuleSet = config.route.rule_set?.find((r: any) => r?.tag === 'geosite-cn')
expect(geositeRuleSet).toBeDefined()
expect(geositeRuleSet.type).toBe('remote')
expect(geositeRuleSet.format).toBe('binary')
expect(String(geositeRuleSet.url)).toContain('sing-geosite')
expect(geositeRuleSet.download_detour).toBe('proxy')
const geoipRouteRule = config.route.rules.find((r: any) =>
Array.isArray(r?.rule_set) && r.rule_set.includes('geoip-cn')
)
expect(geoipRouteRule).toBeDefined()
expect(geoipRouteRule.outbound).toBe('direct')
const geositeRouteRule = config.route.rules.find((r: any) =>
Array.isArray(r?.rule_set) && r.rule_set.includes('geosite-cn')
)
expect(geositeRouteRule).toBeDefined()
expect(geositeRouteRule.outbound).toBe('direct')
})
it('should keep bypass_cn scoped to selected processes when process names are provided', () => {
const routingGame = {
...mockGame,
proxyMode: 'routing' as const,
processName: ['chrome.exe'],
routingRules: ['bypass_cn']
}
const config = JSON.parse(generateSingboxConfig(routingGame, mockNode))
expect(config.route.final).toBe('direct')
const geoipDirectRule = config.route.rules.find((r: any) =>
Array.isArray(r?.rule_set) && r.rule_set.includes('geoip-cn')
)
expect(geoipDirectRule).toBeDefined()
expect(geoipDirectRule.process_name).toContain('chrome.exe')
const processProxyRule = config.route.rules.find((r: any) =>
Array.isArray(r?.process_name) && r.process_name.includes('chrome.exe') && r.outbound === 'proxy'
)
expect(processProxyRule).toBeDefined()
})
it('should scope global routing to selected processes when process names are provided', () => {
const routingGame = {
...mockGame,
proxyMode: 'routing' as const,
processName: ['chrome.exe'],
routingRules: ['global']
}
const config = JSON.parse(generateSingboxConfig(routingGame, mockNode))
expect(config.route.final).toBe('direct')
const processProxyRule = config.route.rules.find((r: any) =>
Array.isArray(r?.process_name) && r.process_name.includes('chrome.exe') && r.outbound === 'proxy'
)
expect(processProxyRule).toBeDefined()
})
it('should keep global routing as full proxy when no process names are provided', () => {
const routingGame = {
...mockGame,
proxyMode: 'routing' as const,
processName: [],
routingRules: ['global']
}
const config = JSON.parse(generateSingboxConfig(routingGame, mockNode))
expect(config.route.final).toBe('proxy')
})
it('should include node outbound', () => {
const config = JSON.parse(generateSingboxConfig(mockGame, mockNode))
const nodeOut = config.outbounds.find((o: any) => o.tag === 'node-out')
@@ -69,4 +205,63 @@ describe('generateSingboxConfig', () => {
expect(nodeOut.type).toBe('vmess')
expect(nodeOut.server).toBe('example.com')
})
it('should not use IP as TLS server_name fallback', () => {
const ipNode: NodeConfig = {
...mockNode,
server: '1.2.3.4',
host: undefined,
tls: {
enabled: true,
server_name: ''
}
}
const config = JSON.parse(generateSingboxConfig(mockGame, ipNode))
const nodeOut = config.outbounds.find((o: any) => o.tag === 'node-out')
expect(nodeOut?.tls?.server_name).toBeUndefined()
})
it('should keep domain TLS server_name fallback', () => {
const domainNode: NodeConfig = {
...mockNode,
server: 'node.example.com',
host: undefined,
tls: {
enabled: true,
server_name: ''
}
}
const config = JSON.parse(generateSingboxConfig(mockGame, domainNode))
const nodeOut = config.outbounds.find((o: any) => o.tag === 'node-out')
expect(nodeOut?.tls?.server_name).toBe('node.example.com')
})
it('should generate VLESS reality outbound from share link', () => {
const link = 'vless://13a3abd8-6315-4735-b3a2-39f38a2e4f3d@156.246.93.26:38073?encryption=none&security=reality&flow=xtls-rprx-vision&type=tcp&sni=www.paypal.com&pbk=DrpIgSOtaEHqJywmydYjljWB9FD_1PlFjQIAlbHiOgk&fp=chrome#233boy-reality-156.246.93.26'
const node = parseShareLink(link)
expect(node).not.toBeNull()
const config = JSON.parse(generateSingboxConfig(mockGame, node as NodeConfig))
const nodeOut = config.outbounds.find((o: any) => o.tag === 'node-out')
expect(nodeOut?.type).toBe('vless')
expect(nodeOut?.flow).toBe('xtls-rprx-vision')
expect(nodeOut?.tls?.server_name).toBe('www.paypal.com')
expect(nodeOut?.tls?.reality?.enabled).toBe(true)
expect(nodeOut?.tls?.reality?.public_key).toBe('DrpIgSOtaEHqJywmydYjljWB9FD_1PlFjQIAlbHiOgk')
})
it('should add bootstrap DNS rule for domain node in secure mode', () => {
const domainNode: NodeConfig = {
...mockNode,
server: 'm.cnmjin.net',
tls: {
enabled: false
}
}
const config = JSON.parse(generateSingboxConfig(mockGame, domainNode, { mode: 'secure' }))
const bootstrapRule = config.dns.rules.find((r: any) =>
Array.isArray(r?.domain) && r.domain.includes('m.cnmjin.net')
)
expect(bootstrapRule).toBeDefined()
expect(bootstrapRule.server).toBe('local')
})
})