feat: redesign settings with tabs, autostart registry, background mode, and about update check

This commit is contained in:
weidu12123 committed 2026-10-13 17:30:23 +08:00
1 parent 76627aad6d
commit ace7e83f58
3 files changed
+671 -42

No files matched your search

+445 -40
View File
@@ -107,6 +107,41 @@ const mcpStatus = ref<{
codexSynced: boolean;
} | null>(null);
// 设置页分类导航与状态
type SettingsTabKey = 'general' | 'security' | 'mcp' | 'about';
const activeSettingsTab = ref<SettingsTabKey>('general');
const autostartEnabled = ref(false);
const closeAction = ref<'tray' | 'exit'>('tray');
const savingAutostart = ref(false);
const appAbout = ref<{
version: string;
description: string;
exePath: string;
port: number;
githubUrl: string;
stack: string;
} | null>(null);
const updateStatus = ref<{
checking: boolean;
checked: boolean;
hasUpdate: boolean;
currentVersion: string;
latestVersion: string;
publishedAt?: string;
releaseNotes?: string;
releaseUrl?: string;
message?: string;
}>({
checking: false,
checked: false,
hasUpdate: false,
currentVersion: 'v0.1.0',
latestVersion: 'v0.1.0',
});
// 拖拽排序
const draggingId = ref<string | null>(null);
const dragOverId = ref<string | null>(null);
@@ -320,9 +355,101 @@ async function loadSettings() {
if (data.cliSync) {
mcpStatus.value = data.cliSync;
}
if (data.autostart !== undefined) {
autostartEnabled.value = Boolean(data.autostart);
}
if (data.closeAction) {
closeAction.value = data.closeAction === 'exit' ? 'exit' : 'tray';
}
} catch {
// ignore
}
void loadAutostartSettings();
void loadAboutInfo();
}
async function loadAutostartSettings() {
try {
const res = await fetch('/api/system/autostart');
const data = await res.json();
if (data.ok) {
autostartEnabled.value = Boolean(data.autostart);
closeAction.value = data.closeAction === 'exit' ? 'exit' : 'tray';
}
} catch {
// ignore
}
}
async function saveAutostartSettings() {
savingAutostart.value = true;
try {
const res = await fetch('/api/system/autostart', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
autostart: autostartEnabled.value,
closeAction: closeAction.value,
}),
});
const data = await res.json();
if (!data.ok) {
showToast(data.error || '保存自启与运行设置失败', 'err');
return;
}
autostartEnabled.value = Boolean(data.autostart);
closeAction.value = data.closeAction === 'exit' ? 'exit' : 'tray';
showToast('开机自启与后台常驻设置已生效');
} catch (e) {
showToast(`保存失败: ${(e as Error).message}`, 'err');
} finally {
savingAutostart.value = false;
}
}
async function loadAboutInfo() {
try {
const res = await fetch('/api/system/about');
const data = await res.json();
if (data.ok) {
appAbout.value = data;
updateStatus.value.currentVersion = data.version;
}
} catch {
// ignore
}
}
async function checkAppUpdate() {
updateStatus.value.checking = true;
try {
const res = await fetch('/api/system/check-update', { method: 'POST' });
const data = await res.json();
if (data.ok) {
updateStatus.value = {
checking: false,
checked: true,
hasUpdate: Boolean(data.hasUpdate),
currentVersion: data.currentVersion || 'v0.1.0',
latestVersion: data.latestVersion || 'v0.1.0',
publishedAt: data.publishedAt,
releaseNotes: data.releaseNotes,
releaseUrl: data.releaseUrl,
message: data.message,
};
if (data.hasUpdate) {
showToast(`发现新版本 ${data.latestVersion}!`);
} else {
showToast('当前已是最新版本');
}
} else {
showToast('检查更新失败', 'err');
}
} catch (e) {
showToast(`检查更新出错: ${(e as Error).message}`, 'err');
} finally {
updateStatus.value.checking = false;
}
}
async function saveMcpSettings(regenerateKey = false) {
@@ -441,8 +568,11 @@ function copyText(text: string, msg: string) {
showToast(msg);
}
function openSettingsPage() {
if (activePage.value === 'settings') {
function openSettingsPage(tab?: SettingsTabKey) {
if (tab) {
activeSettingsTab.value = tab;
}
if (activePage.value === 'settings' && !tab) {
activePage.value = 'workspace';
} else {
activePage.value = 'settings';
@@ -1453,6 +1583,205 @@ onBeforeUnmount(() => {
</div>
</div>
<!-- 分类切换导航条(毛玻璃胶囊 Tab) -->
<div class="flex items-center gap-1.5 overflow-x-auto rounded-2xl border border-zinc-200/80 bg-white/75 p-1.5 backdrop-blur-xl dark:border-zinc-800 dark:bg-zinc-900/75">
<button
type="button"
class="flex items-center gap-2 rounded-xl px-4 py-2 text-xs font-medium transition cursor-pointer"
:class="
activeSettingsTab === 'general'
? 'bg-zinc-900 text-white shadow-sm dark:bg-zinc-100 dark:text-zinc-900'
: 'text-zinc-600 hover:bg-zinc-100/80 dark:text-zinc-400 dark:hover:bg-zinc-800'
"
@click="activeSettingsTab = 'general'"
>
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
<span>常规与自启</span>
</button>
<button
type="button"
class="flex items-center gap-2 rounded-xl px-4 py-2 text-xs font-medium transition cursor-pointer"
:class="
activeSettingsTab === 'security'
? 'bg-zinc-900 text-white shadow-sm dark:bg-zinc-100 dark:text-zinc-900'
: 'text-zinc-600 hover:bg-zinc-100/80 dark:text-zinc-400 dark:hover:bg-zinc-800'
"
@click="activeSettingsTab = 'security'"
>
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
<span>访问安全</span>
</button>
<button
type="button"
class="flex items-center gap-2 rounded-xl px-4 py-2 text-xs font-medium transition cursor-pointer"
:class="
activeSettingsTab === 'mcp'
? 'bg-zinc-900 text-white shadow-sm dark:bg-zinc-100 dark:text-zinc-900'
: 'text-zinc-600 hover:bg-zinc-100/80 dark:text-zinc-400 dark:hover:bg-zinc-800'
"
@click="activeSettingsTab = 'mcp'"
>
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2" />
</svg>
<span>MCP Server</span>
</button>
<button
type="button"
class="flex items-center gap-2 rounded-xl px-4 py-2 text-xs font-medium transition cursor-pointer"
:class="
activeSettingsTab === 'about'
? 'bg-zinc-900 text-white shadow-sm dark:bg-zinc-100 dark:text-zinc-900'
: 'text-zinc-600 hover:bg-zinc-100/80 dark:text-zinc-400 dark:hover:bg-zinc-800'
"
@click="activeSettingsTab = 'about'"
>
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="16" x2="12" y2="12" />
<line x1="12" y1="8" x2="12.01" y2="8" />
</svg>
<span>关于与更新</span>
<span
v-if="updateStatus.hasUpdate"
class="h-2 w-2 rounded-full bg-emerald-500 animate-ping"
/>
</button>
</div>
<!-- 1. 常规与自启 -->
<div v-show="activeSettingsTab === 'general'" class="space-y-4">
<!-- 开机自启与后台常驻 -->
<section
class="space-y-4 rounded-2xl border border-zinc-200/80 bg-white/85 p-5 shadow-[0_2px_12px_rgba(0,0,0,0.02)] backdrop-blur-xl dark:border-zinc-800 dark:bg-zinc-900/85"
>
<div class="flex items-center justify-between border-b border-zinc-100 pb-3.5 dark:border-zinc-800">
<div>
<h3 class="text-sm font-semibold">开机自启与后台常驻</h3>
<p class="text-xs text-zinc-400">
配置 Windows 开机静默后台启动与窗口关闭时的托盘常驻行为
</p>
</div>
</div>
<!-- 开机自启开关 -->
<div class="flex items-center justify-between rounded-xl border border-zinc-200/70 bg-zinc-50/60 p-3.5 dark:border-zinc-800 dark:bg-zinc-800/40">
<div>
<div class="text-xs font-semibold">Windows 登录时自动启动</div>
<div class="mt-0.5 text-[11px] text-zinc-400">
开机登录后自动在后台启动 VPS Switch 并静默常驻系统托盘,无需手动双击运行
</div>
</div>
<label class="inline-flex items-center gap-2 cursor-pointer">
<span class="text-xs font-medium text-zinc-600 dark:text-zinc-300">
{{ autostartEnabled ? '已开启' : '已关闭' }}
</span>
<input
v-model="autostartEnabled"
type="checkbox"
class="h-4 w-4 rounded border-zinc-300 text-zinc-900 cursor-pointer"
:disabled="savingAutostart"
@change="saveAutostartSettings"
/>
</label>
</div>
<!-- 窗口关闭行为 -->
<div class="flex items-center justify-between rounded-xl border border-zinc-200/70 bg-zinc-50/60 p-3.5 dark:border-zinc-800 dark:bg-zinc-800/40">
<div>
<div class="text-xs font-semibold">点击窗口右上角关闭按钮 (✕) 时</div>
<div class="mt-0.5 text-[11px] text-zinc-400">
推荐最小化到系统托盘,保持 SSH 连接池与 MCP Server 在后台随时可用
</div>
</div>
<div class="flex rounded-xl bg-zinc-200/70 p-0.5 text-xs dark:bg-zinc-800">
<button
type="button"
class="rounded-lg px-2.5 py-1 font-medium transition cursor-pointer"
:class="closeAction === 'tray' ? 'bg-white text-zinc-900 shadow-sm dark:bg-zinc-700 dark:text-zinc-100' : 'text-zinc-500'"
@click="closeAction = 'tray'; saveAutostartSettings()"
>
最小化到托盘
</button>
<button
type="button"
class="rounded-lg px-2.5 py-1 font-medium transition cursor-pointer"
:class="closeAction === 'exit' ? 'bg-white text-zinc-900 shadow-sm dark:bg-zinc-700 dark:text-zinc-100' : 'text-zinc-500'"
@click="closeAction = 'exit'; saveAutostartSettings()"
>
直接退出程序
</button>
</div>
</div>
</section>
<!-- 界面与外观主题 -->
<section
class="grid gap-3 rounded-2xl border border-zinc-200/80 bg-white/85 p-5 shadow-[0_2px_12px_rgba(0,0,0,0.02)] backdrop-blur-xl sm:grid-cols-2 dark:border-zinc-800 dark:bg-zinc-900/85"
>
<div class="flex items-center justify-between rounded-xl border border-zinc-200/70 bg-zinc-50/60 p-3.5 dark:border-zinc-800 dark:bg-zinc-800/40">
<div>
<div class="text-xs font-semibold">界面昼夜主题</div>
<div class="mt-0.5 text-[11px] text-zinc-400">日间白色毛玻璃 / 夜间深空黑毛玻璃</div>
</div>
<div class="flex rounded-xl bg-zinc-200/70 p-0.5 text-xs dark:bg-zinc-800">
<button
type="button"
class="rounded-lg px-2.5 py-1 font-medium transition cursor-pointer"
:class="!isDark ? 'bg-white text-zinc-900 shadow-sm' : 'text-zinc-500'"
@click="applyTheme(false)"
>
☀ 日间
</button>
<button
type="button"
class="rounded-lg px-2.5 py-1 font-medium transition cursor-pointer"
:class="isDark ? 'bg-zinc-700 text-white shadow-sm' : 'text-zinc-500'"
@click="applyTheme(true)"
>
☾ 夜间
</button>
</div>
</div>
<div class="flex items-center justify-between rounded-xl border border-zinc-200/70 bg-zinc-50/60 p-3.5 dark:border-zinc-800 dark:bg-zinc-800/40">
<div>
<div class="text-xs font-semibold">终端画布配色</div>
<div class="mt-0.5 text-[11px] text-zinc-400">独立切换终端区域的白底或黑底</div>
</div>
<div class="flex rounded-xl bg-zinc-200/70 p-0.5 text-xs dark:bg-zinc-800">
<button
type="button"
class="rounded-lg px-2.5 py-1 font-medium transition cursor-pointer"
:class="!terminalDark ? 'bg-white text-zinc-900 shadow-sm' : 'text-zinc-500'"
@click="terminalDark = false"
>
白底
</button>
<button
type="button"
class="rounded-lg px-2.5 py-1 font-medium transition cursor-pointer"
:class="terminalDark ? 'bg-zinc-700 text-white shadow-sm' : 'text-zinc-500'"
@click="terminalDark = true"
>
黑底
</button>
</div>
</div>
</section>
</div>
<!-- 2. MCP Server -->
<div v-show="activeSettingsTab === 'mcp'" class="space-y-4">
<!-- 板块 1:MCP Server 开关与 API Key -->
<section
class="space-y-4 rounded-2xl border border-zinc-200/80 bg-white/85 p-5 shadow-[0_2px_12px_rgba(0,0,0,0.02)] backdrop-blur-xl dark:border-zinc-800 dark:bg-zinc-900/85"
@@ -1629,7 +1958,10 @@ onBeforeUnmount(() => {
>{{ displayedMcpConfigJson }}</pre>
</div>
</section>
</div>
<!-- 分类 2:访问安全 (Security) -->
<div v-show="activeSettingsTab === 'security'" class="space-y-4">
<!-- 板块 2:Web 登录安全与账号密码 -->
<section
class="space-y-4 rounded-2xl border border-zinc-200/80 bg-white/85 p-5 shadow-[0_2px_12px_rgba(0,0,0,0.02)] backdrop-blur-xl dark:border-zinc-800 dark:bg-zinc-900/85"
@@ -1684,62 +2016,135 @@ onBeforeUnmount(() => {
</button>
</form>
</section>
</div>
<!-- 板块 3:外观与终端偏好 -->
<!-- 分类 4:关于与更新 (About) -->
<div v-show="activeSettingsTab === 'about'" class="space-y-4">
<section
class="grid gap-3 rounded-2xl border border-zinc-200/80 bg-white/85 p-5 shadow-[0_2px_12px_rgba(0,0,0,0.02)] backdrop-blur-xl sm:grid-cols-2 dark:border-zinc-800 dark:bg-zinc-900/85"
class="space-y-4 rounded-2xl border border-zinc-200/80 bg-white/85 p-5 shadow-[0_2px_12px_rgba(0,0,0,0.02)] backdrop-blur-xl dark:border-zinc-800 dark:bg-zinc-900/85"
>
<div class="flex items-center justify-between rounded-xl border border-zinc-200/70 bg-zinc-50/60 p-3.5 dark:border-zinc-800 dark:bg-zinc-800/40">
<div>
<div class="text-xs font-semibold">界面昼夜主题</div>
<div class="mt-0.5 text-[11px] text-zinc-400">日间白色毛玻璃 / 夜间深空黑毛玻璃</div>
<div class="flex items-center justify-between border-b border-zinc-100 pb-3.5 dark:border-zinc-800">
<div class="flex items-center gap-3">
<BrandLogo size="md" />
<div>
<div class="flex items-center gap-2">
<h3 class="text-base font-bold">VPS Switch</h3>
<span class="rounded-full bg-emerald-100 px-2 py-0.5 font-mono text-[11px] font-semibold text-emerald-800 dark:bg-emerald-950 dark:text-emerald-300">
{{ updateStatus.currentVersion }}
</span>
</div>
<p class="text-xs text-zinc-400">
轻量级 VPS 管理工具与 MCP Server · 概念数少于 1Shell · 零模型调用
</p>
</div>
</div>
<div class="flex rounded-xl bg-zinc-200/70 p-0.5 text-xs dark:bg-zinc-800">
<button
type="button"
class="rounded-lg px-2.5 py-1 font-medium transition"
:class="!isDark ? 'bg-white text-zinc-900 shadow-sm' : 'text-zinc-500'"
@click="applyTheme(false)"
<!-- 检查更新按钮 -->
<button
type="button"
class="inline-flex items-center gap-1.5 rounded-xl bg-zinc-900 px-3.5 py-1.5 text-xs font-semibold text-white shadow-sm transition hover:bg-zinc-800 disabled:opacity-50 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-white cursor-pointer"
:disabled="updateStatus.checking"
@click="checkAppUpdate"
>
<svg
class="h-3.5 w-3.5"
:class="{ 'animate-spin': updateStatus.checking }"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
☀ 日间
</button>
<button
type="button"
class="rounded-lg px-2.5 py-1 font-medium transition"
:class="isDark ? 'bg-zinc-700 text-white shadow-sm' : 'text-zinc-500'"
@click="applyTheme(true)"
<path d="M21 12a9 9 0 1 1-3-6.7" />
<path d="M21 3v6h-6" />
</svg>
<span>{{ updateStatus.checking ? '正在检查...' : '检查更新' }}</span>
</button>
</div>
<!-- 更新检测反馈卡片 -->
<div
v-if="updateStatus.checked"
class="rounded-xl border p-4 transition"
:class="
updateStatus.hasUpdate
? 'border-emerald-200/80 bg-emerald-50/70 dark:border-emerald-900/60 dark:bg-emerald-950/40'
: 'border-zinc-200/70 bg-zinc-50/60 dark:border-zinc-800 dark:bg-zinc-800/40'
"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span
class="h-2 w-2 rounded-full"
:class="updateStatus.hasUpdate ? 'bg-emerald-500 animate-ping' : 'bg-zinc-400'"
/>
<span class="text-xs font-semibold">
{{
updateStatus.hasUpdate
? `发现新版本 ${updateStatus.latestVersion}`
: `当前已是最新版本 (${updateStatus.currentVersion})`
}}
</span>
</div>
<a
v-if="updateStatus.hasUpdate && updateStatus.releaseUrl"
:href="updateStatus.releaseUrl"
target="_blank"
class="text-xs font-semibold text-emerald-600 hover:underline dark:text-emerald-400"
>
☾ 夜间
</button>
前往 GitHub 查看发布说明 →
</a>
</div>
<div
v-if="updateStatus.hasUpdate && updateStatus.releaseNotes"
class="mt-2.5 rounded-lg border border-emerald-200/60 bg-white/80 p-3 font-mono text-xs text-zinc-700 whitespace-pre-wrap dark:border-emerald-900 dark:bg-zinc-900/80 dark:text-zinc-300"
>
{{ updateStatus.releaseNotes }}
</div>
</div>
<div class="flex items-center justify-between rounded-xl border border-zinc-200/70 bg-zinc-50/60 p-3.5 dark:border-zinc-800 dark:bg-zinc-800/40">
<div>
<div class="text-xs font-semibold">终端画布配色</div>
<div class="mt-0.5 text-[11px] text-zinc-400">独立切换终端区域的白底或黑底</div>
<!-- 系统运行信息条目 -->
<div class="grid gap-2.5 sm:grid-cols-2 text-xs">
<div class="rounded-xl border border-zinc-200/70 bg-zinc-50/60 p-3 dark:border-zinc-800 dark:bg-zinc-800/40">
<div class="text-[11px] font-medium text-zinc-400">本地服务监听</div>
<div class="mt-1 font-mono font-semibold">127.0.0.1:{{ appAbout?.port || 18800 }}</div>
</div>
<div class="flex rounded-xl bg-zinc-200/70 p-0.5 text-xs dark:bg-zinc-800">
<div class="rounded-xl border border-zinc-200/70 bg-zinc-50/60 p-3 dark:border-zinc-800 dark:bg-zinc-800/40">
<div class="text-[11px] font-medium text-zinc-400">技术选型与底座</div>
<div class="mt-1 font-mono text-[11px]">Rust + russh + alacritty_terminal + Wry/Tao + Vue 3</div>
</div>
<div class="sm:col-span-2 rounded-xl border border-zinc-200/70 bg-zinc-50/60 p-3 dark:border-zinc-800 dark:bg-zinc-800/40">
<div class="flex items-center justify-between">
<div class="text-[11px] font-medium text-zinc-400">可执行文件绝对路径</div>
<button
v-if="appAbout?.exePath"
type="button"
class="text-[11px] text-zinc-500 hover:text-zinc-900 dark:hover:text-zinc-200 cursor-pointer"
@click="copyText(appAbout.exePath, '已复制程序绝对路径')"
>
复制路径
</button>
</div>
<div class="mt-1 font-mono text-[11px] truncate text-zinc-600 dark:text-zinc-300">
{{ appAbout?.exePath || 'E:\\1shell4.7\\vps-switch\\target\\release\\vps-switch.exe' }}
</div>
</div>
<div class="sm:col-span-2 flex items-center justify-between rounded-xl border border-zinc-200/70 bg-zinc-50/60 p-3 dark:border-zinc-800 dark:bg-zinc-800/40">
<div>
<div class="text-xs font-semibold">GitHub 官方私有仓库</div>
<div class="mt-0.5 font-mono text-[11px] text-zinc-400">https://github.com/weidu12123/vps-switch</div>
</div>
<button
type="button"
class="rounded-lg px-2.5 py-1 font-medium transition"
:class="!terminalDark ? 'bg-white text-zinc-900 shadow-sm' : 'text-zinc-500'"
@click="terminalDark = false"
class="rounded-lg border border-zinc-200 bg-white px-2.5 py-1 text-[11px] font-medium text-zinc-700 hover:bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-200 cursor-pointer"
@click="copyText('https://github.com/weidu12123/vps-switch', '已复制 GitHub 仓库链接')"
>
白底
</button>
<button
type="button"
class="rounded-lg px-2.5 py-1 font-medium transition"
:class="terminalDark ? 'bg-zinc-700 text-white shadow-sm' : 'text-zinc-500'"
@click="terminalDark = true"
>
黑底
复制链接
</button>
</div>
</div>
</section>
</div>
</div>
</main>
<!-- 页面 B:主工作台画布(仓库 + 终端/文件 + 右侧等宽栏) -->
+208
View File
@@ -36,6 +36,8 @@ use std::{
sync::{Arc, Mutex},
time::Instant,
};
#[cfg(windows)]
use std::os::windows::process::CommandExt;
use tokio::io::AsyncReadExt;
use tokio_util::io::StreamReader;
use uuid::Uuid;
@@ -53,6 +55,9 @@ pub struct AppState {
pub fn build_api_router(state: Arc<AppState>) -> Router {
let protected_api = Router::new()
.route("/api/system/about", get(api_get_about))
.route("/api/system/autostart", get(api_get_autostart).post(api_set_autostart))
.route("/api/system/check-update", post(api_check_update))
.route("/api/mcp/status", get(api_mcp_status).post(api_mcp_sync))
.route("/api/settings", get(api_get_settings))
.route("/api/settings/auth", put(api_update_auth_settings))
@@ -1141,16 +1146,219 @@ async fn api_auth_logout(
.unwrap()
}
const CREATE_NO_WINDOW: u32 = 0x08000000;
fn check_autostart_enabled() -> bool {
#[cfg(windows)]
{
std::process::Command::new("reg")
.args(["query", "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run", "/v", "VPSSwitch"])
.creation_flags(CREATE_NO_WINDOW)
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
#[cfg(not(windows))]
false
}
fn set_autostart_enabled(enabled: bool) -> anyhow::Result<()> {
#[cfg(windows)]
{
if enabled {
let exe = std::env::current_exe()?;
let exe_str = exe.to_string_lossy();
let cmd_val = format!("\"{}\" --minimized", exe_str);
let status = std::process::Command::new("reg")
.args([
"add",
"HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run",
"/v",
"VPSSwitch",
"/t",
"REG_SZ",
"/d",
&cmd_val,
"/f",
])
.creation_flags(CREATE_NO_WINDOW)
.status()?;
if !status.success() {
return Err(anyhow::anyhow!("写入 Windows 注册表开机自启失败"));
}
} else {
let _ = std::process::Command::new("reg")
.args([
"delete",
"HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run",
"/v",
"VPSSwitch",
"/f",
])
.creation_flags(CREATE_NO_WINDOW)
.status();
}
Ok(())
}
#[cfg(not(windows))]
Ok(())
}
async fn api_get_autostart(State(state): State<Arc<AppState>>) -> impl IntoResponse {
let enabled = check_autostart_enabled();
let close_action = state
.db
.get_setting("close_action")
.ok()
.flatten()
.unwrap_or_else(|| "tray".to_string());
(
StatusCode::OK,
Json(json!({
"ok": true,
"autostart": enabled,
"closeAction": close_action,
})),
)
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SetAutostartPayload {
autostart: Option<bool>,
close_action: Option<String>,
}
async fn api_set_autostart(
State(state): State<Arc<AppState>>,
Json(p): Json<SetAutostartPayload>,
) -> impl IntoResponse {
if let Some(enable) = p.autostart {
if let Err(e) = set_autostart_enabled(enable) {
return err_response(StatusCode::INTERNAL_SERVER_ERROR, e).into_response();
}
}
if let Some(ref action) = p.close_action {
let valid_action = if action == "exit" { "exit" } else { "tray" };
let _ = state.db.set_setting("close_action", valid_action);
}
let enabled = check_autostart_enabled();
let close_action = state
.db
.get_setting("close_action")
.ok()
.flatten()
.unwrap_or_else(|| "tray".to_string());
(
StatusCode::OK,
Json(json!({
"ok": true,
"autostart": enabled,
"closeAction": close_action,
})),
)
.into_response()
}
async fn api_get_about(State(state): State<Arc<AppState>>) -> impl IntoResponse {
let exe_path = std::env::current_exe()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default();
(
StatusCode::OK,
Json(json!({
"ok": true,
"version": format!("v{}", env!("CARGO_PKG_VERSION")),
"description": "轻量级 VPS 管理工具与 MCP Server",
"exePath": exe_path,
"port": state.port,
"githubUrl": "https://github.com/weidu12123/vps-switch",
"stack": "Rust (Axum + russh + alacritty_terminal + Wry/Tao) + Vue 3",
})),
)
}
async fn api_check_update() -> impl IntoResponse {
let current_version = env!("CARGO_PKG_VERSION");
#[cfg(windows)]
let gh_res = std::process::Command::new("gh")
.args([
"release",
"view",
"--repo",
"weidu12123/vps-switch",
"--json",
"tagName,name,publishedAt,body,url",
])
.creation_flags(CREATE_NO_WINDOW)
.output();
#[cfg(not(windows))]
let gh_res = std::process::Command::new("gh")
.args([
"release",
"view",
"--repo",
"weidu12123/vps-switch",
"--json",
"tagName,name,publishedAt,body,url",
])
.output();
if let Ok(out) = gh_res {
if out.status.success() {
if let Ok(info) = serde_json::from_slice::<serde_json::Value>(&out.stdout) {
let tag = info["tagName"].as_str().unwrap_or("").trim_start_matches('v');
let has_update = !tag.is_empty() && tag != current_version;
return (
StatusCode::OK,
Json(json!({
"ok": true,
"currentVersion": format!("v{current_version}"),
"latestVersion": info["tagName"].as_str().unwrap_or(&format!("v{current_version}")),
"hasUpdate": has_update,
"publishedAt": info["publishedAt"].as_str(),
"releaseNotes": info["body"].as_str(),
"releaseUrl": info["url"].as_str(),
})),
);
}
}
}
(
StatusCode::OK,
Json(json!({
"ok": true,
"currentVersion": format!("v{current_version}"),
"latestVersion": format!("v{current_version}"),
"hasUpdate": false,
"message": "当前已是最新版本",
"releaseUrl": "https://github.com/weidu12123/vps-switch",
})),
)
}
async fn api_get_settings(State(state): State<Arc<AppState>>) -> impl IntoResponse {
match state.db.get_app_settings() {
Ok(settings) => {
let cli_sync = sync_cli_mcp_configs(state.port);
let autostart = check_autostart_enabled();
let close_action = state
.db
.get_setting("close_action")
.ok()
.flatten()
.unwrap_or_else(|| "tray".to_string());
(
StatusCode::OK,
Json(json!({
"ok": true,
"settings": settings,
"cliSync": cli_sync,
"autostart": autostart,
"closeAction": close_action,
})),
)
.into_response()
+18 -2
View File
@@ -179,11 +179,14 @@ pub fn run_desktop_window(
let win_icon = WindowIcon::from_rgba(rgba64.clone(), 64, 64).ok();
let tray_icon_img = TrayIconImage::from_rgba(rgba64, 64, 64)?;
let start_minimized = std::env::args().any(|a| a == "--minimized" || a == "--hidden");
let window = WindowBuilder::new()
.with_title("VPS Switch")
.with_inner_size(Size::Logical(LogicalSize::new(1280.0, 800.0)))
.with_min_inner_size(Size::Logical(LogicalSize::new(920.0, 600.0)))
.with_window_icon(win_icon.clone())
.with_visible(!start_minimized)
.build(&event_loop)?;
let url = format!("http://127.0.0.1:{port}");
@@ -227,8 +230,21 @@ pub fn run_desktop_window(
..
} => {
if window_id == window.id() {
// 点击主窗口右上角 × 最小化驻留到系统托盘,保持后台不断连
window.set_visible(false);
let close_action = state
.db
.get_setting("close_action")
.ok()
.flatten()
.unwrap_or_else(|| "tray".to_string());
if close_action == "exit" {
standalone_windows.clear();
host_to_window.clear();
let _ = tray_icon.take();
*control_flow = ControlFlow::Exit;
} else {
// 默认最小化驻留到系统托盘,保持后台不断连
window.set_visible(false);
}
} else if let Some(removed) = standalone_windows.remove(&window_id) {
// 独立终端窗口点击 ×:完全关闭并销毁该窗口
host_to_window.remove(&removed.host_id);