feat: 添加延迟会话存储并优化游戏库与节点管理

- 新增 IndexedDB 存储用于记录节点延迟历史与游戏会话统计
- 重构游戏库状态管理,支持游戏加速时长跟踪与本地存储当前选中游戏
- 优化节点管理界面样式与交互,为删除操作添加危险按钮样式
- 修复游戏库视图中的类型安全与可选 ID 处理问题
- 更新主题配色方案与 CSS 变量,改进深色模式视觉效果
- 移除已弃用的类型定义文件,统一使用新的类型导出结构
- 新增 .editorconfig 与 .gitattributes 文件以统一代码风格与行尾设置
- 调整构建配置,将自动导入类型定义移至专用目录
This commit is contained in:
ZenEcho committed 2026-02-17 00:12:36 +08:00
1 parent 5e6a704caa
commit e85c4e9d7c
27 files changed
+809 -602

No files matched your search

+12
View File
@@ -0,0 +1,12 @@
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_style = space
indent_size = 2
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false
+1
View File
@@ -0,0 +1 @@
* text=auto eol=lf
+1
View File
@@ -10,6 +10,7 @@ coverage
node_modules
dist
dist-ssr
dist-electron
*.local
.lagzero-dev
# Editor directories and files
-10
View File
@@ -1,10 +0,0 @@
# Changelog
## [0.1.0] - 2026-02-09
### Added
- Initial release of LagZero.
- Core: sing-box integration with start/stop/restart support.
- UI: Dashboard with real-time latency chart and node management.
- UI: Rule editor with Routing and IP Rule modes.
- Protocol: Support for VMess, VLESS, Shadowsocks parsing.
- CI: GitHub Actions for cross-platform build and release.
+15 -4
View File
@@ -31,14 +31,14 @@ const themeColors: Record<ThemeColor, { primary: string; hover: string; pressed:
const themeOverrides = computed<GlobalThemeOverrides>(() => {
const color = themeColors[themeColor.value] || themeColors.green
const common = {
primaryColor: color.primary,
primaryColorHover: color.hover,
primaryColorPressed: color.pressed,
primaryColorSuppl: color.hover,
}
return {
common,
Button: {
@@ -51,12 +51,21 @@ const themeOverrides = computed<GlobalThemeOverrides>(() => {
colorHoverPrimary: color.hover,
colorPressedPrimary: color.pressed,
colorFocusPrimary: color.hover,
colorDisabledPrimary: color.primary, // Use opacity for disabled state usually handled by component, but color needs to be set
colorDisabledPrimary: color.primary,
borderPrimary: `1px solid ${color.primary}`,
borderHoverPrimary: `1px solid ${color.hover}`,
borderPressedPrimary: `1px solid ${color.pressed}`,
borderFocusPrimary: `1px solid ${color.hover}`,
borderDisabledPrimary: `1px solid ${color.primary}`,
// Also ensure error/warning buttons have white text
textColorError: '#ffffff',
textColorHoverError: '#ffffff',
textColorPressedError: '#ffffff',
textColorFocusError: '#ffffff',
textColorWarning: '#ffffff',
textColorHoverWarning: '#ffffff',
textColorPressedWarning: '#ffffff',
textColorFocusWarning: '#ffffff',
}
}
})
@@ -65,7 +74,9 @@ const themeOverrides = computed<GlobalThemeOverrides>(() => {
<style>
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;500;700&display=swap');
html, body, #app {
html,
body,
#app {
height: 100%;
width: 100%;
margin: 0;
File renamed without changes.
+10 -10
View File
@@ -11,10 +11,10 @@ export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
AdvancedConfigEditor: typeof import('./components/library/AdvancedConfigEditor.vue')['default']
CategoryManager: typeof import('./components/library/CategoryManager.vue')['default']
GameEditModal: typeof import('./components/library/GameEditModal.vue')['default']
LatencyChart: typeof import('./components/dashboard/LatencyChart.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']
LatencyChart: typeof import('./../components/dashboard/LatencyChart.vue')['default']
NButton: typeof import('naive-ui')['NButton']
NConfigProvider: typeof import('naive-ui')['NConfigProvider']
NDialogProvider: typeof import('naive-ui')['NDialogProvider']
@@ -26,17 +26,17 @@ declare module 'vue' {
NModal: typeof import('naive-ui')['NModal']
NModalProvider: typeof import('naive-ui')['NModalProvider']
NNotificationProvider: typeof import('naive-ui')['NNotificationProvider']
NodeCard: typeof import('./components/node/NodeCard.vue')['default']
NodeEditModal: typeof import('./components/node/NodeEditModal.vue')['default']
NodeImportModal: typeof import('./components/node/NodeImportModal.vue')['default']
NodeManager: typeof import('./components/node/NodeManager.vue')['default']
NodeSelector: typeof import('./components/node/NodeSelector.vue')['default']
NodeCard: typeof import('./../components/node/NodeCard.vue')['default']
NodeEditModal: typeof import('./../components/node/NodeEditModal.vue')['default']
NodeImportModal: typeof import('./../components/node/NodeImportModal.vue')['default']
NodeManager: typeof import('./../components/node/NodeManager.vue')['default']
NodeSelector: typeof import('./../components/dashboard/NodeSelector.vue')['default']
NRadio: typeof import('naive-ui')['NRadio']
NRadioGroup: typeof import('naive-ui')['NRadioGroup']
NSelect: typeof import('naive-ui')['NSelect']
NSpace: typeof import('naive-ui')['NSpace']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
ThemeToggle: typeof import('./components/common/ThemeToggle.vue')['default']
ThemeToggle: typeof import('./../components/common/ThemeToggle.vue')['default']
}
}
+87 -50
View File
@@ -3,68 +3,105 @@
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import * as echarts from 'echarts'
import { useNodeStore } from '@/stores/nodes'
import { useIntervalFn } from '@vueuse/core'
const props = defineProps<{
nodeKey?: string | null
}>()
const nodeStore = useNodeStore()
const chartRef = ref<HTMLElement>()
let chart: echarts.ECharts | null = null
onMounted(() => {
if (chartRef.value) {
chart = echarts.init(chartRef.value)
// Mock data generation
const data = Array.from({ length: 20 }, () => Math.floor(Math.random() * 100))
const xData = Array.from({ length: 20 }, (_, i) => i)
const activeNodeKey = computed(() => props.nodeKey || '')
const option = {
backgroundColor: 'transparent',
tooltip: {
trigger: 'axis',
axisPointer: { type: 'cross' }
},
grid: {
top: 20,
right: 20,
bottom: 20,
left: 40,
containLabel: true
},
xAxis: {
type: 'category',
data: xData,
axisLine: { lineStyle: { color: '#666' } }
},
yAxis: {
type: 'value',
splitLine: { lineStyle: { color: '#333' } },
axisLine: { lineStyle: { color: '#666' } }
},
series: [
{
data: data,
type: 'line',
smooth: true,
showSymbol: false,
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(62, 175, 124, 0.5)' },
{ offset: 1, color: 'rgba(62, 175, 124, 0.0)' }
])
},
itemStyle: { color: '#3eaf7c' }
}
]
}
chart.setOption(option)
window.addEventListener('resize', resize)
function formatTime(timestamp: number) {
const date = new Date(timestamp)
return `${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}:${date.getSeconds().toString().padStart(2, '0')}`
}
function renderChart(xData: string[], yData: (number | null)[]) {
if (!chart) return
chart.setOption({
backgroundColor: 'transparent',
tooltip: {
trigger: 'axis',
axisPointer: { type: 'cross' }
},
grid: {
top: 20,
right: 20,
bottom: 20,
left: 40,
containLabel: true
},
xAxis: {
type: 'category',
data: xData,
axisLine: { lineStyle: { color: '#666' } }
},
yAxis: {
type: 'value',
splitLine: { lineStyle: { color: '#333' } },
axisLine: { lineStyle: { color: '#666' } }
},
series: [
{
data: yData,
type: 'line',
smooth: true,
showSymbol: false,
connectNulls: false,
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(62, 175, 124, 0.5)' },
{ offset: 1, color: 'rgba(62, 175, 124, 0.0)' }
])
},
itemStyle: { color: '#3eaf7c' }
}
]
})
}
async function refreshChart() {
const nodeKey = activeNodeKey.value
if (!nodeKey) {
renderChart([], [])
return
}
const history = await nodeStore.getNodeLatencyHistory(nodeKey, 240)
renderChart(
history.map(item => formatTime(item.timestamp)),
history.map(item => (item.latency > 0 ? item.latency : null))
)
}
const { pause } = useIntervalFn(() => {
void refreshChart()
}, 1000)
onMounted(() => {
if (!chartRef.value) return
chart = echarts.init(chartRef.value)
void refreshChart()
window.addEventListener('resize', resize)
})
watch(activeNodeKey, () => {
void refreshChart()
})
onUnmounted(() => {
pause()
window.removeEventListener('resize', resize)
chart?.dispose()
chart = null
})
const resize = () => chart?.resize()
+67 -104
View File
@@ -1,29 +1,14 @@
<template>
<n-modal
:show="modelValue"
@update:show="$emit('update:modelValue', $event)"
preset="card"
:title="isEdit ? $t('common.edit') : $t('games.add_game')"
class="w-[600px]"
:mask-closable="false"
>
<n-form
ref="formRef"
label-placement="top"
:model="form"
>
<n-modal :show="modelValue" @update:show="$emit('update:modelValue', $event)" preset="card"
:title="isEdit ? $t('common.edit') : $t('games.add_game')" class="w-[600px]" :mask-closable="false">
<n-form ref="formRef" label-placement="top" :model="form">
<div class="grid grid-cols-2 gap-4">
<n-form-item :label="$t('games.name')">
<n-input
v-model:value="form.name"
placeholder="例如:英雄联盟"
/>
<n-input v-model:value="form.name" placeholder="例如:英雄联盟" />
</n-form-item>
<n-form-item :label="$t('games.category')">
<n-select
v-model:value="form.category"
:options="categoryStore.categories.map(c => ({ label: c.name, value: c.id }))"
/>
<n-select v-model:value="form.category"
:options="categoryStore.categories.map((c: any) => ({ label: c.name, value: c.id }))" />
</n-form-item>
</div>
@@ -43,88 +28,71 @@
<template v-if="form.proxyMode === 'process'">
<n-form-item :label="$t('games.process_name')">
<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 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>
</n-form-item>
</template>
<template v-if="form.proxyMode === 'routing'">
<n-form-item :label="$t('rules.ip_rules')">
<div class="flex flex-col w-full gap-1">
<n-input
v-model:value="routingRulesText"
type="textarea"
:rows="5"
class="font-mono"
placeholder="1.1.1.1/32&#10;8.8.8.8"
/>
<n-input v-model:value="routingRulesText" type="textarea" :rows="5" class="font-mono"
placeholder="1.1.1.1/32&#10;8.8.8.8" />
<span class="text-xs text-on-surface-muted">{{ $t('games.process_name_tip') }} (CIDR/IP)</span>
</div>
</n-form-item>
<n-form-item :label="`${$t('games.process_name')} (${$t('common.optional')})`">
<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 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>
</n-form-item>
@@ -132,27 +100,22 @@
<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">
<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-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>
</n-form>
<template #footer>
<div class="flex justify-end gap-2">
<n-button @click="close">{{ $t('common.cancel') }}</n-button>
<n-button
type="primary"
@click="save"
>
<n-button type="primary" @click="save">
{{ $t('common.save') }}
</n-button>
</div>
@@ -197,7 +160,7 @@ watch(() => props.modelValue, (val) => {
if (val) {
if (props.editingGame) {
form.value = JSON.parse(JSON.stringify(props.editingGame))
if (Array.isArray(props.editingGame.processName)) {
processList.value = [...props.editingGame.processName]
} else if (props.editingGame.processName) {
@@ -262,7 +225,7 @@ async function pickProcess() {
// 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 => current.add(f))
files.forEach((f: string) => current.add(f))
processList.value = Array.from(current)
if (processList.value.length === 0) processList.value.push('')
}
@@ -292,7 +255,7 @@ async function pickProcessFolder() {
const files = await picker(depth.value)
if (files && files.length > 0) {
const current = new Set(processList.value.filter(Boolean))
files.forEach(f => current.add(f))
files.forEach((f: string) => current.add(f))
processList.value = Array.from(current)
if (processList.value.length === 0) processList.value.push('')
message.success(`已添加 ${files.length} 个可执行文件`)
@@ -307,7 +270,7 @@ function save() {
if (!form.value.name) return
const cleanProcesses = processList.value.map(p => p.trim()).filter(Boolean)
const cleanRules = routingRulesText.value.split('\n').map(r => r.trim()).filter(Boolean)
const gameData: Game = {
+20 -41
View File
@@ -1,23 +1,10 @@
<template>
<n-modal
:show="modelValue"
@update:show="$emit('update:modelValue', $event)"
preset="card"
:title="$t('common.categories')"
class="w-[600px]"
:mask-closable="false"
>
<n-modal :show="modelValue" @update:show="$emit('update:modelValue', $event)" preset="card"
:title="$t('common.categories')" class="w-[600px]" :mask-closable="false">
<div class="flex gap-2 mb-4">
<n-input
v-model:value="newCategoryName"
:placeholder="$t('categories.new_category_placeholder')"
@keyup.enter="addCategory"
/>
<n-button
type="primary"
@click="addCategory"
:disabled="!newCategoryName"
>
<n-input v-model:value="newCategoryName" :placeholder="$t('categories.new_category_placeholder')"
@keyup.enter="addCategory" />
<n-button type="primary" @click="addCategory" :disabled="!newCategoryName">
<template #icon>
<div class="i-material-symbols-add"></div>
</template>
@@ -26,27 +13,19 @@
</div>
<div class="max-h-[60vh] overflow-y-auto min-h-[100px]">
<draggable
v-model="categoryStore.categories"
item-key="id"
handle=".drag-handle"
@end="saveOrder"
class="space-y-2"
>
<draggable v-model="categoryStore.categories" item-key="id" handle=".drag-handle" @end="saveOrder"
class="space-y-2">
<template #item="{ element }">
<div class="flex items-center gap-3 p-3 bg-surface rounded border border-border group hover:border-primary/50 transition">
<div class="drag-handle text-on-surface-muted i-material-symbols-drag-indicator hover:text-on-surface cursor-grab active:cursor-grabbing"></div>
<div
class="flex items-center gap-3 p-3 bg-surface rounded border border-border group hover:border-primary/50 transition">
<div
class="drag-handle text-on-surface-muted i-material-symbols-drag-indicator hover:text-on-surface cursor-grab active:cursor-grabbing">
</div>
<div class="flex-1 flex items-center gap-2">
<div v-if="editingId === element.id" class="flex-1">
<n-input
v-model:value="element.name"
size="small"
@blur="finishEdit(element)"
@keyup.enter="finishEdit(element)"
ref="editInput"
v-focus
/>
<n-input v-model:value="element.name" size="small" @blur="finishEdit(element)"
@keyup.enter="finishEdit(element)" ref="editInput" v-focus />
</div>
<div v-else class="flex-1 font-bold text-on-surface" @dblclick="startEdit(element)">
{{ element.name }}
@@ -108,13 +87,13 @@ const vFocus = {
async function addCategory() {
if (!newCategoryName.value) return
await categoryStore.addCategory({
id: Date.now().toString(36),
name: newCategoryName.value,
order: categoryStore.categories.length
})
newCategoryName.value = ''
message.success('Category added')
}
@@ -125,7 +104,7 @@ function startEdit(category: Category) {
async function finishEdit(category: Category) {
if (!editingId.value) return
await categoryStore.updateCategory(category)
editingId.value = null
}
@@ -149,11 +128,11 @@ async function saveOrder() {
// Assuming updateCategory handles it per item or we need a bulk save.
// Current store only has add/update/remove.
// Let's iterate and update for now as the store doesn't have bulk save.
const updates = categoryStore.categories.map((c, index) => ({
const updates = categoryStore.categories.map((c: Category, index: number) => ({
...c,
order: index
}))
for (const c of updates) {
await categoryStore.updateCategory(c)
}
+13 -37
View File
@@ -1,30 +1,14 @@
<template>
<n-modal
:show="modelValue"
@update:show="$emit('update:modelValue', $event)"
preset="card"
:title="isEdit ? $t('common.edit') : $t('games.add_game')"
class="w-[500px]"
:mask-closable="false"
>
<n-form
ref="formRef"
label-placement="top"
:model="form"
>
<n-modal :show="modelValue" @update:show="$emit('update:modelValue', $event)" preset="card"
:title="isEdit ? $t('common.edit') : $t('games.add_game')" class="w-[500px]" :mask-closable="false">
<n-form ref="formRef" label-placement="top" :model="form">
<n-form-item :label="$t('games.name')">
<n-input
v-model:value="form.name"
placeholder="例如:英雄联盟"
/>
<n-input v-model:value="form.name" placeholder="例如:英雄联盟" />
</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"
/>
<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>
@@ -35,7 +19,8 @@
<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">
<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>
@@ -47,12 +32,7 @@
<n-form-item :label="$t('games.category')">
<n-radio-group v-model:value="form.category">
<n-radio-button
v-for="cat in categories"
:key="cat"
:value="cat"
:label="cat.toUpperCase()"
/>
<n-radio-button v-for="cat in categories" :key="cat" :value="cat" :label="cat.toUpperCase()" />
</n-radio-group>
</n-form-item>
</n-form>
@@ -60,16 +40,13 @@
<template #footer>
<div class="flex justify-end gap-2">
<n-button @click="close">{{ $t('common.cancel') }}</n-button>
<n-button
type="primary"
@click="save"
:disabled="!form.name || !processNames"
>
<n-button type="primary" @click="save" :disabled="!form.name || !processNames">
{{ $t('common.save') }}
</n-button>
</div>
</template>
</n-modal>
</template>
<script setup lang="ts">
@@ -136,7 +113,6 @@ function close() {
}
async function pickImage() {
// @ts-ignore
const picker = window.electron?.pickImage
if (!picker) {
message.error('当前环境不支持选择文件')
@@ -163,7 +139,7 @@ async function pickProcess() {
current = [form.value.processName]
}
const set = new Set(current)
files.forEach(f => set.add(f))
files.forEach((f: string) => set.add(f))
form.value.processName = Array.from(set)
}
}
@@ -199,7 +175,7 @@ async function pickProcessFolder() {
current = [form.value.processName]
}
const set = new Set(current)
files.forEach(f => set.add(f))
files.forEach((f: string) => set.add(f))
form.value.processName = Array.from(set)
message.success(`已添加 ${files.length} 个可执行文件`)
} else if (files) {
@@ -211,7 +187,7 @@ async function pickProcessFolder() {
function save() {
if (!form.value.name) return
if (props.editingGame) {
emit('save', { ...form.value, id: props.editingGame.id })
} else {
+22 -60
View File
@@ -1,90 +1,56 @@
<template>
<n-modal
:show="modelValue"
@update:show="$emit('update:modelValue', $event)"
preset="card"
:title="isEdit ? $t('nodes.edit_node') : $t('nodes.add_node')"
class="w-[500px]"
:mask-closable="false"
>
<n-form
ref="formRef"
label-placement="top"
:model="form"
>
<n-modal :show="modelValue" @update:show="$emit('update:modelValue', $event)" preset="card"
:title="isEdit ? $t('nodes.edit_node') : $t('nodes.add_node')" class="w-[500px]" :mask-closable="false">
<n-form ref="formRef" label-placement="top" :model="form">
<n-form-item :label="$t('nodes.tag')">
<n-input
v-model:value="form.tag"
:placeholder="$t('nodes.tag_placeholder')"
/>
<n-input v-model:value="form.tag" :placeholder="$t('nodes.tag_placeholder')" />
</n-form-item>
<n-form-item :label="$t('nodes.type')">
<n-select
v-model:value="form.type"
:options="[
{ label: 'VMess', value: 'vmess' },
{ label: 'VLESS', value: 'vless' },
{ label: 'Shadowsocks', value: 'shadowsocks' },
{ label: 'Trojan', value: 'trojan' }
]"
/>
<n-select v-model:value="form.type" :options="[
{ label: 'VMess', value: 'vmess' },
{ label: 'VLESS', value: 'vless' },
{ label: 'Shadowsocks', value: 'shadowsocks' },
{ label: 'Trojan', value: 'trojan' }
]" />
</n-form-item>
<div class="grid grid-cols-3 gap-4">
<div class="col-span-2">
<n-form-item :label="$t('nodes.server')">
<n-input
v-model:value="form.server"
:placeholder="$t('nodes.server_placeholder')"
/>
<n-input v-model:value="form.server" :placeholder="$t('nodes.server_placeholder')" />
</n-form-item>
</div>
<div>
<n-form-item :label="$t('nodes.port')">
<n-input-number
v-model:value="form.server_port"
:placeholder="$t('nodes.port_placeholder')"
:show-button="false"
/>
<n-input-number v-model:value="form.server_port" :placeholder="$t('nodes.port_placeholder')"
:show-button="false" />
</n-form-item>
</div>
</div>
<template v-if="form.type && ['vmess', 'vless', 'trojan'].includes(form.type)">
<n-form-item :label="$t('nodes.uuid')">
<n-input
v-model:value="form.uuid"
:placeholder="$t('nodes.uuid_placeholder')"
/>
<n-input v-model:value="form.uuid" :placeholder="$t('nodes.uuid_placeholder')" />
</n-form-item>
</template>
<template v-else-if="form.type === 'shadowsocks'">
<n-form-item :label="$t('nodes.password')">
<n-input
v-model:value="form.password"
:placeholder="$t('nodes.password_placeholder')"
/>
<n-input v-model:value="form.password" :placeholder="$t('nodes.password_placeholder')" />
</n-form-item>
<n-form-item :label="$t('nodes.method')">
<n-input
v-model:value="form.method"
:placeholder="$t('nodes.method_placeholder')"
/>
<n-input v-model:value="form.method" :placeholder="$t('nodes.method_placeholder')" />
</n-form-item>
</template>
<template v-if="form.type && ['vmess', 'vless'].includes(form.type)">
<n-form-item :label="$t('nodes.network')">
<n-select
v-model:value="form.network"
:options="[
{ label: 'TCP', value: 'tcp' },
{ label: 'WebSocket', value: 'ws' },
{ label: 'gRPC', value: 'grpc' }
]"
/>
<n-select v-model:value="form.network" :options="[
{ label: 'TCP', value: 'tcp' },
{ label: 'WebSocket', value: 'ws' },
{ label: 'gRPC', value: 'grpc' }
]" />
</n-form-item>
</template>
</n-form>
@@ -92,11 +58,7 @@
<template #footer>
<div class="flex justify-end gap-2">
<n-button @click="close">{{ $t('common.cancel') }}</n-button>
<n-button
type="primary"
@click="save"
:disabled="!canSave"
>
<n-button type="primary" @click="save" :disabled="!canSave">
{{ $t('common.save') }}
</n-button>
</div>
+4 -2
View File
@@ -16,12 +16,11 @@
<div class="min-w-0">
<div class="font-medium text-sm text-on-surface truncate">{{ node.tag || 'Unnamed Node' }}</div>
<div class="text-xs text-on-surface-muted flex items-center gap-2">
<span class="uppercase bg-surface-overlay px-1 rounded text-[10px]">{{ node.type }}</span>
<span class="uppercase bg-surface-overlay my-1 px-1 rounded text-[10px]">{{ node.type }}</span>
<span class="truncate">{{ node.server }}:{{ node.server_port }}</span>
</div>
</div>
</div>
<div class="flex items-center gap-4">
<!-- Status -->
<div v-if="getNodeStats(node)" class="flex items-center gap-3 text-xs font-mono">
@@ -126,6 +125,9 @@ async function deleteNode(node: NodeConfig) {
content: t('nodes.delete_confirm', { name: node.tag }) || `确定要删除节点 ${node.tag} 吗?`,
positiveText: t('common.delete') || '删除',
negativeText: t('common.cancel') || '取消',
positiveButtonProps: {
type: 'error'
},
onPositiveClick: async () => {
if (node.id) {
await nodeStore.removeNode(node.id)
-8
View File
@@ -159,14 +159,6 @@ const close = () => window.electron.close()
</script>
<style>
.app-drag-region {
-webkit-app-region: drag;
}
.no-drag {
-webkit-app-region: no-drag;
}
.slide-fade-enter-active {
transition: all 0.2s ease-out;
}
+5
View File
@@ -7,6 +7,11 @@ import App from './App.vue'
import router from './router'
import pinia from './stores'
import i18n from './i18n'
import { initLatencySessionStore } from './utils/latency-session'
void initLatencySessionStore().catch((e) => {
console.error('Failed to init latency session store:', e)
})
createApp(App)
.use(router)
+34 -30
View File
@@ -1,5 +1,6 @@
import { defineStore } from 'pinia'
import { ref, computed, toRaw } from 'vue'
import { ref, computed, toRaw, reactive } from 'vue'
import { useLocalStorage } from '@vueuse/core'
import type { Game } from '@/types'
import { useNodeStore } from './nodes'
import { generateSingboxConfig } from '@/utils/singbox-config'
@@ -24,20 +25,19 @@ function normalizeProxyModeForPreset(game: Game): Game {
}
export const useGameStore = defineStore('games', () => {
// 游戏特征库
// All games in local library
const gameLibrary = ref<Game[]>([])
// Current selected game ID (shared with dashboard)
const currentGameId = useLocalStorage<string | null>('games-current-id', null)
// 当前选中的游戏 (Dashboard 展示)
const currentGameId = ref<string | null>(null)
const currentGame = computed(() =>
const currentGame = computed(() =>
gameLibrary.value.find(g => g.id === currentGameId.value) || null
)
// 扫描到的运行中游戏
// Running game IDs matched by process names
const runningGames = ref<string[]>([])
const accelerationStartedAt = reactive<Record<string, number>>({})
// 初始化加载
async function init() {
try {
// @ts-ignore
@@ -47,31 +47,30 @@ export const useGameStore = defineStore('games', () => {
status: 'idle',
latency: 0
}))
// 默认选中第一个
if (!currentGameId.value && gameLibrary.value.length > 0) {
const hasCurrentGame = !!currentGameId.value && gameLibrary.value.some(g => g.id === currentGameId.value)
if (!hasCurrentGame && gameLibrary.value.length > 0) {
const firstGame = gameLibrary.value[0]
if (firstGame) {
if (firstGame && firstGame.id) {
currentGameId.value = firstGame.id
}
} else if (!hasCurrentGame) {
currentGameId.value = null
}
} catch (e) {
console.error('Failed to load games:', e)
}
}
// 添加游戏
async function addGame(game: Game) {
game = normalizeProxyModeForPreset(game)
// @ts-ignore
const updatedGames = await window.games.save(toIpcGame(game))
// 更新本地状态
syncGames(updatedGames)
}
// 更新游戏
async function updateGame(game: Game) {
game = normalizeProxyModeForPreset(game)
// 乐观更新:立即更新本地状态
// Optimistic local update
const index = gameLibrary.value.findIndex(g => g.id === game.id)
if (index !== -1) {
gameLibrary.value[index] = { ...gameLibrary.value[index], ...game }
@@ -82,23 +81,20 @@ export const useGameStore = defineStore('games', () => {
const updatedGames = await window.games.save(toIpcGame(game))
syncGames(updatedGames)
} catch (e) {
console.error('更新游戏失败:', e)
// 如果失败,可能需要回滚(这里暂不实现复杂回滚,依赖 syncGames 下次同步)
console.error('Failed to update game:', e)
}
}
// 删除游戏
async function removeGame(id: string) {
// @ts-ignore
const updatedGames = await window.games.delete(id)
syncGames(updatedGames)
if (currentGameId.value === id) {
const firstGame = gameLibrary.value[0]
currentGameId.value = (gameLibrary.value.length > 0 && firstGame) ? firstGame.id : null
currentGameId.value = (gameLibrary.value.length > 0 && firstGame && firstGame.id) ? firstGame.id : null
}
}
// 同步状态(保留运行状态)
function syncGames(remoteGames: Game[]) {
gameLibrary.value = remoteGames.map((rg: Game) => {
const existing = gameLibrary.value.find(lg => lg.id === rg.id)
@@ -116,7 +112,6 @@ export const useGameStore = defineStore('games', () => {
const game = gameLibrary.value.find(g => g.id === id)
if (game) {
game.lastPlayed = Date.now()
// 可选:更新 lastPlayed 到后端
// @ts-ignore
window.games.save(toIpcGame({ ...game, lastPlayed: Date.now() }))
}
@@ -127,6 +122,17 @@ export const useGameStore = defineStore('games', () => {
if (game) {
game.status = status
}
if (status === 'accelerating') {
if (!accelerationStartedAt[id]) {
accelerationStartedAt[id] = Date.now()
}
} else {
delete accelerationStartedAt[id]
}
}
function getAccelerationStartedAt(id: string) {
return accelerationStartedAt[id] || 0
}
function updateLatency(id: string, ms: number) {
@@ -136,12 +142,12 @@ export const useGameStore = defineStore('games', () => {
}
}
// 根据扫描到的进程名匹配游戏
function matchRunningGames(processNames: string[]) {
const matchedIds: string[] = []
processNames.forEach(pName => {
const pNameLower = pName.toLowerCase()
gameLibrary.value.forEach(game => {
if (!game.id) return
const targets = Array.isArray(game.processName) ? game.processName : [game.processName]
if (targets.some(t => t.toLowerCase() === pNameLower)) {
if (!matchedIds.includes(game.id)) {
@@ -154,7 +160,6 @@ export const useGameStore = defineStore('games', () => {
return matchedIds
}
// 启动加速
async function startGame(id: string) {
const game = gameLibrary.value.find(g => g.id === id)
if (!game) return
@@ -162,7 +167,7 @@ export const useGameStore = defineStore('games', () => {
const nodeStore = useNodeStore()
const selectedNodeId = String(game.nodeId || '')
const node = nodeStore.nodes.find(n => n.id === selectedNodeId || n.tag === selectedNodeId)
if (!node) {
const msg = 'Selected node not found. Please reselect a node and try again.'
console.error(msg, { gameId: id, nodeId: selectedNodeId })
@@ -175,34 +180,32 @@ export const useGameStore = defineStore('games', () => {
const config = String(generateSingboxConfig(rawGame, rawNode))
// @ts-ignore
await window.singbox.start(config)
const procs = Array.isArray(rawGame.processName) ? rawGame.processName.map(p => String(p)) : [String(rawGame.processName)]
// @ts-ignore
await window.proxyMonitor.start(String(rawGame.id), procs)
setGameStatus(id, 'accelerating')
} catch (e) {
console.error('启动加速失败:', e)
console.error('Failed to start game acceleration:', e)
throw e
}
}
// 停止加速
async function stopGame(id: string) {
try {
// @ts-ignore
await window.singbox.stop()
// @ts-ignore
await window.proxyMonitor.stop()
setGameStatus(id, 'idle')
} catch (e) {
console.error('停止加速失败:', e)
console.error('Failed to stop game acceleration:', e)
throw e
}
}
// 初始化调用
init()
return {
@@ -213,6 +216,7 @@ export const useGameStore = defineStore('games', () => {
init,
setCurrentGame,
setGameStatus,
getAccelerationStartedAt,
updateLatency,
matchRunningGames,
addGame,
+47 -2
View File
@@ -3,12 +3,27 @@ import { ref, reactive } from 'vue'
import { parseShareLink, parseBatchLinks, normalizeNodeType } from '@/utils/protocol'
import type { NodeConfig } from '@/utils/protocol'
import { useSettingsStore } from './settings'
import {
appendLatencyRecord,
getGameLatencyStats,
getRecentLatencyRecords,
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 }>>({})
const latencySessionReady = initLatencySessionStore().catch((e) => {
console.error('Failed to init latency session store:', e)
})
const settingsStore = useSettingsStore()
@@ -20,7 +35,7 @@ export const useNodeStore = defineStore('nodes', () => {
}
}
async function checkNode(node: NodeConfig, methodOverride?: CheckMethod) {
async function checkNode(node: NodeConfig, methodOverride?: CheckMethod, context?: CheckRecordContext) {
if (!node.server) return
const method = methodOverride || settingsStore.checkMethod
@@ -56,6 +71,24 @@ export const useNodeStore = defineStore('nodes', () => {
const key = node.id || node.tag
if (key) {
nodeStats[key] = result
if (context?.recordLatency) {
void latencySessionReady
.then(() => appendLatencyRecord({
nodeKey: key,
server: node.server,
port: node.server_port,
method,
latency: result.latency,
loss: result.loss,
timestamp: Date.now(),
gameId: context.gameId,
accelerationSeconds: context.accelerationSeconds,
sessionLossRate: context.sessionLossRate
}))
.catch((e) => {
console.error('Failed to append latency record:', e)
})
}
}
return result
}
@@ -86,6 +119,16 @@ export const useNodeStore = defineStore('nodes', () => {
}
}
async function getNodeLatencyHistory(nodeKey: string, limit = 240) {
await latencySessionReady
return getRecentLatencyRecords(nodeKey, limit)
}
async function getGameLatencyStatsForSession(gameId: string) {
await latencySessionReady
return getGameLatencyStats(gameId)
}
async function loadNodes() {
try {
// @ts-ignore
@@ -171,6 +214,8 @@ export const useNodeStore = defineStore('nodes', () => {
updateNode,
nodeStats,
checkNode,
checkAllNodes
checkAllNodes,
getNodeLatencyHistory,
getGameLatencyStatsForSession
}
})
+48
View File
@@ -104,6 +104,54 @@ html.theme-red .n-button.n-button--primary-type:hover {
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;
+84 -72
View File
@@ -2,48 +2,51 @@
/* Primitive Colors (Semantic Base) */
--rgb-white: 255, 255, 255;
--rgb-black: 0, 0, 0;
/* Status Colors (Default) */
--rgb-success: 34, 197, 94; /* #22c55e */
--rgb-success-bg: 220, 252, 231; /* #dcfce7 */
--rgb-on-success: 255, 255, 255; /* #ffffff */
--rgb-warning: 234, 179, 8; /* #eab308 */
--rgb-warning-bg: 254, 249, 195; /* #fef9c3 */
--rgb-on-warning: 255, 255, 255; /* #ffffff */
--rgb-error: 239, 68, 68; /* #ef4444 */
--rgb-error-bg: 254, 226, 226; /* #fee2e2 */
--rgb-on-error: 255, 255, 255; /* #ffffff */
--rgb-info: 59, 130, 246; /* #3b82f6 */
--rgb-info-bg: 219, 234, 254; /* #dbeafe */
--rgb-on-info: 255, 255, 255; /* #ffffff */
/* Theme: Light (Default) */
--rgb-primary: 62, 175, 124; /* #3eaf7c */
--rgb-primary-hover: 16, 185, 129;/* #10b981 */
--rgb-on-primary: 255, 255, 255; /* #ffffff */
--rgb-secondary: 100, 116, 139; /* #64748b */
--rgb-secondary-hover: 71, 85, 105;/* #475569 */
--rgb-on-secondary: 255, 255, 255;/* #ffffff */
/* 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-background: 255, 255, 255; /* #ffffff */
--rgb-surface: 248, 250, 252; /* #f8fafc */
--rgb-surface-panel: 255, 255, 255;/* #ffffff */
--rgb-surface-overlay: 255, 255, 255;/* #ffffff */
--rgb-on-surface: 15, 23, 42; /* #0f172a */
--rgb-on-surface-variant: 71, 85, 105; /* #475569 */
--rgb-on-surface-muted: 148, 163, 184; /* #94a3b8 */
--rgb-border: 226, 232, 240; /* #e2e8f0 */
--rgb-border-hover: 203, 213, 225;/* #cbd5e1 */
--rgb-overlay: 0, 0, 0;
/* CSS Variables for direct usage (Legacy/Fallback) */
/* CSS Variables for direct usage */
--color-primary: rgb(var(--rgb-primary));
--color-background: rgb(var(--rgb-background));
--color-surface: rgb(var(--rgb-surface));
@@ -53,61 +56,70 @@
/* Theme Colors Override */
html.theme-green {
--rgb-primary: 62, 175, 124;
--rgb-primary-hover: 16, 185, 129;
--rgb-primary: 16, 185, 129; /* Emerald-500 */
--rgb-primary-hover: 5, 150, 105; /* Emerald-600 */
}
html.theme-blue {
--rgb-primary: 59, 130, 246;
--rgb-primary-hover: 37, 99, 235;
--rgb-primary: 59, 130, 246; /* Blue-500 */
--rgb-primary-hover: 37, 99, 235; /* Blue-600 */
}
html.theme-purple {
--rgb-primary: 139, 92, 246;
--rgb-primary-hover: 124, 58, 237;
--rgb-primary: 139, 92, 246; /* Violet-500 */
--rgb-primary-hover: 124, 58, 237; /* Violet-600 */
}
html.theme-orange {
--rgb-primary: 249, 115, 22;
--rgb-primary-hover: 234, 88, 12;
--rgb-primary: 249, 115, 22; /* Orange-500 */
--rgb-primary-hover: 234, 88, 12; /* Orange-600 */
}
html.theme-red {
--rgb-primary: 239, 68, 68;
--rgb-primary-hover: 220, 38, 38;
--rgb-primary: 239, 68, 68; /* Red-500 */
--rgb-primary-hover: 220, 38, 38; /* Red-600 */
}
html.dark {
/* Theme: Dark */
/* Theme: Dark (Deep Midnight Noir) */
--rgb-on-primary: 255, 255, 255;
--rgb-secondary: 71, 85, 105; /* #475569 */
--rgb-secondary-hover: 51, 65, 85;/* #334155 */
--rgb-on-secondary: 248, 250, 252;/* #f8fafc */
--rgb-secondary: 148, 163, 184; /* Slate-400 */
--rgb-secondary-hover: 226, 232, 240; /* Slate-200 */
--rgb-on-secondary: 15, 23, 42; /* Slate-900 */
--rgb-background: 15, 23, 42; /* #0f172a */
--rgb-surface: 15, 23, 42; /* #0f172a */
--rgb-surface-panel: 30, 41, 59; /* #1e293b */
--rgb-surface-overlay: 51, 65, 85;/* #334155 */
--rgb-on-surface: 241, 245, 249; /* #f1f5f9 */
--rgb-on-surface-variant: 148, 163, 184; /* #94a3b8 */
--rgb-on-surface-muted: 71, 85, 105; /* #475569 */
/* 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 */
--rgb-border: 51, 65, 85; /* #334155 */
--rgb-border-hover: 71, 85, 105; /* #475569 */
/* 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 */
--rgb-success: 74, 222, 128; /* #4ade80 */
--rgb-warning: 250, 204, 21; /* #facc15 */
--rgb-error: 248, 113, 113; /* #f87171 */
--rgb-info: 96, 165, 250; /* #60a5fa */
/* 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);
*,
*::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 {
*,
*::before,
*::after {
transition: none !important;
}
}
-113
View File
@@ -1,113 +0,0 @@
export interface Game {
id: string
name: string
iconUrl?: string
processName: string | string[]
category: string
tags?: string[]
profileId?: string
lastPlayed?: number
status?: 'idle' | 'accelerating'
latency?: number
nodeId?: string
proxyMode?: 'process' | 'routing'
routingRules?: string[]
chainProxy?: boolean
}
export interface Category {
id: string
name: string
parentId?: string
rules?: string[]
order?: number
}
export interface ConfigProfile {
id: string
name: string
description?: string
rules: {
type: 'process' | 'domain' | 'ip'
value: string[]
outbound: 'proxy' | 'direct' | 'block'
}[]
chainProxy: boolean
}
export interface ProcessInfo {
pid: number
ppid: number
name: string
path?: string
children?: ProcessInfo[]
}
declare global {
interface ElectronAPI {
minimize: () => Promise<void>
maximize: () => Promise<void>
close: () => Promise<void>
pickImage: () => Promise<string | null>
pickProcess: () => Promise<string[] | null>
pickProcessFolder: (maxDepth?: number) => Promise<string[] | null>
on: (channel: string, callback: (...args: any[]) => void) => void
off: (channel: string, callback: (...args: any[]) => void) => void
}
interface SingBoxAPI {
start: (config: string) => Promise<void>
stop: () => Promise<void>
restart: (config: string) => Promise<void>
}
interface GamesAPI {
getAll: () => Promise<Game[]>
save: (game: Game) => Promise<Game[]>
delete: (id: string) => Promise<Game[]>
}
interface CategoriesAPI {
getAll: () => Promise<Category[]>
save: (category: Category) => Promise<Category[]>
delete: (id: string) => Promise<Category[]>
}
interface ProfilesAPI {
getAll: () => Promise<ConfigProfile[]>
save: (profile: ConfigProfile) => Promise<ConfigProfile[]>
delete: (id: string) => Promise<ConfigProfile[]>
}
interface SystemAPI {
scanProcesses: () => Promise<string[]>
getProcessTree: () => Promise<ProcessInfo[]>
ping: (host: string) => Promise<{ latency: number; loss: number }>
tcpPing: (host: string, port: number) => Promise<{ latency: number; loss: number }>
}
interface ProxyMonitorAPI {
start: (gameId: string, processNames: string[]) => Promise<void>
stop: () => Promise<void>
}
interface NodesAPI {
getAll: () => Promise<any[]>
save: (node: any) => Promise<any[]>
delete: (id: string) => Promise<any[]>
import: (nodes: any[]) => Promise<any[]>
}
interface Window {
electron: ElectronAPI
singbox: SingBoxAPI
games: GamesAPI
categories: CategoriesAPI
profiles: ProfilesAPI
system: SystemAPI
proxyMonitor: ProxyMonitorAPI
nodes: NodesAPI
}
}
export { }
+25
View File
@@ -0,0 +1,25 @@
export interface Game {
id?: string
name: string
iconUrl?: string
processName: string | string[]
category: string
tags?: string[]
profileId?: string
lastPlayed?: number
status?: 'idle' | 'accelerating'
latency?: number
nodeId?: string
proxyMode?: 'process' | 'routing'
routingRules?: string[]
chainProxy?: boolean
}
export interface Category {
id: string
name: string
parentId?: string
rules?: string[]
icon?: string
order?: number
}
+138
View File
@@ -0,0 +1,138 @@
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
}
const DB_NAME = 'lagzero-latency-session'
const DB_VERSION = 2
const STORE_NAME = 'latency_records'
let dbPromise: Promise<IDBDatabase> | null = null
let initialized = false
function openDb(): Promise<IDBDatabase> {
if (dbPromise) return dbPromise
dbPromise = new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION)
request.onupgradeneeded = () => {
const db = request.result
let store: IDBObjectStore
if (!db.objectStoreNames.contains(STORE_NAME)) {
store = db.createObjectStore(STORE_NAME, { keyPath: 'id', autoIncrement: true })
} else {
const tx = request.transaction
if (!tx) return
store = tx.objectStore(STORE_NAME)
}
if (!store.indexNames.contains('nodeKey')) {
store.createIndex('nodeKey', 'nodeKey', { unique: false })
}
if (!store.indexNames.contains('timestamp')) {
store.createIndex('timestamp', 'timestamp', { unique: false })
}
if (!store.indexNames.contains('nodeKey_timestamp')) {
store.createIndex('nodeKey_timestamp', ['nodeKey', 'timestamp'], { unique: false })
}
if (!store.indexNames.contains('gameId_timestamp')) {
store.createIndex('gameId_timestamp', ['gameId', 'timestamp'], { unique: false })
}
}
request.onsuccess = () => resolve(request.result)
request.onerror = () => reject(request.error ?? new Error('Failed to open latency session DB'))
})
return dbPromise
}
function txDone(tx: IDBTransaction): Promise<void> {
return new Promise((resolve, reject) => {
tx.oncomplete = () => resolve()
tx.onerror = () => reject(tx.error ?? new Error('IndexedDB transaction failed'))
tx.onabort = () => reject(tx.error ?? new Error('IndexedDB transaction aborted'))
})
}
export async function initLatencySessionStore() {
const db = await openDb()
if (initialized) return
const tx = db.transaction(STORE_NAME, 'readwrite')
tx.objectStore(STORE_NAME).clear()
await txDone(tx)
initialized = true
}
export async function appendLatencyRecord(record: LatencyRecord) {
const db = await openDb()
const tx = db.transaction(STORE_NAME, 'readwrite')
tx.objectStore(STORE_NAME).add(record)
await txDone(tx)
}
export async function getRecentLatencyRecords(nodeKey: string, limit = 240): Promise<LatencyRecord[]> {
if (!nodeKey || limit <= 0) return []
const db = await openDb()
const tx = db.transaction(STORE_NAME, 'readonly')
const index = tx.objectStore(STORE_NAME).index('nodeKey_timestamp')
const range = IDBKeyRange.bound([nodeKey, 0], [nodeKey, Number.MAX_SAFE_INTEGER])
return new Promise((resolve, reject) => {
const records: LatencyRecord[] = []
const req = index.openCursor(range, 'prev')
req.onsuccess = () => {
const cursor = req.result
if (!cursor || records.length >= limit) {
resolve(records.reverse())
return
}
records.push(cursor.value as LatencyRecord)
cursor.continue()
}
req.onerror = () => reject(req.error ?? new Error('Failed to read latency records'))
})
}
export async function getGameLatencyStats(gameId: string): Promise<{ total: number; lost: number }> {
if (!gameId) return { total: 0, lost: 0 }
const db = await openDb()
const tx = db.transaction(STORE_NAME, 'readonly')
const index = tx.objectStore(STORE_NAME).index('gameId_timestamp')
const range = IDBKeyRange.bound([gameId, 0], [gameId, Number.MAX_SAFE_INTEGER])
return new Promise((resolve, reject) => {
let total = 0
let lost = 0
const req = index.openCursor(range, 'next')
req.onsuccess = () => {
const cursor = req.result
if (!cursor) {
resolve({ total, lost })
return
}
const row = cursor.value as LatencyRecord
total += 1
if (row.loss > 0 || row.latency <= 0) {
lost += 1
}
cursor.continue()
}
req.onerror = () => reject(req.error ?? new Error('Failed to read game latency stats'))
})
}
+121 -46
View File
@@ -77,30 +77,27 @@
<main class="flex-1 overflow-y-auto custom-scrollbar p-6 lg:p-10 flex flex-col gap-8 relative">
<!-- 1. Hero Section: The Status Core -->
<section class="flex flex-col items-center justify-center min-h-[450px] flex-1 py-10 relative">
<!-- HUD Status Card (Floating) -->
<div v-if="isRunning"
class="absolute top-0 w-full max-w-2xl mx-auto flex justify-center animate-fade-in-up md:static md:mb-12">
<section class="flex flex-col items-center justify-center min-h-[500px] ">
<div v-if="isRunning" class=" w-full max-w-2xl flex justify-center">
<div
class="flex items-center gap-8 md:gap-12 px-8 py-4 bg-surface-panel/40 backdrop-blur-lg border border-border/50 rounded-2xl shadow-sm">
<div class="flex flex-col items-center">
<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>
<!-- Control Center -->
<div class="relative z-10 flex flex-col items-center">
<!-- Latency Big Display -->
<div v-if="isRunning" class="mb-12 text-center animate-scale-in">
@@ -167,13 +164,12 @@
<!-- 2. Secondary Info: Charts -->
<section v-if="isRunning"
class="h-48 w-full max-w-4xl mx-auto opacity-90 hover:opacity-100 transition-opacity">
class=" w-full max-w-4xl mx-auto opacity-90 hover:opacity-100 transition-opacity">
<div
class="w-full h-full bg-surface-panel/40 border border-border rounded-2xl p-4 relative overflow-hidden shadow-sm backdrop-blur-sm">
<div
class="absolute top-4 left-4 z-10 text-[10px] font-black uppercase text-on-surface-muted tracking-widest">
<div class="z-10 text-[10px] font-black uppercase text-on-surface-muted tracking-widest">
{{ $t('common.network_stability') }}</div>
<LatencyChart />
<LatencyChart :node-key="selectedNode || undefined" />
</div>
</section>
@@ -243,13 +239,13 @@
</template>
<script setup lang="ts">
import { ref, computed, onUnmounted, onMounted } from 'vue'
import { ref, computed, onUnmounted, onMounted, onActivated, watch } from 'vue'
import { useGameStore, isRoutingOnlyPresetGame } from '@/stores/games'
import { useCategoryStore } from '@/stores/categories'
import { useNodeStore } from '@/stores/nodes'
import { useSettingsStore } from '@/stores/settings'
import LatencyChart from '@/components/dashboard/LatencyChart.vue'
import NodeSelector from '@/components/node/NodeSelector.vue'
import NodeSelector from '@/components/dashboard/NodeSelector.vue'
import { useIntervalFn } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { useMessage } from 'naive-ui'
@@ -273,12 +269,13 @@ const durationSeconds = ref(0)
const lastConnection = ref('')
const isSwitchingNode = ref(false)
const pendingNodeRestart = ref(false)
const totalSamples = ref(0)
const lostSamples = ref(0)
// Computed Helpers
const categoryLabel = computed(() => {
if (!game.value) return ''
const cat = categoryStore.categories.find(c => c.id === game.value!.category)
// @ts-ignore
const cat = categoryStore.categories.find((c: any) => c.id === game.value!.category)
return cat?.name || i18n.global.t('common.uncategorized')
})
@@ -314,6 +311,7 @@ async function restartForNodeSwitch() {
isSwitchingNode.value = true
try {
if (!game.value?.id) return
do {
pendingNodeRestart.value = false
await gameStore.stopGame(game.value.id)
@@ -334,31 +332,75 @@ async function onNodeChange(val: string) {
await gameStore.updateGame({ ...game.value, nodeId: val })
await restartForNodeSwitch()
if (isRunning.value) {
void sampleLatencyAndLoss(true)
}
}
// Lifecycle & Updates
const { pause } = useIntervalFn(async () => {
if (isRunning.value) {
if (game.value?.nodeId) {
const node = nodeStore.nodes.find(n => (n.id === game.value!.nodeId) || (n.tag === game.value!.nodeId))
if (node) {
const stats = await nodeStore.checkNode(node, isRunning.value ? 'tcp' : undefined)
if (stats) {
currentLatency.value = stats.latency
currentLoss.value = stats.loss
gameStore.updateLatency(game.value.id, currentLatency.value)
}
}
}
if (startTime.value > 0) {
durationSeconds.value = Math.floor((Date.now() - startTime.value) / 1000)
}
} else {
currentLatency.value = 0
currentLoss.value = 0
function refreshDuration() {
if (!isRunning.value || startTime.value <= 0) {
durationSeconds.value = 0
return
}
}, checkInterval)
durationSeconds.value = Math.floor((Date.now() - startTime.value) / 1000)
}
function refreshSessionLossRate() {
if (totalSamples.value <= 0) {
currentLoss.value = 0
return
}
currentLoss.value = Math.round((lostSamples.value / totalSamples.value) * 100)
}
async function syncSessionStateFromStore() {
if (!game.value?.id) return
const sessionStart = gameStore.getAccelerationStartedAt(game.value.id)
if (sessionStart > 0) {
startTime.value = sessionStart
} else if (isRunning.value) {
startTime.value = Date.now()
}
const stats = await nodeStore.getGameLatencyStatsForSession(game.value.id)
totalSamples.value = stats.total
lostSamples.value = stats.lost
refreshSessionLossRate()
refreshDuration()
}
async function sampleLatencyAndLoss(recordLatency: boolean) {
if (!isRunning.value || !game.value?.id || !game.value.nodeId) return
const node = nodeStore.nodes.find(n => (n.id === game.value!.nodeId) || (n.tag === game.value!.nodeId))
if (!node) return
const stats = await nodeStore.checkNode(node, undefined, {
recordLatency,
gameId: game.value.id,
accelerationSeconds: durationSeconds.value,
sessionLossRate: currentLoss.value
})
if (!stats) return
currentLatency.value = stats.latency
gameStore.updateLatency(game.value.id, currentLatency.value)
if (recordLatency) {
totalSamples.value += 1
if (stats.loss > 0 || stats.latency <= 0) {
lostSamples.value += 1
}
}
refreshSessionLossRate()
}
const { pause: pauseSampling, resume: resumeSampling } = useIntervalFn(() => {
void sampleLatencyAndLoss(true)
}, checkInterval, { immediate: false })
const { pause: pauseDuration, resume: resumeDuration } = useIntervalFn(() => {
refreshDuration()
}, 1000, { immediate: false })
async function toggleAccelerator() {
if (!game.value) return
@@ -366,10 +408,14 @@ async function toggleAccelerator() {
if (isRunning.value) {
try {
await gameStore.stopGame(game.value.id)
if (game.value.id) await gameStore.stopGame(game.value.id)
} finally {
startTime.value = 0
totalSamples.value = 0
lostSamples.value = 0
lastConnection.value = ''
refreshSessionLossRate()
refreshDuration()
}
} else {
if (!selectedNode.value) {
@@ -377,8 +423,9 @@ async function toggleAccelerator() {
return
}
try {
await gameStore.startGame(game.value.id)
startTime.value = Date.now()
if (game.value.id) await gameStore.startGame(game.value.id)
await syncSessionStateFromStore()
await sampleLatencyAndLoss(true)
} catch (e: any) {
message.error(String(e?.message || e || i18n.global.t('dashboard.start_failed')))
}
@@ -406,7 +453,7 @@ function onSingboxEvent(name: string, data: any) {
} else if (name === 'singbox-error') {
message.error(String(data || i18n.global.t('dashboard.singbox_error')))
} else if (name === 'singbox-status' && data === 'stopped') {
if (game.value && isRunning.value) {
if (game.value?.id && isRunning.value) {
gameStore.setGameStatus(game.value.id, 'idle')
startTime.value = 0
lastConnection.value = ''
@@ -416,16 +463,44 @@ function onSingboxEvent(name: string, data: any) {
onMounted(() => {
nodeStore.loadNodes()
// @ts-ignore
if (isRunning.value) {
void syncSessionStateFromStore().then(() => sampleLatencyAndLoss(true))
resumeSampling()
resumeDuration()
}
if (window.electron?.on) {
window.electron.on('singbox-log', (d) => onSingboxEvent('singbox-log', d))
window.electron.on('singbox-error', (d) => onSingboxEvent('singbox-error', d))
window.electron.on('singbox-status', (d) => onSingboxEvent('singbox-status', d))
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))
}
})
onActivated(() => {
if (isRunning.value) {
void syncSessionStateFromStore().then(() => sampleLatencyAndLoss(true))
}
})
watch(isRunning, (running) => {
if (running) {
void syncSessionStateFromStore().then(() => sampleLatencyAndLoss(true))
resumeSampling()
resumeDuration()
} else {
pauseSampling()
pauseDuration()
startTime.value = 0
durationSeconds.value = 0
currentLatency.value = 0
totalSamples.value = 0
lostSamples.value = 0
refreshSessionLossRate()
}
}, { immediate: true })
onUnmounted(() => {
pause()
pauseSampling()
pauseDuration()
// @ts-ignore
if (window.electron?.off) {
// Logic for cleanup if necessary
+15 -11
View File
@@ -68,11 +68,12 @@
<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="selectGame(game.id)"
<div v-for="game in filteredGames" :key="game.id" @click="game.id && selectGame(game.id!)"
class="bg-surface-panel border border-transparent hover:border-primary rounded-lg cursor-pointer transition group relative"
:class="viewMode === 'grid' ? 'p-4 flex flex-col items-center text-center' : 'p-3 flex items-center gap-4'">
:class="viewMode === 'grid' ? 'p-4 flex flex-col items-center text-center' : 'p-3 pr-24 flex items-center gap-4'">
<!-- Action Buttons (Hover) -->
<div class="absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<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>
@@ -108,7 +109,7 @@
<div class="w-2 h-2 rounded-full bg-primary"></div>
{{ $t('games.selected') }}
</div>
<div v-else-if="runningGames.includes(game.id)"
<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') }}
@@ -156,7 +157,7 @@ onMounted(() => {
})
const categories = computed(() => {
const list = categoryStore.categories.map(c => ({
const list = categoryStore.categories.map((c: any) => ({
label: c.name,
value: c.id
}))
@@ -164,18 +165,18 @@ const categories = computed(() => {
})
function getCategoryLabel(id: string) {
const cat = categoryStore.categories.find(c => c.id === id)
const cat = categoryStore.categories.find((c: any) => c.id === id)
return cat?.name || '未分类'
}
const filteredGames = computed(() => {
return gameStore.gameLibrary.filter(game => {
return gameStore.gameLibrary.filter((game: Game) => {
const matchSearch = game.name.toLowerCase().includes(searchQuery.value.toLowerCase())
const matchCat = activeCategory.value === 'all' || game.category === activeCategory.value
return matchSearch && matchCat
}).sort((a, b) => {
const aRunning = runningGames.value.includes(a.id)
const bRunning = runningGames.value.includes(b.id)
}).sort((a: Game, b: Game) => {
const aRunning = a.id ? runningGames.value.includes(a.id) : false
const bRunning = b.id ? runningGames.value.includes(b.id) : false
if (aRunning !== bRunning) return aRunning ? -1 : 1
const aTime = a.lastPlayed || 0
@@ -223,8 +224,11 @@ async function deleteGame(game: Game) {
content: t('games.delete_confirm') || '您确定要删除这个游戏吗?',
positiveText: t('common.delete') || '删除',
negativeText: t('common.cancel') || '取消',
positiveButtonProps: {
type: 'error'
},
onPositiveClick: async () => {
await gameStore.removeGame(game.id)
if (game.id) await gameStore.removeGame(game.id)
message.success(t('common.deleted') || '已删除')
}
})
+38
View File
@@ -7,7 +7,45 @@ declare module '*.vue' {
}
interface Window {
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[]>
getProcessTree: () => Promise<any>
ping: (host: string) => Promise<number>
tcpPing: (host: string, port: number) => Promise<number>
}
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[]>
}
}
+2 -2
View File
@@ -24,11 +24,11 @@ export default defineConfig({
]
}
],
dts: 'src/auto-imports.d.ts'
dts: 'src/autoImport/auto-imports.d.ts'
}),
Components({
resolvers: [NaiveUiResolver()],
dts: 'src/components.d.ts'
dts: 'src/autoImport/components.d.ts'
}),
electron({
main: {