feat: 添加延迟会话存储并优化游戏库与节点管理
- 新增 IndexedDB 存储用于记录节点延迟历史与游戏会话统计 - 重构游戏库状态管理,支持游戏加速时长跟踪与本地存储当前选中游戏 - 优化节点管理界面样式与交互,为删除操作添加危险按钮样式 - 修复游戏库视图中的类型安全与可选 ID 处理问题 - 更新主题配色方案与 CSS 变量,改进深色模式视觉效果 - 移除已弃用的类型定义文件,统一使用新的类型导出结构 - 新增 .editorconfig 与 .gitattributes 文件以统一代码风格与行尾设置 - 调整构建配置,将自动导入类型定义移至专用目录
This commit is contained in:
1 parent
5e6a704caa
commit
e85c4e9d7c
27 files changed
+809
-602
No files matched your search
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
* text=auto eol=lf
|
||||
@@ -10,6 +10,7 @@ coverage
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
dist-electron
|
||||
*.local
|
||||
.lagzero-dev
|
||||
# Editor directories and files
|
||||
|
||||
@@ -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
@@ -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
@@ -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']
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
File renamed without changes.
@@ -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 8.8.8.8"
|
||||
/>
|
||||
<n-input v-model:value="routingRulesText" type="textarea" :rows="5" class="font-mono"
|
||||
placeholder="1.1.1.1/32 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 = {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
})
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
Vendored
-113
@@ -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 { }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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') || '已删除')
|
||||
}
|
||||
})
|
||||
|
||||
Vendored
+38
@@ -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
@@ -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: {
|
||||
|
||||
Reference in new issue
Block a user