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:
1 parent
23938d6479
commit
c2bb7e6ed8
57 files changed
+3661
-1917
No files matched your search
@@ -0,0 +1 @@
|
||||
test
|
||||
@@ -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 和后端扫描接口,处理扫描进度和结果展示。
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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),
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export const proxyMonitorApi = {
|
||||
start: (gameId: string, processNames: string[]) => window.proxyMonitor.start(gameId, processNames),
|
||||
stop: () => window.proxyMonitor.stop(),
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Vendored
+8
@@ -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']
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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,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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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.
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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')
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Vendored
+109
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
@@ -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 || '')
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
Vendored
-101
@@ -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>
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user