feat: add unified Tauri desktop client and secure web access

This commit is contained in:
lbl
2026-08-22 04:09:44 +08:00
parent 0ccd045e78
commit cd5b4431dc
62 changed files with 5018 additions and 500 deletions
+93 -215
View File
@@ -1,203 +1,116 @@
<script setup>
import { ref, provide } from "vue";
import { computed, onBeforeUnmount, onMounted, provide, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useAppStore } from "./stores/app";
import { useStartLogStore } from "./stores/startLog";
import { visibleNavItems } from "./navigation";
import AppSidebar from "./components/AppSidebar.vue";
import AppModal from "./components/AppModal.vue";
import ToastHost from "./components/ToastHost.vue";
import ConfirmHost from "./components/ConfirmHost.vue";
import StatusDot from "./components/StatusDot.vue";
import AppTooltip from "./components/AppTooltip.vue";
import ConfirmHost from "./components/ConfirmHost.vue";
import ToastHost from "./components/ToastHost.vue";
import AccessGate from "./components/AccessGate.vue";
import { authorized, isDesktop } from "./auth";
const app = useAppStore();
const startLog = useStartLogStore();
const route = useRoute();
const router = useRouter();
const tooltipRef = ref(null);
provide("peerTooltip", tooltipRef);
// 主题切换:localStorage 持久化,默认跟随系统
const isDark = ref(document.documentElement.classList.contains("dark"));
const mobileNavOpen = ref(false);
const items = visibleNavItems();
const pageMeta = computed(() => items.find((item) => item.to === route.path) || items[0]);
const savedTheme = localStorage.getItem("vnt-theme");
const isDark = ref(savedTheme
? savedTheme === "dark"
: window.matchMedia("(prefers-color-scheme: dark)").matches);
const applyTheme = () => document.documentElement.classList.toggle("dark", isDark.value);
const toggleTheme = () => {
isDark.value = !isDark.value;
document.documentElement.classList.toggle("dark", isDark.value);
localStorage.setItem("vnt-theme", isDark.value ? "dark" : "light");
applyTheme();
};
applyTheme();
watch(() => route.path, () => { mobileNavOpen.value = false; });
const handleKeydown = (event) => {
if (!(event.ctrlKey || event.metaKey) || event.altKey) return;
const index = Number(event.key) - 1;
if (index >= 0 && index < items.length) {
event.preventDefault();
router.push(items[index].to);
}
};
const mobileNavOpen = ref(false);
const closeMobileNav = () => {
mobileNavOpen.value = false;
};
const navItems = [
{
to: "/",
label: "总览",
exact: true,
icon: "M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6",
},
{
to: "/instances",
label: "实例",
icon: "M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01",
},
{
to: "/config",
label: "配置",
icon: "M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z",
icon2: "M15 12a3 3 0 11-6 0 3 3 0 016 0z",
},
{
to: "/peers",
label: "设备列表",
icon: "M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z",
},
{
to: "/routes",
label: "路由",
icon: "M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 01-.806-.98l-3.747-1.874M12 7v13m3-13v13m-3 0l3 3",
},
];
onMounted(() => window.addEventListener("keydown", handleKeydown));
onBeforeUnmount(() => window.removeEventListener("keydown", handleKeydown));
</script>
<template>
<div class="flex min-h-screen flex-col">
<!-- 顶部导航栏 -->
<header
class="sticky top-0 z-30 border-b border-slate-200 bg-white/80 backdrop-blur dark:border-slate-800 dark:bg-slate-900/80"
>
<div class="mx-auto flex h-14 max-w-6xl items-center gap-3 px-4 lg:px-8">
<!-- 移动端汉堡 -->
<AccessGate v-if="!isDesktop && !authorized" />
<div v-else class="flex h-[100dvh] min-h-0 overflow-hidden bg-slate-50 text-slate-700 dark:bg-slate-950 dark:text-slate-200">
<aside class="hidden w-60 shrink-0 border-r border-slate-200 dark:border-slate-800 lg:block">
<AppSidebar />
</aside>
<transition name="drawer">
<div v-if="mobileNavOpen" class="fixed inset-0 z-40 lg:hidden">
<button class="absolute inset-0 bg-slate-950/45 backdrop-blur-[2px]" aria-label="关闭导航" @click="mobileNavOpen = false"></button>
<aside class="drawer-panel relative h-full w-[min(82vw,288px)] border-r border-slate-200 shadow-2xl dark:border-slate-700">
<AppSidebar @navigate="mobileNavOpen = false" />
</aside>
</div>
</transition>
<div class="flex min-w-0 flex-1 flex-col">
<header class="flex h-16 shrink-0 items-center gap-3 border-b border-slate-200 bg-white/90 px-4 backdrop-blur lg:px-6 dark:border-slate-800 dark:bg-slate-900/90">
<button
class="rounded-lg p-1.5 text-slate-500 hover:bg-slate-100 hover:text-slate-900 lg:hidden dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white"
@click="mobileNavOpen = !mobileNavOpen"
class="grid h-9 w-9 shrink-0 place-items-center rounded-lg text-slate-500 hover:bg-slate-100 hover:text-slate-900 lg:hidden dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white"
aria-label="打开导航"
@click="mobileNavOpen = true"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
</svg>
<svg class="h-5 w-5 fill-none stroke-current" viewBox="0 0 24 24"><path d="M4 7h16M4 12h16M4 17h16" stroke-linecap="round" stroke-width="2" /></svg>
</button>
<!-- Logo -->
<router-link to="/" class="flex items-center gap-2" @click="closeMobileNav">
<span class="text-lg font-bold tracking-wide text-indigo-600 dark:text-indigo-400">VNT</span>
<span class="text-sm font-medium text-slate-400 dark:text-slate-500">Web</span>
</router-link>
<div class="min-w-0">
<h1 class="truncate text-lg font-bold text-slate-900 dark:text-white">{{ pageMeta.label }}</h1>
<p class="hidden text-xs text-slate-400 sm:block">VNT 虚拟局域网管理</p>
</div>
<!-- 桌面导航 -->
<nav class="ml-6 hidden h-14 items-stretch gap-1 lg:flex">
<router-link
v-for="item in navItems"
:key="item.to"
:to="item.to"
custom
v-slot="{ navigate, isActive, isExactActive }"
>
<button
@click="navigate"
class="relative flex items-center px-3 text-sm font-medium transition-colors"
:class="
(item.exact ? isExactActive : isActive)
? 'text-indigo-600 dark:text-indigo-400'
: 'text-slate-500 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white'
"
>
{{ item.label }}
<span
v-if="item.exact ? isExactActive : isActive"
class="absolute inset-x-3 bottom-0 h-0.5 rounded-full bg-indigo-600 dark:bg-indigo-400"
></span>
</button>
</router-link>
</nav>
<div class="ml-auto flex items-center gap-3">
<!-- 全局状态点 -->
<div
class="flex items-center gap-2 rounded-full border border-slate-200 bg-white px-3 py-1 dark:border-slate-700 dark:bg-slate-800"
:title="app.headerStatusText"
>
<StatusDot
:status="app.runningCount > 0 ? 'running' : app.startingCount > 0 ? 'starting' : 'stopped'"
/>
<span class="hidden text-xs font-medium text-slate-600 sm:inline dark:text-slate-300">{{
app.headerStatusText
}}</span>
<div class="ml-auto flex items-center gap-2">
<div class="flex h-8 items-center gap-2 rounded-lg border border-slate-200 bg-white px-2.5 text-xs font-medium text-slate-500 sm:px-3 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300">
<span
class="h-1.5 w-1.5 rounded-full"
:class="app.runningCount > 0 ? 'bg-green-500' : app.startingCount > 0 ? 'animate-pulse bg-amber-400' : 'bg-slate-300 dark:bg-slate-600'"
></span>
<span class="hidden sm:inline">{{ app.headerStatusText }}</span>
</div>
<span class="hidden text-xs text-slate-400 md:inline dark:text-slate-500"
>v{{ app.version || "-" }}</span
>
<!-- 主题切换 -->
<button
class="rounded-lg border border-slate-200 bg-white p-1.5 text-slate-500 shadow-sm transition-colors hover:bg-slate-50 hover:text-slate-900 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-400 dark:hover:bg-slate-700 dark:hover:text-white"
:title="isDark ? '切换浅色模式' : '切换深色模式'"
class="grid h-8 w-8 place-items-center rounded-lg border border-slate-200 bg-white text-slate-500 hover:border-indigo-300 hover:text-indigo-600 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-400 dark:hover:border-indigo-500 dark:hover:text-indigo-400"
:title="isDark ? '切换浅色模式' : '切换深色模式'"
:aria-label="isDark ? '切换浅色模式' : '切换深色模式'"
@click="toggleTheme"
>
<svg v-if="isDark" class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"
/>
</svg>
<svg v-else class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"
/>
</svg>
<svg v-if="isDark" class="h-4 w-4 fill-none stroke-current" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><path d="M12 2v2m0 16v2M4.9 4.9l1.4 1.4m11.4 11.4 1.4 1.4M2 12h2m16 0h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" stroke-linecap="round" stroke-width="1.7"/></svg>
<svg v-else class="h-4 w-4 fill-none stroke-current" viewBox="0 0 24 24"><path d="M20.5 15.2A8.5 8.5 0 0 1 8.8 3.5 8.5 8.5 0 1 0 20.5 15.2Z" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.7"/></svg>
</button>
</div>
</div>
</header>
<!-- 移动端下拉导航 -->
<transition name="navdrop">
<nav
v-if="mobileNavOpen"
class="border-t border-slate-200 bg-white px-4 py-2 lg:hidden dark:border-slate-800 dark:bg-slate-900"
>
<router-link
v-for="item in navItems"
:key="item.to"
:to="item.to"
custom
v-slot="{ navigate, isActive, isExactActive }"
>
<button
@click="
navigate();
closeMobileNav();
"
class="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors"
:class="
(item.exact ? isExactActive : isActive)
? 'bg-indigo-50 text-indigo-600 dark:bg-indigo-500/10 dark:text-indigo-400'
: 'text-slate-500 hover:bg-slate-50 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white'
"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" :d="item.icon" />
<path v-if="item.icon2" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" :d="item.icon2" />
</svg>
{{ item.label }}
</button>
</router-link>
</nav>
</transition>
</header>
<main class="custom-scrollbar min-h-0 flex-1 overflow-x-hidden overflow-y-auto">
<div class="mx-auto w-full max-w-7xl px-4 py-5 sm:px-5 lg:px-7 lg:py-6">
<router-view v-slot="{ Component }">
<transition name="fade" mode="out-in"><component :is="Component" /></transition>
</router-view>
</div>
</main>
</div>
<!-- 内容区 -->
<main class="flex-1 overflow-x-hidden">
<div class="mx-auto w-full max-w-6xl px-4 py-6 lg:px-8">
<router-view v-slot="{ Component }">
<transition name="fade" mode="out-in">
<component :is="Component" />
</transition>
</router-view>
</div>
</main>
<!-- 启动日志弹窗 (全局) -->
<AppModal
:show="startLog.showStartLog"
:mask-closable="startLog.startStatus !== 'starting'"
@@ -206,66 +119,31 @@ const navItems = [
@close="startLog.close"
>
<template #header>
<div class="flex items-center gap-3">
<div
v-if="startLog.startStatus === 'starting'"
class="h-3 w-3 animate-ping rounded-full bg-blue-500"
></div>
<div v-else-if="startLog.startStatus === 'running'" class="h-3 w-3 rounded-full bg-green-500"></div>
<div v-else class="h-3 w-3 rounded-full bg-red-500"></div>
<h3 class="text-lg font-bold text-slate-900 dark:text-white">
{{
startLog.startStatus === "starting"
? "正在启动组网..."
: startLog.startStatus === "running"
? "启动成功"
: "启动失败"
}}
<div class="flex min-w-0 items-center gap-3">
<span
class="h-2 w-2 shrink-0 rounded-full"
:class="startLog.startStatus === 'starting' ? 'animate-pulse bg-amber-400' : startLog.startStatus === 'running' ? 'bg-green-500' : 'bg-red-500'"
></span>
<h3 class="truncate text-base font-bold text-slate-900 sm:text-lg dark:text-white">
{{ startLog.startStatus === "starting" ? "正在建立虚拟网络" : startLog.startStatus === "running" ? "网络已连接" : "连接未完成" }}
</h3>
</div>
<div class="flex items-center gap-3">
<span
class="max-w-[200px] truncate text-sm font-medium text-indigo-600 dark:text-indigo-400"
:title="startLog.logFileName"
>{{ startLog.logConfigName }}</span
>
<span class="font-mono text-xs uppercase tracking-widest text-slate-400">{{
startLog.startStatus
}}</span>
</div>
<span class="max-w-[40%] truncate font-mono text-xs text-indigo-600 dark:text-indigo-400">{{ startLog.logConfigName }}</span>
</template>
<template #body>
<div
:ref="(el) => (startLog.logContainer = el)"
class="scrollbar-hide h-80 space-y-2 overflow-y-auto bg-slate-50 p-6 font-mono text-sm dark:bg-black/20"
class="custom-scrollbar h-64 space-y-2 overflow-y-auto border-y border-slate-200 bg-slate-50 p-4 font-mono text-xs text-slate-600 sm:h-80 sm:p-6 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-300"
>
<div v-for="(log, idx) in startLog.startLogs" :key="idx" class="flex gap-3">
<span class="shrink-0 text-indigo-500 dark:text-indigo-400">>>></span>
<span class="break-all text-slate-600 dark:text-slate-300">{{ log }}</span>
</div>
<div v-if="startLog.startStatus === 'starting'" class="mt-4 animate-pulse italic text-blue-500">
等待后续步骤...
</div>
<div
v-if="startLog.startStatus === 'stopped' && startLog.startLogs.length > 0"
class="mt-4 rounded-lg border border-red-200 bg-red-50 p-3 text-red-600 dark:border-red-900/50 dark:bg-red-900/20 dark:text-red-400"
>
<strong>启动失败:</strong>
请检查配置或网络连接
<span class="text-indigo-600 dark:text-indigo-400"></span><span class="break-all">{{ log }}</span>
</div>
<div v-if="startLog.startStatus === 'starting'" class="animate-pulse text-indigo-600 dark:text-indigo-400">等待下一阶段</div>
</div>
</template>
<template #footer>
<button v-if="startLog.startStatus === 'starting'" class="btn-ghost" @click="startLog.cancelStart">
取消组网
</button>
<button
v-if="startLog.startStatus === 'stopped' || startLog.startStatus === 'running'"
class="btn-primary"
@click="startLog.close"
>
关闭窗口
</button>
<button v-if="startLog.startStatus === 'starting'" class="btn-ghost" @click="startLog.cancelStart">取消连接</button>
<button v-else class="btn-primary" @click="startLog.close">完成</button>
</template>
</AppModal>
+19 -3
View File
@@ -1,7 +1,23 @@
// fetch 封装:统一解包 ApiResponse{code,msg,data},code!==0 抛出带 msg 的错误
const request = async (url, options) => {
const res = await fetch(url, options);
import { clearAccessToken, getAccessToken } from "../auth";
// HTTP 与 Tauri IPC 共用相同的 ApiResponse{code,msg,data} 协议。
const request = async (url, options = {}) => {
if (globalThis.__VNT_IPC_REQUEST__) {
const json = await globalThis.__VNT_IPC_REQUEST__({
method: options.method || "GET",
path: url,
body: options.body || null,
});
if (json.code !== 0) throw new Error(json.msg || "请求失败");
return json.data;
}
const headers = new Headers(options.headers || {});
const token = getAccessToken();
if (token) headers.set("Authorization", `Bearer ${token}`);
const res = await fetch(url, { ...options, headers });
const json = await res.json();
if (res.status === 401) clearAccessToken();
if (json.code !== 0) {
throw new Error(json.msg || "请求失败");
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

+32
View File
@@ -0,0 +1,32 @@
import { ref } from "vue";
const STORAGE_KEY = "vnt-web-access-token";
export const isDesktop = Boolean(globalThis.__VNT_DESKTOP__);
const url = new URL(window.location.href);
const tokenFromUrl = url.searchParams.get("token") || "";
if (tokenFromUrl) {
localStorage.setItem(STORAGE_KEY, tokenFromUrl);
url.searchParams.delete("token");
window.history.replaceState({}, "", `${url.pathname}${url.search}${url.hash}`);
}
export const accessToken = ref(
isDesktop ? "" : tokenFromUrl || localStorage.getItem(STORAGE_KEY) || "",
);
export const authorized = ref(isDesktop || Boolean(accessToken.value));
export const getAccessToken = () => accessToken.value;
export const saveAccessToken = (token) => {
const normalized = token.trim();
localStorage.setItem(STORAGE_KEY, normalized);
accessToken.value = normalized;
authorized.value = Boolean(normalized);
};
export const clearAccessToken = () => {
localStorage.removeItem(STORAGE_KEY);
accessToken.value = "";
authorized.value = false;
};
+30
View File
@@ -0,0 +1,30 @@
<script setup>
import { ref } from "vue";
import { saveAccessToken } from "../auth";
import vntIcon from "../assets/vnt-icon.png";
const token = ref("");
const submit = () => {
if (!token.value.trim()) return;
saveAccessToken(token.value);
window.location.reload();
};
</script>
<template>
<main class="grid h-[100dvh] place-items-center overflow-y-auto bg-slate-50 p-4 text-slate-700 dark:bg-slate-950 dark:text-slate-200">
<form class="w-full max-w-md rounded-2xl border border-slate-200 bg-white p-6 shadow-sm sm:p-8 dark:border-slate-800 dark:bg-slate-900" @submit.prevent="submit">
<div class="mb-7 flex items-center gap-3">
<img :src="vntIcon" alt="" class="h-10 w-10 shrink-0" />
<div>
<h1 class="text-lg font-bold text-slate-900 dark:text-white">访问 VNT 控制台</h1>
<p class="mt-0.5 text-xs text-slate-400">请输入桌面端 Web 访问设置中的令牌</p>
</div>
</div>
<label class="mb-2 block text-sm font-medium text-slate-700 dark:text-slate-200" for="access-token">访问令牌</label>
<input id="access-token" v-model="token" class="input font-mono" type="password" autocomplete="current-password" autofocus placeholder="粘贴访问令牌" />
<button class="btn-primary mt-5 w-full" type="submit" :disabled="!token.trim()">进入控制台</button>
<p class="mt-5 text-center text-xs leading-5 text-slate-400">令牌只保存在当前浏览器中可随时在桌面端重新生成</p>
</form>
</main>
</template>
+60
View File
@@ -0,0 +1,60 @@
<script setup>
import { useRoute } from "vue-router";
import { useAppStore } from "../stores/app";
import { visibleNavItems } from "../navigation";
import vntIcon from "../assets/vnt-icon.png";
defineEmits(["navigate"]);
const route = useRoute();
const app = useAppStore();
const items = visibleNavItems();
</script>
<template>
<div class="flex h-full min-h-0 flex-col bg-white dark:bg-slate-900">
<div class="flex h-16 shrink-0 items-center gap-3 border-b border-slate-200 px-5 dark:border-slate-800">
<img :src="vntIcon" alt="" class="h-8 w-8 shrink-0" />
<div>
<div class="text-sm font-bold tracking-wide text-slate-900 dark:text-white">VNT</div>
<div class="text-[9px] font-semibold tracking-[0.18em] text-slate-400">CONTROL CENTER</div>
</div>
</div>
<div class="mx-3 mt-4 flex items-center gap-3 rounded-xl border border-slate-200 bg-slate-50 px-3 py-3 dark:border-slate-700 dark:bg-slate-800/60">
<span
class="h-2.5 w-2.5 shrink-0 rounded-full"
:class="app.runningCount > 0 ? 'bg-green-500' : app.startingCount > 0 ? 'animate-pulse bg-amber-400' : 'bg-slate-300 dark:bg-slate-600'"
></span>
<div class="min-w-0">
<div class="text-[9px] font-semibold tracking-wider text-slate-400">虚拟网络</div>
<div class="mt-0.5 truncate text-xs font-medium text-slate-700 dark:text-slate-200">{{ app.headerStatusText }}</div>
</div>
</div>
<nav class="mt-4 flex min-h-0 flex-1 flex-col gap-1 overflow-y-auto px-3" aria-label="主导航">
<router-link
v-for="item in items"
:key="item.to"
:to="item.to"
class="flex min-h-10 items-center gap-3 rounded-lg px-3 text-sm font-medium transition-colors"
:class="route.path === item.to
? 'bg-indigo-50 text-indigo-700 dark:bg-indigo-500/10 dark:text-indigo-300'
: 'text-slate-500 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white'"
@click="$emit('navigate')"
>
<svg class="h-[18px] w-[18px] shrink-0 fill-none stroke-current" viewBox="0 0 24 24">
<path :d="item.icon" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.7" />
</svg>
<span>{{ item.label }}</span>
</router-link>
</nav>
<div class="mx-3 mt-3 shrink-0 border-t border-slate-200 px-1 py-4 dark:border-slate-800">
<div class="flex items-center gap-2 text-xs text-slate-400">
<span class="h-1.5 w-1.5 rounded-full" :class="app.version ? 'bg-green-500' : 'bg-amber-400'"></span>
<span>{{ app.version ? "本地服务正常" : "正在连接服务…" }}</span>
<span class="ml-auto font-mono text-[10px]">v{{ app.version || "2.0" }}</span>
</div>
</div>
</div>
</template>
+7
View File
@@ -2,6 +2,13 @@ import { createApp } from "vue";
import { createPinia } from "pinia";
import App from "./App.vue";
import router from "./router";
import vntIcon from "./assets/vnt-icon.png";
import "./style.css";
const favicon = document.querySelector('link[rel~="icon"]') || document.createElement("link");
favicon.rel = "icon";
favicon.type = "image/png";
favicon.href = vntIcon;
document.head.appendChild(favicon);
createApp(App).use(createPinia()).use(router).mount("#app");
+42
View File
@@ -0,0 +1,42 @@
export const navItems = [
{
to: "/",
label: "网络总览",
shortLabel: "总览",
icon: "M4 4h6v6H4V4Zm10 0h6v6h-6V4ZM4 14h6v6H4v-6Zm10 0h6v6h-6v-6Z",
},
{
to: "/instances",
label: "运行实例",
shortLabel: "实例",
icon: "M4 18V8m5 10V4m6 14v-7m5 7V6",
},
{
to: "/peers",
label: "在线设备",
shortLabel: "设备",
icon: "M15 19a6 6 0 0 0-12 0m18 0a5 5 0 0 0-5-5m-7-3a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm8 0a3 3 0 1 0 0-6",
},
{
to: "/routes",
label: "路由表",
shortLabel: "路由",
icon: "M6 8a2 2 0 1 0 0-4 2 2 0 0 0 0 4Zm12 12a2 2 0 1 0 0-4 2 2 0 0 0 0 4ZM8 6h6a4 4 0 0 1 4 4v2m-3-3 3 3 3-3M16 18h-6a4 4 0 0 1-4-4v-2",
},
{
to: "/config",
label: "组网配置",
shortLabel: "配置",
icon: "M12 15.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Zm7.4-.5a1.7 1.7 0 0 0 .3 1.9l.1.1-2.8 2.8-.1-.1a1.7 1.7 0 0 0-1.9-.3A1.7 1.7 0 0 0 14 21v.2h-4V21a1.7 1.7 0 0 0-1-1.6 1.7 1.7 0 0 0-1.9.3l-.1.1L4.2 17l.1-.1a1.7 1.7 0 0 0 .3-1.9A1.7 1.7 0 0 0 3 14h-.2v-4H3a1.7 1.7 0 0 0 1.6-1 1.7 1.7 0 0 0-.3-1.9L4.2 7 7 4.2l.1.1A1.7 1.7 0 0 0 9 4.6 1.7 1.7 0 0 0 10 3v-.2h4V3a1.7 1.7 0 0 0 1 1.6 1.7 1.7 0 0 0 1.9-.3l.1-.1L19.8 7l-.1.1a1.7 1.7 0 0 0-.3 1.9 1.7 1.7 0 0 0 1.6 1h.2v4H21a1.7 1.7 0 0 0-1.6 1Z",
},
{
to: "/web-access",
label: "Web 访问",
shortLabel: "Web",
desktopOnly: true,
icon: "M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Zm0 0c2.2-2.5 3.3-5.5 3.3-9S14.2 5.5 12 3m0 18c-2.2-2.5-3.3-5.5-3.3-9S9.8 5.5 12 3M3.5 9h17m-17 6h17",
},
];
export const visibleNavItems = () =>
navItems.filter((item) => !item.desktopOnly || globalThis.__VNT_DESKTOP__);
+2
View File
@@ -4,6 +4,7 @@ import InstancesView from "../views/InstancesView.vue";
import ConfigView from "../views/ConfigView.vue";
import PeersView from "../views/PeersView.vue";
import RoutesView from "../views/RoutesView.vue";
import WebAccessView from "../views/WebAccessView.vue";
const routes = [
{ path: "/", component: DashboardView },
@@ -13,6 +14,7 @@ const routes = [
{ path: "/config", component: ConfigView },
{ path: "/peers", component: PeersView },
{ path: "/routes", component: RoutesView },
{ path: "/web-access", component: WebAccessView },
];
export default createRouter({
+2
View File
@@ -12,6 +12,7 @@ import {
} from "../api";
import { useUiStore } from "./ui";
import { useStartLogStore } from "./startLog";
import { getAccessToken, isDesktop } from "../auth";
export const useAppStore = defineStore("app", () => {
const ui = useUiStore();
@@ -217,6 +218,7 @@ export const useAppStore = defineStore("app", () => {
startLog.bindApp({ fetchInstances, instanceList, configList });
const init = async () => {
if (!isDesktop && !getAccessToken()) return;
document.addEventListener("visibilitychange", visibilityHandler);
fetchVersion();
await fetchInstances();
+38 -3
View File
@@ -1,4 +1,5 @@
@import "tailwindcss";
@source "./**/*.{vue,js}";
@custom-variant dark (&:where(.dark, .dark *));
@@ -12,6 +13,13 @@ body {
@apply bg-slate-50 text-slate-700 antialiased dark:bg-slate-950 dark:text-slate-200;
}
html,
body,
#app {
height: 100%;
overflow: hidden;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
@@ -123,6 +131,27 @@ body {
transform: translateY(-6px);
}
/* 移动端侧栏抽屉 */
.drawer-enter-active,
.drawer-leave-active {
transition: opacity 0.2s ease;
}
.drawer-enter-active .drawer-panel,
.drawer-leave-active .drawer-panel {
transition: transform 0.2s ease;
}
.drawer-enter-from,
.drawer-leave-to {
opacity: 0;
}
.drawer-enter-from .drawer-panel,
.drawer-leave-to .drawer-panel {
transform: translateX(-100%);
}
@utility btn {
@apply inline-flex items-center justify-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium
transition-all duration-150 active:scale-95 cursor-pointer
@@ -232,9 +261,15 @@ body {
@apply text-2xl font-bold text-slate-900 dark:text-white;
}
.page-subtitle {
@apply mt-1 text-sm text-slate-500 dark:text-slate-400;
}
.page-subtitle {
@apply mt-1 text-sm text-slate-500 dark:text-slate-400;
}
/* 页面名称由统一外壳标题栏展示,视图只保留自身操作按钮。 */
.page-title,
.page-subtitle {
display: none;
}
/* 弱文本 */
.muted {
+20 -1
View File
@@ -111,7 +111,7 @@ onMounted(() => app.fetchConfigList());
</p>
</div>
</div>
<div class="mt-4 flex justify-end opacity-0 transition-opacity group-hover:opacity-100">
<div class="config-card-actions mt-4 flex justify-end transition-opacity">
<button
class="mr-4 text-sm text-indigo-600 hover:text-indigo-500 dark:text-indigo-400"
@click.stop="openEditor(cfg.file_name)"
@@ -133,3 +133,22 @@ onMounted(() => app.fetchConfigList());
/>
</div>
</template>
<style scoped>
/* 触屏和窄屏设备没有可靠的 hover,操作按钮必须直接可见。 */
.config-card-actions {
opacity: 1;
}
/* 只有宽屏且确实支持精细悬停的设备才使用移入显示。 */
@media (min-width: 640px) and (hover: hover) and (pointer: fine) {
.config-card-actions {
opacity: 0;
}
.group:hover .config-card-actions,
.group:focus-within .config-card-actions {
opacity: 1;
}
}
</style>
+211
View File
@@ -0,0 +1,211 @@
<script setup>
import { onMounted, reactive, ref } from "vue";
const bridge = globalThis.__VNT_WEB_ACCESS__;
const draft = reactive({ enabled: false, port: 19099, global: false, token: "" });
const status = ref(null);
const loading = ref(true);
const saving = ref(false);
const notice = ref("");
const error = ref("");
const sync = (value) => {
status.value = value;
Object.assign(draft, {
enabled: value.enabled,
port: value.port,
global: value.global,
token: value.token,
});
};
const load = async () => {
loading.value = true;
error.value = "";
try {
if (!bridge) throw new Error("Web 访问设置仅在桌面客户端中提供");
sync(await bridge.status());
} catch (err) {
error.value = err.message || String(err);
} finally {
loading.value = false;
}
};
const update = async (changes, successMessage) => {
saving.value = true;
error.value = "";
notice.value = "";
try {
sync(await bridge.update({
...draft,
...changes,
port: Number(changes.port ?? draft.port),
}));
notice.value = successMessage;
} catch (err) {
const message = err.message || String(err);
try {
sync(await bridge.status());
} catch {
// 保留原始操作错误,状态刷新失败不覆盖它。
}
error.value = message;
} finally {
saving.value = false;
}
};
const toggleService = async () => {
const enabled = !draft.enabled;
await update(
{ enabled },
enabled ? "Web 服务已启动" : "Web 服务已关闭",
);
};
const regenerate = async () => {
const token = await bridge.generateToken();
await update(
{ token },
draft.enabled ? "新令牌已生效,Web 服务已重新加载" : "新令牌已生成",
);
};
const saveNetworkSettings = async () => {
if (draft.enabled) return;
await update({}, "监听设置已自动保存");
};
const copyToken = async () => {
await navigator.clipboard.writeText(draft.token);
notice.value = "访问令牌已复制";
};
const copyUrl = async () => {
await navigator.clipboard.writeText(status.value.url);
notice.value = "访问地址已复制";
};
const openBrowser = async () => {
await bridge.openUrl(status.value.url);
};
onMounted(load);
</script>
<template>
<div class="mx-auto max-w-4xl space-y-5">
<div class="page-title">
<div>
<h2>Web 访问</h2>
<p>从浏览器访问当前 VNT 进程API 请求由持久访问令牌保护</p>
</div>
</div>
<div v-if="loading" class="card text-sm text-slate-400">正在读取 Web 服务状态</div>
<div v-else-if="!bridge" class="card border-red-200 text-sm text-red-600 dark:border-red-900 dark:text-red-300">{{ error }}</div>
<template v-else>
<section class="card space-y-6">
<div class="flex items-start justify-between gap-5">
<div>
<h3 class="text-sm font-semibold text-slate-900 dark:text-white">启用 Web 服务</h3>
<p class="mt-1 text-xs leading-5 text-slate-400">服务内置于桌面客户端不会启动单独的 vnt2_web 进程</p>
</div>
<button
type="button"
role="switch"
aria-label="启用 Web 服务"
:aria-checked="draft.enabled"
:disabled="saving"
class="web-switch transition-colors disabled:opacity-50"
:class="draft.enabled ? 'bg-indigo-600 dark:bg-indigo-500' : 'bg-slate-300 dark:bg-slate-600'"
@click="toggleService"
>
<span class="web-switch-knob bg-white shadow-sm" :class="{ 'web-switch-knob-on': draft.enabled }"></span>
</button>
</div>
<div class="grid gap-5 sm:grid-cols-2">
<label class="block">
<span class="mb-2 block text-sm font-medium text-slate-700 dark:text-slate-200">监听端口</span>
<input v-model.number="draft.port" class="input font-mono" type="number" min="1" max="65535" :disabled="saving || draft.enabled" @change="saveNetworkSettings" />
</label>
<label class="block">
<span class="mb-2 block text-sm font-medium text-slate-700 dark:text-slate-200">监听范围</span>
<select v-model="draft.global" class="input" :disabled="saving || draft.enabled" @change="saveNetworkSettings">
<option :value="false">仅本机推荐</option>
<option :value="true">局域网内所有设备</option>
</select>
</label>
</div>
<p class="-mt-3 text-xs text-slate-400">端口和监听范围会自动保存需要修改时请先关闭 Web 服务</p>
<div>
<div class="mb-2 flex items-center justify-between gap-3">
<span class="text-sm font-medium text-slate-700 dark:text-slate-200">访问令牌</span>
<button class="text-xs font-medium text-indigo-600 hover:text-indigo-500 disabled:opacity-50 dark:text-indigo-400" type="button" :disabled="saving" @click="regenerate">更换令牌</button>
</div>
<div class="flex items-center gap-2">
<code class="min-w-0 flex-1 truncate rounded-lg border border-slate-200 bg-slate-50 px-3 py-2.5 text-xs text-slate-600 dark:border-slate-700 dark:bg-slate-950 dark:text-slate-300">{{ draft.token }}</code>
<button class="btn-ghost shrink-0" type="button" :disabled="saving" @click="copyToken">复制令牌</button>
</div>
<p class="mt-2 text-xs text-slate-400">更换令牌后已登录的浏览器需要使用新令牌重新鉴权</p>
</div>
<div v-if="notice || error" class="border-t border-slate-200 pt-5 dark:border-slate-800">
<span v-if="notice" class="text-xs text-green-600 dark:text-green-400">{{ notice }}</span>
<span v-if="error" class="text-xs text-red-600 dark:text-red-400">{{ error }}</span>
</div>
</section>
<section class="card">
<div class="flex items-start gap-3">
<span class="mt-1 h-2.5 w-2.5 shrink-0 rounded-full" :class="status?.running ? 'bg-green-500' : 'bg-slate-300 dark:bg-slate-600'"></span>
<div class="min-w-0 flex-1">
<div class="text-sm font-semibold text-slate-900 dark:text-white">{{ status?.running ? "运行中" : "未运行" }}</div>
<div class="mt-1 text-xs text-slate-400">监听地址{{ status?.listenAddress }}</div>
<div v-if="status?.running" class="mt-4 rounded-lg border border-slate-200 bg-slate-50 px-3 py-2.5 font-mono text-xs text-slate-600 dark:border-slate-700 dark:bg-slate-950 dark:text-slate-300">
<span class="block truncate">{{ status.url }}</span>
</div>
<div v-if="status?.running" class="mt-3 flex flex-wrap gap-2">
<button class="btn-primary btn-sm" type="button" @click="openBrowser">打开浏览器</button>
<button class="btn-ghost btn-sm" type="button" @click="copyUrl">复制访问地址</button>
</div>
</div>
</div>
</section>
</template>
</div>
</template>
<style scoped>
.web-switch {
position: relative;
display: inline-flex;
flex: 0 0 44px;
align-items: center;
width: 44px;
min-width: 44px;
height: 24px;
min-height: 24px;
padding: 2px;
border: 0;
border-radius: 9999px;
cursor: pointer;
}
.web-switch-knob {
display: block;
width: 20px;
min-width: 20px;
height: 20px;
border-radius: 9999px;
transform: translateX(0);
transition: transform 160ms ease;
}
.web-switch-knob-on {
transform: translateX(20px);
}
</style>