feat: add unified Tauri desktop client and secure web access
This commit is contained in:
+93
-215
@@ -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>
|
||||
|
||||
|
||||
@@ -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 |
@@ -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;
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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");
|
||||
|
||||
@@ -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__);
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user