refactor(vnt-web): 前端重构为 Vite+Vue3+Pinia 工程并重新设计 UI
- 单文件 index.html 拆分为 vnt-web/ui(pnpm+Vite+Vue3 SFC+Pinia+Tailwind v4),构建产物输出到 static/ - UI 重新设计:浅色简洁风(可切换深色模式)、顶部导航、新增总览仪表盘首页 - 自定义弹窗/toast/确认框替换全部原生 alert/confirm,交互与过渡统一 - 响应式布局:移动端汉堡导航,表格与卡片自适应 - 实例卡片移除日志按钮;启动组网默认选中第一个配置
This commit is contained in:
+3
-1
@@ -4,4 +4,6 @@
|
||||
vnt_config
|
||||
vnt_current_config.txt
|
||||
logs/*
|
||||
/.tools/
|
||||
/.tools/
|
||||
node_modules
|
||||
vnt-web/ui/dist
|
||||
@@ -33,6 +33,17 @@
|
||||
2. 浏览器打开 http://127.0.0.1:19099
|
||||
3. 在页面上添加组网配置,再启动组网
|
||||
|
||||
## 前端构建
|
||||
|
||||
web 前端源码位于 `vnt-web/ui/`(Vite + Vue 3 + Pinia + Tailwind CSS v4),构建产物输出到 `vnt-web/static/`,由 RustEmbed 嵌入二进制。
|
||||
|
||||
```
|
||||
cd vnt-web/ui
|
||||
pnpm install
|
||||
pnpm build
|
||||
```
|
||||
|
||||
开发调试使用 `pnpm dev`,Vite dev server 会将 `/api` 代理到 `127.0.0.1:19099`(需先启动 vnt2_web)。
|
||||
|
||||
# VNT2.0新特性
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+24
-3226
@@ -1,3230 +1,28 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>VNT Dashboard</title>
|
||||
<link rel="icon"
|
||||
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect x='0' y='0' width='64' height='64' rx='16' fill='%230f172a'/%3E%3Cpath d='M16 20 L32 48 L48 20' fill='none' stroke='%233b82f6' stroke-width='6' stroke-linecap='round' stroke-linejoin='round'/%3E%3Ccircle cx='16' cy='20' r='6' fill='%2360a5fa'/%3E%3Ccircle cx='48' cy='20' r='6' fill='%2360a5fa'/%3E%3Ccircle cx='32' cy='48' r='6' fill='%2322c55e'/%3E%3C/svg%3E"
|
||||
type="image/svg+xml">
|
||||
<script src="tailwindcss3.4.17.js.gz"></script>
|
||||
<script src="vue.global.prod.js.gz"></script>
|
||||
<script src="vue-router.global.prod.js.gz"></script>
|
||||
|
||||
<style>
|
||||
[v-cloak] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: #0f172a;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.glass-panel {
|
||||
background: rgba(30, 41, 59, 0.7);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.input-dark {
|
||||
background-color: #1e293b;
|
||||
border-color: #334155;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.input-dark:focus {
|
||||
border-color: #3b82f6;
|
||||
outline: none;
|
||||
ring: 2px;
|
||||
}
|
||||
|
||||
/* Tooltip CSS (保留用于表格内的静态 tooltip) */
|
||||
.tooltip {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.tooltip .tooltip-text {
|
||||
visibility: hidden;
|
||||
width: 140px;
|
||||
background-color: rgba(0, 0, 0, 0.9);
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
border-radius: 6px;
|
||||
padding: 6px 4px;
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
bottom: 125%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
font-size: 12px;
|
||||
pointer-events: none;
|
||||
border: 1px solid #475569;
|
||||
}
|
||||
|
||||
.tooltip .tooltip-text::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
margin-left: -5px;
|
||||
border-width: 5px;
|
||||
border-style: solid;
|
||||
border-color: rgba(0, 0, 0, 0.9) transparent transparent transparent;
|
||||
}
|
||||
|
||||
.tooltip:hover .tooltip-text {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* 路由切换动画 */
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* 自定义滚动条样式 */
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: rgba(15, 23, 42, 0.5);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: rgba(71, 85, 105, 0.8);
|
||||
border-radius: 4px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(100, 116, 139, 0.9);
|
||||
}
|
||||
|
||||
/* Firefox 滚动条样式 */
|
||||
.custom-scrollbar {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(71, 85, 105, 0.8) rgba(15, 23, 42, 0.5);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="h-screen overflow-hidden flex flex-col font-sans">
|
||||
<div id="app" v-cloak class="flex h-full">
|
||||
<!-- 侧边栏 -->
|
||||
<aside
|
||||
class="w-64 bg-slate-900 border-r border-slate-800 flex flex-col shrink-0"
|
||||
>
|
||||
<div
|
||||
class="p-6 flex items-center justify-center border-b border-slate-800"
|
||||
>
|
||||
<h1 class="text-2xl font-bold text-blue-500 tracking-wider">
|
||||
VNT<span class="text-slate-400 text-sm ml-2">Web</span>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<nav class="flex-1 p-4 space-y-2">
|
||||
<!-- 使用 router-link 和 v-slot 自定义渲染按钮 -->
|
||||
<router-link
|
||||
to="/general"
|
||||
custom
|
||||
v-slot="{ navigate, isActive }"
|
||||
>
|
||||
<button
|
||||
@click="navigate"
|
||||
:class="navClass(isActive)"
|
||||
class="w-full flex items-center p-3 rounded-lg transition-all duration-200"
|
||||
>
|
||||
<svg
|
||||
class="w-5 h-5 mr-3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="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"
|
||||
></path>
|
||||
</svg>
|
||||
通用
|
||||
</button>
|
||||
</router-link>
|
||||
|
||||
<router-link
|
||||
to="/config"
|
||||
custom
|
||||
v-slot="{ navigate, isActive }"
|
||||
>
|
||||
<button
|
||||
@click="navigate"
|
||||
:class="navClass(isActive)"
|
||||
class="w-full flex items-center p-3 rounded-lg transition-all duration-200"
|
||||
>
|
||||
<svg
|
||||
class="w-5 h-5 mr-3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="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"
|
||||
></path>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
></path>
|
||||
</svg>
|
||||
配置
|
||||
</button>
|
||||
</router-link>
|
||||
|
||||
<router-link
|
||||
to="/peers"
|
||||
custom
|
||||
v-slot="{ navigate, isActive }"
|
||||
>
|
||||
<button
|
||||
@click="navigate"
|
||||
:class="navClass(isActive)"
|
||||
class="w-full flex items-center p-3 rounded-lg transition-all duration-200"
|
||||
>
|
||||
<svg
|
||||
class="w-5 h-5 mr-3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="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"
|
||||
></path>
|
||||
</svg>
|
||||
设备列表
|
||||
</button>
|
||||
</router-link>
|
||||
|
||||
<router-link
|
||||
to="/routes"
|
||||
custom
|
||||
v-slot="{ navigate, isActive }"
|
||||
>
|
||||
<button
|
||||
@click="navigate"
|
||||
:class="navClass(isActive)"
|
||||
class="w-full flex items-center p-3 rounded-lg transition-all duration-200"
|
||||
>
|
||||
<svg
|
||||
class="w-5 h-5 mr-3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="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.874O12 7m3 13V7m-3 0l3 3"
|
||||
></path>
|
||||
</svg>
|
||||
路由
|
||||
</button>
|
||||
</router-link>
|
||||
</nav>
|
||||
|
||||
<div class="p-4 text-xs text-slate-500 text-center">
|
||||
Client: v{{ version || '-' }}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main
|
||||
class="flex-1 flex flex-col bg-slate-900/50 relative overflow-hidden"
|
||||
>
|
||||
<!-- Header -->
|
||||
<header
|
||||
class="h-16 glass-panel border-b border-slate-700 flex items-center justify-between px-8 z-10 shrink-0"
|
||||
>
|
||||
<div class="flex items-center space-x-6">
|
||||
<div
|
||||
class="flex items-center space-x-2 bg-slate-800 rounded-full px-3 py-1 border border-slate-700"
|
||||
>
|
||||
<span
|
||||
class="w-2.5 h-2.5 rounded-full"
|
||||
:class="runningCount > 0 ? 'bg-green-500 animate-pulse' : (startingCount > 0 ? 'bg-blue-500 animate-pulse' : 'bg-red-500')"
|
||||
></span>
|
||||
<span class="text-sm font-medium"
|
||||
>{{ headerStatusText }}</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="selectedInfo"
|
||||
class="flex items-center space-x-2 bg-slate-800 rounded-full px-3 py-1 border border-slate-700"
|
||||
:title="serverStatusText"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4"
|
||||
:class="isServerConnected ? 'text-green-400' : 'text-red-400'"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"
|
||||
></path>
|
||||
</svg>
|
||||
<span
|
||||
class="text-sm font-medium"
|
||||
:class="isServerConnected ? 'text-green-400' : 'text-red-400'"
|
||||
>服务器: {{ isServerConnected ? '已连接' :
|
||||
'未连接' }}</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="selectedInfo && selectedInfo.ip"
|
||||
class="flex items-center space-x-2 text-slate-300"
|
||||
>
|
||||
<span
|
||||
class="font-mono text-blue-400 font-bold bg-blue-900/20 px-2 py-0.5 rounded"
|
||||
>{{ selectedInfo.ip }}</span
|
||||
>
|
||||
<span class="text-xs text-slate-500"
|
||||
>{{ selectedConfigName }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-2 text-slate-400" v-if="selectedInfo">
|
||||
<span class="text-sm">设备:</span>
|
||||
<span class="font-bold text-white"
|
||||
>{{ selectedInfo.name || '' }}</span
|
||||
>
|
||||
<span
|
||||
class="text-xs px-2 py-0.5 rounded bg-slate-800 text-slate-500"
|
||||
title="Device ID"
|
||||
>{{ (selectedInfo.device_id || '').substring(0, 8) }}...</span
|
||||
>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Router View with Transition -->
|
||||
<div class="flex-1 overflow-auto scrollbar-hide p-8 relative">
|
||||
<router-view v-slot="{ Component }">
|
||||
<transition name="fade" mode="out-in">
|
||||
<component :is="Component"/>
|
||||
</transition>
|
||||
</router-view>
|
||||
</div>
|
||||
|
||||
<!-- 启动日志弹窗 (Global) -->
|
||||
<div
|
||||
v-if="showStartLog"
|
||||
class="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 backdrop-blur-sm p-4"
|
||||
>
|
||||
<div
|
||||
class="bg-slate-900/95 border border-slate-700 rounded-xl w-full max-w-2xl flex flex-col shadow-2xl overflow-hidden"
|
||||
>
|
||||
<div
|
||||
class="px-6 py-4 border-b border-slate-700 flex justify-between items-center bg-slate-800/50"
|
||||
>
|
||||
<div class="flex items-center space-x-3">
|
||||
<div
|
||||
v-if="startStatus === 'starting'"
|
||||
class="w-3 h-3 bg-blue-500 rounded-full animate-ping"
|
||||
></div>
|
||||
<div
|
||||
v-else-if="startStatus === 'running'"
|
||||
class="w-3 h-3 bg-green-500 rounded-full"
|
||||
></div>
|
||||
<div
|
||||
v-else
|
||||
class="w-3 h-3 bg-red-500 rounded-full"
|
||||
></div>
|
||||
<h3 class="text-lg font-bold text-white">
|
||||
{{ startStatus === 'starting' ?
|
||||
'正在启动组网...' : (startStatus ===
|
||||
'running' ? '启动成功' : '启动失败') }}
|
||||
</h3>
|
||||
</div>
|
||||
<div class="flex items-center space-x-3">
|
||||
<span
|
||||
class="text-sm font-medium text-blue-400 truncate max-w-[200px]"
|
||||
:title="logFileName"
|
||||
>{{ logConfigName }}</span
|
||||
>
|
||||
<span
|
||||
class="text-xs font-mono text-slate-500 uppercase tracking-widest"
|
||||
>{{ startStatus }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
ref="logContainer"
|
||||
class="flex-1 p-6 h-80 overflow-y-auto scrollbar-hide font-mono text-sm space-y-2 bg-black/20"
|
||||
>
|
||||
<div
|
||||
v-for="(log, idx) in startLogs"
|
||||
:key="idx"
|
||||
class="flex space-x-3 animate-in fade-in slide-in-from-left-2"
|
||||
>
|
||||
<span class="text-blue-500 shrink-0">>>></span>
|
||||
<span class="text-slate-300 break-all"
|
||||
>{{ log }}</span
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
v-if="startStatus === 'starting'"
|
||||
class="text-blue-400 animate-pulse italic mt-4"
|
||||
>
|
||||
等待后续步骤...
|
||||
</div>
|
||||
<div
|
||||
v-if="startStatus === 'stopped' && startLogs.length > 0"
|
||||
class="p-3 bg-red-900/20 border border-red-900/50 rounded-lg text-red-400 mt-4"
|
||||
>
|
||||
<strong>启动失败:</strong>
|
||||
请检查配置或网络连接。
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="p-4 border-t border-slate-700 flex justify-end space-x-3 bg-slate-800/50"
|
||||
>
|
||||
<button
|
||||
v-if="startStatus === 'starting'"
|
||||
@click="cancelStart"
|
||||
class="px-6 py-2 bg-slate-700 hover:bg-red-600 text-white rounded-lg transition-colors font-medium"
|
||||
>
|
||||
取消组网
|
||||
</button>
|
||||
<button
|
||||
v-if="startStatus === 'stopped' || startStatus === 'running'"
|
||||
@click="showStartLog = false"
|
||||
class="px-6 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg transition-colors font-medium"
|
||||
>
|
||||
关闭窗口
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tooltip (Global Teleport) -->
|
||||
<teleport to="body">
|
||||
<div
|
||||
v-if="tooltipState.show"
|
||||
:style="{ top: tooltipState.y + 'px', left: tooltipState.x + 'px' }"
|
||||
class="fixed z-[9999] transform -translate-x-1/2 mt-1"
|
||||
@mouseenter="onTooltipEnter"
|
||||
@mouseleave="onTooltipLeave"
|
||||
>
|
||||
<div
|
||||
class="w-auto min-w-[260px] max-w-[320px] text-left p-4 bg-slate-800 border border-slate-600 shadow-2xl rounded-lg text-sm text-slate-200"
|
||||
>
|
||||
<div
|
||||
class="absolute -top-2 left-1/2 -translate-x-1/2 w-4 h-4 bg-slate-800 border-t border-l border-slate-600 transform rotate-45"
|
||||
></div>
|
||||
<div
|
||||
class="flex justify-between items-center border-b border-slate-600 pb-2 mb-2 relative z-10"
|
||||
>
|
||||
<span
|
||||
class="text-slate-400 text-xs uppercase font-bold"
|
||||
>NAT Type</span
|
||||
>
|
||||
<span
|
||||
class="text-green-400 font-bold bg-green-900/30 px-2 py-0.5 rounded text-xs border border-green-800"
|
||||
>{{ tooltipState.info.nat_type }}</span
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
v-if="tooltipState.info.public_ips && tooltipState.info.public_ips.length > 0"
|
||||
class="mb-3 relative z-10"
|
||||
>
|
||||
<span class="text-slate-400 text-xs block mb-1"
|
||||
>Public IPv4:</span
|
||||
>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<span
|
||||
v-for="pip in tooltipState.info.public_ips"
|
||||
:key="pip"
|
||||
class="text-xs bg-slate-700 text-slate-200 px-1.5 py-0.5 rounded border border-slate-600"
|
||||
>{{ pip }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="tooltipState.info.ipv6"
|
||||
class="relative z-10"
|
||||
>
|
||||
<span class="text-slate-400 text-xs block mb-1"
|
||||
>IPv6:</span
|
||||
>
|
||||
<div
|
||||
class="text-slate-200 text-xs break-all whitespace-normal leading-relaxed bg-slate-900/50 p-1.5 rounded border border-slate-700/50"
|
||||
>
|
||||
{{ tooltipState.info.ipv6 }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</teleport>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- ================= TEMPLATES ================= -->
|
||||
|
||||
<!-- 1. General (通用) -->
|
||||
<template id="tpl-general">
|
||||
<div class="space-y-6 max-w-5xl mx-auto">
|
||||
<!-- 启动组网 -->
|
||||
<div class="glass-panel rounded-xl p-6 shadow-lg">
|
||||
<h2
|
||||
class="text-xl font-bold mb-6 text-white border-l-4 border-blue-500 pl-3"
|
||||
>
|
||||
启动组网
|
||||
</h2>
|
||||
<div class="flex items-end space-x-4">
|
||||
<div class="flex-1">
|
||||
<label
|
||||
class="block text-sm font-medium text-slate-400 mb-2"
|
||||
>选择配置</label
|
||||
>
|
||||
<select
|
||||
v-model="localSelectedConfig"
|
||||
class="w-full bg-slate-800 border border-slate-600 rounded-lg px-4 py-2.5 text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<option value="" disabled>请选择配置...</option>
|
||||
<option
|
||||
v-for="cfg in availableConfigs"
|
||||
:key="cfg.file_name"
|
||||
:value="cfg.file_name"
|
||||
>
|
||||
{{ cfg.config_name || cfg.file_name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
@click="handleStart"
|
||||
:disabled="!localSelectedConfig || !!loadingMap[localSelectedConfig]"
|
||||
class="px-8 py-2.5 rounded-lg font-bold text-white shadow-lg transition-transform active:scale-95 flex items-center disabled:opacity-50 disabled:cursor-not-allowed bg-green-500 hover:bg-green-600"
|
||||
>
|
||||
<span v-if="loadingMap[localSelectedConfig]" class="mr-2 animate-spin"
|
||||
>⟳</span
|
||||
>
|
||||
启动
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 组网实例 -->
|
||||
<div class="glass-panel rounded-xl p-6 shadow-lg">
|
||||
<h2
|
||||
class="text-xl font-bold mb-6 text-white border-l-4 border-green-500 pl-3"
|
||||
>
|
||||
组网实例
|
||||
</h2>
|
||||
<div
|
||||
v-if="instanceList.length === 0"
|
||||
class="text-center text-slate-500 py-8"
|
||||
>
|
||||
暂无运行中的组网,请在上方选择配置启动
|
||||
</div>
|
||||
<div v-else class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div
|
||||
v-for="inst in instanceList"
|
||||
:key="inst.file_name"
|
||||
@click="selectInstance(inst.file_name)"
|
||||
:class="selectedInstance === inst.file_name ? 'ring-2 ring-blue-500 bg-slate-800/80' : 'bg-slate-800/40 hover:bg-slate-800/60'"
|
||||
class="rounded-xl p-5 border border-slate-700 transition-all cursor-pointer"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<h3
|
||||
class="font-bold text-lg text-white truncate"
|
||||
:title="inst.file_name"
|
||||
>
|
||||
{{ inst.config_name || inst.file_name }}
|
||||
</h3>
|
||||
<span class="flex items-center text-sm shrink-0 ml-2">
|
||||
<span
|
||||
class="w-2 h-2 rounded-full mr-1.5"
|
||||
:class="statusDotClass(inst.status)"
|
||||
></span>
|
||||
<span :class="statusTextClass(inst.status)">{{ statusText(inst.status) }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-2 text-sm">
|
||||
<span class="text-slate-400">虚拟 IP:</span>
|
||||
<span class="font-mono text-blue-300 ml-1">{{ infoOf(inst.file_name).ip || '-' }}</span>
|
||||
</div>
|
||||
<div class="mt-3 flex space-x-4 text-xs text-slate-400">
|
||||
<span>在线 <span class="text-blue-400 font-bold">{{ infoOf(inst.file_name).online_client_num || 0 }}</span></span>
|
||||
<span>直连 <span class="text-green-400 font-bold">{{ infoOf(inst.file_name).direct_client_num || 0 }}</span></span>
|
||||
<span>离线 <span class="text-slate-500 font-bold">{{ infoOf(inst.file_name).offline_client_num || 0 }}</span></span>
|
||||
</div>
|
||||
<div class="mt-4 flex justify-end space-x-2">
|
||||
<button
|
||||
@click.stop="openStartLog(inst.file_name)"
|
||||
class="px-3 py-1.5 text-sm rounded-lg bg-slate-700 hover:bg-slate-600 text-slate-200 transition-colors"
|
||||
>
|
||||
日志
|
||||
</button>
|
||||
<button
|
||||
v-if="inst.status === 'running' || inst.status === 'stopped'"
|
||||
@click.stop="restartVnt(inst.file_name)"
|
||||
:disabled="!!loadingMap[inst.file_name]"
|
||||
class="px-3 py-1.5 text-sm rounded-lg bg-blue-600 hover:bg-blue-500 text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center"
|
||||
>
|
||||
<span v-if="loadingMap[inst.file_name]" class="mr-1 animate-spin">⟳</span>
|
||||
{{ inst.status === 'stopped' ? '重新启动' : '重启' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="inst.status !== 'stopped'"
|
||||
@click.stop="stopVnt(inst.file_name)"
|
||||
:disabled="!!loadingMap[inst.file_name]"
|
||||
class="px-3 py-1.5 text-sm rounded-lg bg-red-500 hover:bg-red-600 text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center"
|
||||
>
|
||||
<span v-if="loadingMap[inst.file_name]" class="mr-1 animate-spin">⟳</span
|
||||
>
|
||||
停止
|
||||
</button>
|
||||
<button
|
||||
v-if="inst.status === 'stopped'"
|
||||
@click.stop="dismissInstance(inst.file_name)"
|
||||
:disabled="!!loadingMap[inst.file_name]"
|
||||
class="px-3 py-1.5 text-sm rounded-lg bg-slate-700 hover:bg-slate-600 text-slate-300 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center"
|
||||
>
|
||||
<span v-if="loadingMap[inst.file_name]" class="mr-1 animate-spin">⟳</span>
|
||||
移除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 选中实例详情 -->
|
||||
<template v-if="selectedInstance">
|
||||
<div class="glass-panel rounded-xl p-6 shadow-lg">
|
||||
<h2 class="text-lg font-bold mb-4 text-white">
|
||||
网络详情
|
||||
<span class="text-sm font-medium text-blue-400 ml-2">{{ selectedConfigName }}</span>
|
||||
</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
|
||||
<div
|
||||
class="flex justify-between border-b border-slate-700 pb-2"
|
||||
>
|
||||
<span class="text-slate-400">虚拟 IP / 掩码</span>
|
||||
<span class="font-mono text-white"
|
||||
>{{ selectedInfo.ip || '-' }} / {{ selectedInfo.prefix_len ||
|
||||
'-' }}</span
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="flex justify-between border-b border-slate-700 pb-2"
|
||||
>
|
||||
<span class="text-slate-400">网关</span>
|
||||
<span class="font-mono text-white"
|
||||
>{{ selectedInfo.gateway || '-' }}</span
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="flex justify-between border-b border-slate-700 pb-2"
|
||||
>
|
||||
<span class="text-slate-400">网络编号</span>
|
||||
<span class="font-mono text-white"
|
||||
>{{ selectedInfo.network_code || '-' }}</span
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="flex justify-between border-b border-slate-700 pb-2"
|
||||
>
|
||||
<span class="text-slate-400">MTU</span>
|
||||
<span class="font-mono text-white"
|
||||
>{{ selectedInfo.mtu || '' }}</span
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="flex justify-between border-b border-slate-700 pb-2"
|
||||
>
|
||||
<span class="text-slate-400">NAT 类型</span>
|
||||
<span class="font-mono text-blue-300"
|
||||
>{{ selectedInfo.nat_type || 'Unknown' }}</span
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="flex justify-between border-b border-slate-700 pb-2"
|
||||
>
|
||||
<span class="text-slate-400">Public IPv6</span>
|
||||
<span
|
||||
class="font-mono text-white truncate max-w-[200px]"
|
||||
:title="selectedInfo.public_ipv6"
|
||||
>{{ selectedInfo.public_ipv6 || '-' }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<div class="flex items-center space-x-2 bg-slate-800/50 rounded-lg px-3 py-2">
|
||||
<span class="w-2 h-2 rounded-full" :class="selectedInfo.encrypt ? 'bg-green-500' : 'bg-slate-600'"></span>
|
||||
<span class="text-sm text-slate-300">加密</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2 bg-slate-800/50 rounded-lg px-3 py-2">
|
||||
<span class="w-2 h-2 rounded-full" :class="selectedInfo.compress ? 'bg-green-500' : 'bg-slate-600'"></span>
|
||||
<span class="text-sm text-slate-300">压缩</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2 bg-slate-800/50 rounded-lg px-3 py-2">
|
||||
<span class="w-2 h-2 rounded-full" :class="selectedInfo.fec ? 'bg-green-500' : 'bg-slate-600'"></span>
|
||||
<span class="text-sm text-slate-300">FEC纠错</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2 bg-slate-800/50 rounded-lg px-3 py-2">
|
||||
<span class="w-2 h-2 rounded-full" :class="selectedInfo.rtx ? 'bg-green-500' : 'bg-slate-600'"></span>
|
||||
<span class="text-sm text-slate-300">QUIC传输</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<span class="text-slate-400 text-sm block mb-2"
|
||||
>Public IPv4s</span
|
||||
>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span
|
||||
v-for="pip in selectedInfo.public_ipv4s"
|
||||
:key="pip"
|
||||
class="px-2 py-1 bg-slate-800 rounded text-xs font-mono text-green-300 border border-slate-600"
|
||||
>{{ pip }}</span
|
||||
>
|
||||
<span
|
||||
v-if="!selectedInfo.public_ipv4s || selectedInfo.public_ipv4s.length === 0"
|
||||
class="text-slate-600 text-xs"
|
||||
>无</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="glass-panel rounded-xl p-6 shadow-lg">
|
||||
<h2 class="text-lg font-bold mb-4 text-white">
|
||||
服务器连接列表
|
||||
</h2>
|
||||
<div
|
||||
class="overflow-auto max-h-[400px] custom-scrollbar rounded-lg border border-slate-700"
|
||||
>
|
||||
<table class="min-w-full divide-y divide-slate-700">
|
||||
<thead class="bg-slate-800">
|
||||
<tr>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium text-slate-400 uppercase tracking-wider"
|
||||
>
|
||||
地址
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium text-slate-400 uppercase tracking-wider"
|
||||
>
|
||||
状态
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium text-slate-400 uppercase tracking-wider"
|
||||
>
|
||||
延迟
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium text-slate-400 uppercase tracking-wider"
|
||||
>
|
||||
版本
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody
|
||||
class="divide-y divide-slate-700 bg-slate-900/30"
|
||||
>
|
||||
<tr
|
||||
v-for="(server, idx) in selectedInfo.server_info"
|
||||
:key="idx"
|
||||
>
|
||||
<td
|
||||
class="px-6 py-4 whitespace-nowrap text-sm text-slate-300 font-mono"
|
||||
>
|
||||
{{ server.server }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span
|
||||
:class="server.connected ? 'bg-green-900 text-green-300' : 'bg-red-900 text-red-300'"
|
||||
class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full"
|
||||
>
|
||||
{{ server.connected ? '已连接' :
|
||||
'未连接' }}
|
||||
</span>
|
||||
</td>
|
||||
<td
|
||||
class="px-6 py-4 whitespace-nowrap text-sm text-slate-400"
|
||||
>
|
||||
{{ server.server_rtt ? server.server_rtt
|
||||
+ ' ms' : '-' }}
|
||||
</td>
|
||||
<td
|
||||
class="px-6 py-4 whitespace-nowrap text-sm text-slate-400"
|
||||
>
|
||||
{{ server.server_version || '-' }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
v-if="!selectedInfo.server_info || selectedInfo.server_info.length === 0"
|
||||
>
|
||||
<td
|
||||
colspan="4"
|
||||
class="px-6 py-4 text-center text-slate-500"
|
||||
>
|
||||
暂无数据
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 2. Config (配置) -->
|
||||
<template id="tpl-config">
|
||||
<div class="space-y-6 max-w-5xl mx-auto h-full flex flex-col">
|
||||
<div class="flex justify-between items-center">
|
||||
<h2 class="text-2xl font-bold text-white">配置管理</h2>
|
||||
<button
|
||||
@click="openEditor(null)"
|
||||
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium flex items-center transition-colors"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4 mr-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 4v16m8-8H4"
|
||||
></path>
|
||||
</svg>
|
||||
新建配置
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"
|
||||
>
|
||||
<div
|
||||
v-for="cfg in configList"
|
||||
:key="cfg.file_name"
|
||||
:class="{'ring-2 ring-green-500 bg-slate-800/80': instStatus(cfg.file_name) === 'running', 'ring-2 ring-blue-500 bg-slate-800/80': instStatus(cfg.file_name) === 'starting', 'bg-slate-800/40 hover:bg-slate-800/60': !instStatus(cfg.file_name)}"
|
||||
class="rounded-xl p-5 border border-slate-700 transition-all cursor-pointer group relative overflow-hidden flex flex-col justify-between min-h-[140px]"
|
||||
@click="openEditor(cfg.file_name)"
|
||||
>
|
||||
<div
|
||||
v-if="instStatus(cfg.file_name) === 'running'"
|
||||
class="absolute top-0 right-0 bg-green-500 text-white text-xs px-2 py-1 rounded-bl"
|
||||
>
|
||||
运行中
|
||||
</div>
|
||||
<div
|
||||
v-else-if="instStatus(cfg.file_name) === 'starting'"
|
||||
class="absolute top-0 right-0 bg-blue-500 text-white text-xs px-2 py-1 rounded-bl"
|
||||
>
|
||||
启动中
|
||||
</div>
|
||||
<div class="flex items-start">
|
||||
<div
|
||||
class="p-2 rounded bg-blue-500/10 text-blue-400 mr-3 mt-1"
|
||||
>
|
||||
<svg
|
||||
class="w-6 h-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="overflow-hidden">
|
||||
<h3
|
||||
class="font-bold text-lg text-white truncate"
|
||||
:title="cfg.config_name"
|
||||
>
|
||||
{{ cfg.config_name || 'Unnamed' }}
|
||||
</h3>
|
||||
<p
|
||||
class="text-xs text-slate-500 font-mono truncate"
|
||||
:title="cfg.file_name"
|
||||
>
|
||||
{{ cfg.file_name }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="mt-4 flex justify-end opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<button
|
||||
@click.stop="openEditor(cfg.file_name)"
|
||||
class="text-blue-400 hover:text-blue-300 text-sm mr-4"
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
@click.stop="deleteConfig(cfg.file_name)"
|
||||
class="text-red-400 hover:text-red-300 text-sm"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 编辑器 Modal -->
|
||||
<div
|
||||
v-if="showEditor"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm p-4"
|
||||
>
|
||||
<div
|
||||
class="bg-slate-900 border border-slate-700 rounded-xl w-full max-w-6xl h-[85vh] flex flex-col shadow-2xl"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex justify-between items-center p-4 border-b border-slate-700 bg-slate-800/50">
|
||||
<h3 class="text-lg font-bold text-white">
|
||||
{{ editorMode === 'new' ? '新建配置' : '编辑配置' }}
|
||||
</h3>
|
||||
<div class="flex items-center space-x-4">
|
||||
<!-- 模式切换按钮 -->
|
||||
<div class="flex bg-slate-800 rounded-lg p-1 border border-slate-600">
|
||||
<button
|
||||
@click="switchToFormMode"
|
||||
:class="editMode === 'form' ? 'bg-blue-600 text-white' : 'text-slate-400 hover:text-white'"
|
||||
class="px-4 py-1.5 rounded text-sm font-medium transition-colors flex items-center"
|
||||
>
|
||||
<svg class="w-4 h-4 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
||||
</svg>
|
||||
表单模式
|
||||
</button>
|
||||
<button
|
||||
@click="switchToTomlMode"
|
||||
:class="editMode === 'toml' ? 'bg-blue-600 text-white' : 'text-slate-400 hover:text-white'"
|
||||
class="px-4 py-1.5 rounded text-sm font-medium transition-colors flex items-center"
|
||||
>
|
||||
<svg class="w-4 h-4 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"></path>
|
||||
</svg>
|
||||
TOML模式
|
||||
</button>
|
||||
</div>
|
||||
<div class="text-sm text-slate-500 font-mono" v-if="editorFileName">
|
||||
{{ editorFileName }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 overflow-hidden">
|
||||
<!-- 表单模式 -->
|
||||
<div v-show="editMode === 'form'" class="h-full overflow-y-auto scrollbar-hide p-6">
|
||||
<div class="max-w-4xl mx-auto space-y-6">
|
||||
<!-- 基础配置 -->
|
||||
<div class="glass-panel rounded-lg p-5 border border-slate-700">
|
||||
<h4 class="text-md font-bold text-blue-400 mb-4 flex items-center">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path>
|
||||
</svg>
|
||||
基础配置
|
||||
</h4>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-2">配置名称</label>
|
||||
<input v-model="formData.config_name" type="text" placeholder="例如: 我的VPN配置"
|
||||
class="w-full bg-slate-800 border border-slate-600 rounded-lg px-3 py-2 text-white placeholder-slate-500 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-2">
|
||||
网络编号 <span class="text-red-400">*</span>
|
||||
</label>
|
||||
<input v-model="formData.network_code" type="text" placeholder="例如: my_network" required
|
||||
class="w-full bg-slate-800 border border-slate-600 rounded-lg px-3 py-2 text-white placeholder-slate-500 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<label class="block text-sm font-medium text-slate-300 mb-2">
|
||||
服务器地址 <span class="text-red-400">*</span>
|
||||
<span class="text-xs text-slate-500 ml-2">支持 quic:// tcp:// wss:// dynamic://</span>
|
||||
</label>
|
||||
<div class="space-y-2">
|
||||
<div v-for="(server, idx) in formData.server" :key="idx" class="flex space-x-2">
|
||||
<input v-model="formData.server[idx]" type="text" placeholder="例如: quic://1.2.3.4:29872"
|
||||
class="flex-1 bg-slate-800 border border-slate-600 rounded-lg px-3 py-2 text-white placeholder-slate-500 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
<button @click="formData.server.splice(idx, 1)" v-if="formData.server.length > 1"
|
||||
class="px-3 py-2 bg-red-600/20 hover:bg-red-600/40 text-red-400 rounded-lg transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<button @click="formData.server.push('')"
|
||||
class="w-full px-3 py-2 bg-blue-600/20 hover:bg-blue-600/40 text-blue-400 rounded-lg transition-colors text-sm flex items-center justify-center">
|
||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path>
|
||||
</svg>
|
||||
添加服务器
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 网络设置 -->
|
||||
<div class="glass-panel rounded-lg p-5 border border-slate-700">
|
||||
<h4 class="text-md font-bold text-green-400 mb-4 flex items-center">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"></path>
|
||||
</svg>
|
||||
网络设置
|
||||
</h4>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-2">
|
||||
自定义虚拟IP
|
||||
<span class="text-xs text-slate-500 ml-1">(可选)</span>
|
||||
</label>
|
||||
<input v-model="formData.ip" type="text" placeholder="例如: 10.26.0.2"
|
||||
class="w-full bg-slate-800 border border-slate-600 rounded-lg px-3 py-2 text-white placeholder-slate-500 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-2">MTU</label>
|
||||
<input v-model.number="formData.mtu" type="number" placeholder="1380"
|
||||
class="w-full bg-slate-800 border border-slate-600 rounded-lg px-3 py-2 text-white placeholder-slate-500 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-2">隧道端口</label>
|
||||
<input v-model.number="formData.tunnel_port" type="number" placeholder="0 (自动分配)"
|
||||
class="w-full bg-slate-800 border border-slate-600 rounded-lg px-3 py-2 text-white placeholder-slate-500 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 传输优化 -->
|
||||
<div class="glass-panel rounded-lg p-5 border border-slate-700">
|
||||
<h4 class="text-md font-bold text-purple-400 mb-4 flex items-center">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path>
|
||||
</svg>
|
||||
传输优化
|
||||
</h4>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<label class="flex items-center justify-between p-3 bg-slate-800/50 rounded-lg border border-slate-700 cursor-pointer hover:bg-slate-800 transition-colors">
|
||||
<div class="flex-1">
|
||||
<div class="text-sm font-medium text-white">QUIC传输优化</div>
|
||||
<div class="text-xs text-slate-400 mt-0.5">重传丢包</div>
|
||||
</div>
|
||||
<input v-model="formData.rtx" type="checkbox" class="w-5 h-5 text-blue-600 bg-slate-700 border-slate-600 rounded focus:ring-blue-500">
|
||||
</label>
|
||||
<label class="flex items-center justify-between p-3 bg-slate-800/50 rounded-lg border border-slate-700 cursor-pointer hover:bg-slate-800 transition-colors">
|
||||
<div class="flex-1">
|
||||
<div class="text-sm font-medium text-white">FEC前向纠错</div>
|
||||
<div class="text-xs text-slate-400 mt-0.5">损失部分带宽提升稳定性</div>
|
||||
</div>
|
||||
<input v-model="formData.fec" type="checkbox" class="w-5 h-5 text-blue-600 bg-slate-700 border-slate-600 rounded focus:ring-blue-500">
|
||||
</label>
|
||||
<label class="flex items-center justify-between p-3 bg-slate-800/50 rounded-lg border border-slate-700 cursor-pointer hover:bg-slate-800 transition-colors">
|
||||
<div class="flex-1">
|
||||
<div class="text-sm font-medium text-white">LZ4压缩</div>
|
||||
<div class="text-xs text-slate-400 mt-0.5">减少传输数据量</div>
|
||||
</div>
|
||||
<input v-model="formData.compress" type="checkbox" class="w-5 h-5 text-blue-600 bg-slate-700 border-slate-600 rounded focus:ring-blue-500">
|
||||
</label>
|
||||
<label class="flex items-center justify-between p-3 bg-slate-800/50 rounded-lg border border-slate-700 cursor-pointer hover:bg-slate-800 transition-colors">
|
||||
<div class="flex-1">
|
||||
<div class="text-sm font-medium text-white">关闭P2P打洞</div>
|
||||
<div class="text-xs text-slate-400 mt-0.5">仅通过服务器中转</div>
|
||||
</div>
|
||||
<input v-model="formData.no_punch" type="checkbox" class="w-5 h-5 text-blue-600 bg-slate-700 border-slate-600 rounded focus:ring-blue-500">
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 安全配置 -->
|
||||
<div class="glass-panel rounded-lg p-5 border border-slate-700">
|
||||
<h4 class="text-md font-bold text-red-400 mb-4 flex items-center">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path>
|
||||
</svg>
|
||||
安全配置
|
||||
</h4>
|
||||
<div class="space-y-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-2">组网加密密码(连接公共服务器时建议填写,同一组网密码需要相同)</label>
|
||||
<input v-model="formData.password" type="password" placeholder="留空则不加密"
|
||||
class="w-full bg-slate-800 border border-slate-600 rounded-lg px-3 py-2 text-white placeholder-slate-500 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-2">服务端证书校验模式</label>
|
||||
<select v-model="formData.cert_mode"
|
||||
class="w-full bg-slate-800 border border-slate-600 rounded-lg px-3 py-2 text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
<option value="skip">跳过验证 (默认)</option>
|
||||
<option value="standard">系统证书验证</option>
|
||||
<option value="finger">证书指纹验证</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="formData.cert_mode === 'finger'" class="animate-in fade-in slide-in-from-top-2">
|
||||
<label class="block text-sm font-medium text-slate-300 mb-2">
|
||||
证书指纹
|
||||
<span class="text-xs text-slate-500 ml-1">(服务端启动时日志会输出指纹)</span>
|
||||
</label>
|
||||
<input v-model="formData.fingerprint" type="text" placeholder="例如: 3bdd8675606837cdf95d5e13445606315762315a78555f9da652940a25feaec1"
|
||||
class="w-full bg-slate-800 border border-slate-600 rounded-lg px-3 py-2 text-white placeholder-slate-500 focus:ring-2 focus:ring-blue-500 focus:border-transparent font-mono text-sm">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- NAT与路由 -->
|
||||
<div class="glass-panel rounded-lg p-5 border border-slate-700">
|
||||
<h4 class="text-md font-bold text-yellow-400 mb-4 flex items-center">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="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 00-1.447-.894L15 9m0 0V7m0 2v6"></path>
|
||||
</svg>
|
||||
NAT与路由 (点对网)
|
||||
</h4>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-2">
|
||||
入栈网段
|
||||
<span class="text-xs text-slate-500 ml-1">格式: CIDR,目标IP</span>
|
||||
</label>
|
||||
<div class="space-y-2">
|
||||
<div v-for="(item, idx) in formData.input" :key="idx" class="flex space-x-2">
|
||||
<input v-model="formData.input[idx]" type="text" placeholder="例如: 192.168.0.0/24,10.26.0.2"
|
||||
class="flex-1 bg-slate-800 border border-slate-600 rounded-lg px-3 py-2 text-white placeholder-slate-500 focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm">
|
||||
<button @click="formData.input.splice(idx, 1)"
|
||||
class="px-3 py-2 bg-red-600/20 hover:bg-red-600/40 text-red-400 rounded-lg transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<button @click="formData.input.push('')"
|
||||
class="w-full px-3 py-1.5 bg-yellow-600/20 hover:bg-yellow-600/40 text-yellow-400 rounded-lg transition-colors text-sm">
|
||||
+ 添加入栈网段
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-2">
|
||||
出栈网段
|
||||
<span class="text-xs text-slate-500 ml-1">格式: CIDR (允许转发的网段)</span>
|
||||
</label>
|
||||
<div class="space-y-2">
|
||||
<div v-for="(item, idx) in formData.output" :key="idx" class="flex space-x-2">
|
||||
<input v-model="formData.output[idx]" type="text" placeholder="例如: 0.0.0.0/0"
|
||||
class="flex-1 bg-slate-800 border border-slate-600 rounded-lg px-3 py-2 text-white placeholder-slate-500 focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm">
|
||||
<button @click="formData.output.splice(idx, 1)"
|
||||
class="px-3 py-2 bg-red-600/20 hover:bg-red-600/40 text-red-400 rounded-lg transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<button @click="formData.output.push('')"
|
||||
class="w-full px-3 py-1.5 bg-yellow-600/20 hover:bg-yellow-600/40 text-yellow-400 rounded-lg transition-colors text-sm">
|
||||
+ 添加出栈网段
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<label class="flex items-center justify-between p-3 bg-slate-800/50 rounded-lg border border-slate-700 cursor-pointer hover:bg-slate-800 transition-colors">
|
||||
<div class="flex-1">
|
||||
<div class="text-sm font-medium text-white">关闭内置NAT</div>
|
||||
<div class="text-xs text-slate-400 mt-0.5">使用系统网卡转发</div>
|
||||
</div>
|
||||
<input v-model="formData.no_nat" type="checkbox" class="w-5 h-5 text-blue-600 bg-slate-700 border-slate-600 rounded focus:ring-blue-500">
|
||||
</label>
|
||||
<label class="flex items-center justify-between p-3 bg-slate-800/50 rounded-lg border border-slate-700 cursor-pointer hover:bg-slate-800 transition-colors">
|
||||
<div class="flex-1">
|
||||
<div class="text-sm font-medium text-white">关闭TUN网卡</div>
|
||||
<div class="text-xs text-slate-400 mt-0.5">仅作流量出口或端口映射</div>
|
||||
</div>
|
||||
<input v-model="formData.no_tun" type="checkbox" class="w-5 h-5 text-blue-600 bg-slate-700 border-slate-600 rounded focus:ring-blue-500">
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 端口映射 -->
|
||||
<div class="glass-panel rounded-lg p-5 border border-slate-700">
|
||||
<h4 class="text-md font-bold text-orange-400 mb-4 flex items-center">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
|
||||
</svg>
|
||||
端口映射
|
||||
</h4>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-2">
|
||||
映射规则
|
||||
<span class="text-xs text-slate-500 ml-1">格式: 协议://监听地址-虚拟IP-目标地址</span>
|
||||
</label>
|
||||
<div class="space-y-2">
|
||||
<div v-for="(item, idx) in formData.port_mapping" :key="idx" class="flex space-x-2">
|
||||
<input v-model="formData.port_mapping[idx]" type="text" placeholder="例如: tcp://0.0.0.0:81-10.0.0.2-10.0.0.2:80"
|
||||
class="flex-1 bg-slate-800 border border-slate-600 rounded-lg px-3 py-2 text-white placeholder-slate-500 focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm font-mono">
|
||||
<button @click="formData.port_mapping.splice(idx, 1)"
|
||||
class="px-3 py-2 bg-red-600/20 hover:bg-red-600/40 text-red-400 rounded-lg transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<button @click="formData.port_mapping.push('')"
|
||||
class="w-full px-3 py-1.5 bg-orange-600/20 hover:bg-orange-600/40 text-orange-400 rounded-lg transition-colors text-sm">
|
||||
+ 添加映射规则
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<label class="flex items-center justify-between p-3 bg-slate-800/50 rounded-lg border border-slate-700 cursor-pointer hover:bg-slate-800 transition-colors">
|
||||
<div class="flex-1">
|
||||
<div class="text-sm font-medium text-white">允许作为映射出口</div>
|
||||
<div class="text-xs text-slate-400 mt-0.5">允许其他设备使用本机作跳板</div>
|
||||
</div>
|
||||
<input v-model="formData.allow_mapping" type="checkbox" class="w-5 h-5 text-blue-600 bg-slate-700 border-slate-600 rounded focus:ring-blue-500">
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 设备配置 -->
|
||||
<div class="glass-panel rounded-lg p-5 border border-slate-700">
|
||||
<h4 class="text-md font-bold text-cyan-400 mb-4 flex items-center">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"></path>
|
||||
</svg>
|
||||
设备配置
|
||||
</h4>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-2">设备名称</label>
|
||||
<input v-model="formData.device_name" type="text" placeholder="默认为主机名"
|
||||
class="w-full bg-slate-800 border border-slate-600 rounded-lg px-3 py-2 text-white placeholder-slate-500 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-2">设备ID</label>
|
||||
<input v-model="formData.device_id" type="text" placeholder="自动生成"
|
||||
class="w-full bg-slate-800 border border-slate-600 rounded-lg px-3 py-2 text-white placeholder-slate-500 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-2">虚拟网卡名</label>
|
||||
<input v-model="formData.tun_name" type="text" placeholder="默认为vnt-tun"
|
||||
class="w-full bg-slate-800 border border-slate-600 rounded-lg px-3 py-2 text-white placeholder-slate-500 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- STUN配置 -->
|
||||
<div class="glass-panel rounded-lg p-5 border border-slate-700">
|
||||
<h4 class="text-md font-bold text-pink-400 mb-4 flex items-center">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
STUN配置 (高级)
|
||||
</h4>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-2">UDP STUN服务器</label>
|
||||
<div class="space-y-2">
|
||||
<div v-for="(item, idx) in formData.udp_stun" :key="idx" class="flex space-x-2">
|
||||
<input v-model="formData.udp_stun[idx]" type="text" placeholder="例如: stun.l.google.com:19302"
|
||||
class="flex-1 bg-slate-800 border border-slate-600 rounded-lg px-3 py-2 text-white placeholder-slate-500 focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm">
|
||||
<button @click="formData.udp_stun.splice(idx, 1)"
|
||||
class="px-3 py-2 bg-red-600/20 hover:bg-red-600/40 text-red-400 rounded-lg transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<button @click="formData.udp_stun.push('')"
|
||||
class="w-full px-3 py-1.5 bg-pink-600/20 hover:bg-pink-600/40 text-pink-400 rounded-lg transition-colors text-sm">
|
||||
+ 添加UDP STUN
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-2">TCP STUN服务器</label>
|
||||
<div class="space-y-2">
|
||||
<div v-for="(item, idx) in formData.tcp_stun" :key="idx" class="flex space-x-2">
|
||||
<input v-model="formData.tcp_stun[idx]" type="text" placeholder="例如: stun.nextcloud.com:443"
|
||||
class="flex-1 bg-slate-800 border border-slate-600 rounded-lg px-3 py-2 text-white placeholder-slate-500 focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm">
|
||||
<button @click="formData.tcp_stun.splice(idx, 1)"
|
||||
class="px-3 py-2 bg-red-600/20 hover:bg-red-600/40 text-red-400 rounded-lg transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<button @click="formData.tcp_stun.push('')"
|
||||
class="w-full px-3 py-1.5 bg-pink-600/20 hover:bg-pink-600/40 text-pink-400 rounded-lg transition-colors text-sm">
|
||||
+ 添加TCP STUN
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TOML模式 -->
|
||||
<div v-show="editMode === 'toml'" class="h-full">
|
||||
<textarea
|
||||
v-model="editorContent"
|
||||
class="w-full h-full bg-[#1e1e1e] text-[#d4d4d4] font-mono p-4 resize-none focus:outline-none text-sm"
|
||||
spellcheck="false"
|
||||
placeholder="# 在此处输入 TOML 配置..."
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="p-4 border-t border-slate-700 flex justify-between items-center bg-slate-800/50">
|
||||
<div class="text-xs text-slate-500">
|
||||
<span v-if="editMode === 'form'">填写完成后保存即可生成配置文件</span>
|
||||
<span v-else>* 请使用标准 TOML 格式</span>
|
||||
</div>
|
||||
<div class="space-x-3">
|
||||
<button
|
||||
@click="showEditor = false"
|
||||
class="px-4 py-2 rounded text-slate-300 hover:text-white transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
@click="saveConfig"
|
||||
class="px-6 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded font-medium shadow-lg transition-colors"
|
||||
>
|
||||
保存配置
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 3. Peers (设备列表) -->
|
||||
<template id="tpl-peers">
|
||||
<div class="max-w-7xl mx-auto">
|
||||
<!-- 实例切换器 -->
|
||||
<div
|
||||
v-if="instanceList.length > 0"
|
||||
class="flex items-center space-x-2 mb-4 overflow-x-auto scrollbar-hide"
|
||||
>
|
||||
<button
|
||||
v-for="inst in instanceList"
|
||||
:key="inst.file_name"
|
||||
@click="selectedInstance = inst.file_name"
|
||||
:class="selectedInstance === inst.file_name ? 'bg-blue-600 text-white border-blue-500' : 'bg-slate-800 text-slate-300 border-slate-700 hover:bg-slate-700'"
|
||||
class="flex items-center px-4 py-2 rounded-lg border text-sm font-medium transition-colors shrink-0"
|
||||
>
|
||||
<span
|
||||
class="w-2 h-2 rounded-full mr-2"
|
||||
:class="inst.status === 'running' ? 'bg-green-500' : (inst.status === 'starting' ? 'bg-blue-400 animate-pulse' : 'bg-red-500')"
|
||||
></span>
|
||||
{{ inst.config_name || inst.file_name }}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-if="!selectedInstance"
|
||||
class="glass-panel rounded-xl p-12 text-center text-slate-500"
|
||||
>
|
||||
暂无运行中的组网实例
|
||||
</div>
|
||||
<div v-else class="glass-panel rounded-xl shadow-lg overflow-hidden">
|
||||
<div
|
||||
class="px-6 py-4 border-b border-slate-700 flex justify-between items-center"
|
||||
>
|
||||
<h2 class="text-xl font-bold text-white">设备列表</h2>
|
||||
<div class="flex space-x-4 text-sm text-slate-400">
|
||||
<span
|
||||
>Online:
|
||||
<span class="text-white"
|
||||
>{{ peers.filter(p => p.online).length
|
||||
}}</span
|
||||
></span
|
||||
>
|
||||
<span
|
||||
>Total:
|
||||
<span class="text-white"
|
||||
>{{ peers.length }}</span
|
||||
></span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="overflow-auto max-h-[600px] custom-scrollbar">
|
||||
<table class="min-w-full divide-y divide-slate-700">
|
||||
<thead class="bg-slate-800">
|
||||
<tr>
|
||||
<th class="w-8 px-2 py-3"></th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium text-slate-400 tracking-wider"
|
||||
>
|
||||
IP地址
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium text-slate-400 tracking-wider"
|
||||
>
|
||||
名称
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium text-slate-400 tracking-wider"
|
||||
>
|
||||
版本
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium text-slate-400 tracking-wider"
|
||||
>
|
||||
状态
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium text-slate-400 tracking-wider"
|
||||
>
|
||||
模式
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium text-slate-400 tracking-wider"
|
||||
>
|
||||
延迟
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium text-slate-400 tracking-wider"
|
||||
>
|
||||
丢包率
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium text-slate-400 tracking-wider"
|
||||
>
|
||||
流量
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium text-slate-400 tracking-wider"
|
||||
>
|
||||
最后在线
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody
|
||||
class="divide-y divide-slate-700 bg-slate-900/30"
|
||||
>
|
||||
<template v-for="peer in peers" :key="peer.ip">
|
||||
<tr class="hover:bg-slate-800/50 transition-colors">
|
||||
<td class="px-2 py-4 text-center cursor-pointer select-none" @click="toggleExpand(peer.ip)">
|
||||
<svg class="w-4 h-4 text-slate-500 transition-transform duration-200 inline-block" :class="{ 'rotate-90': expandedPeers[peer.ip] }" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
|
||||
</svg>
|
||||
</td>
|
||||
<td
|
||||
class="px-6 py-4 whitespace-nowrap font-mono text-sm text-blue-300"
|
||||
>
|
||||
<span
|
||||
class="border-b border-dotted border-blue-500/50 pb-0.5 cursor-help"
|
||||
@mouseenter="showPeerTooltip($event, peer)"
|
||||
@mouseleave="hidePeerTooltip"
|
||||
>
|
||||
{{ peer.ip }}
|
||||
</span>
|
||||
</td>
|
||||
<td
|
||||
class="px-6 py-4 whitespace-nowrap text-sm text-slate-300"
|
||||
>
|
||||
{{ peer.name || '-' }}
|
||||
</td>
|
||||
<td
|
||||
class="px-6 py-4 whitespace-nowrap text-xs text-slate-500"
|
||||
>
|
||||
{{ peer.version || '-' }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="flex items-center space-x-2">
|
||||
<span
|
||||
:class="peer.online ? 'bg-green-900 text-green-300' : 'bg-slate-700 text-slate-400'"
|
||||
class="px-2 py-0.5 text-xs rounded-full font-medium"
|
||||
>{{ peer.online ? 'Online' : 'Offline' }}</span>
|
||||
<!-- 加密状态图标 -->
|
||||
<div v-if="peer.online && peer.key_equal === 1" class="tooltip">
|
||||
<svg
|
||||
class="w-4 h-4 text-green-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
|
||||
></path>
|
||||
</svg>
|
||||
<span class="tooltip-text">双方加密传输</span>
|
||||
</div>
|
||||
<div v-else-if="peer.online && peer.key_equal === 2" class="tooltip">
|
||||
<svg
|
||||
class="w-4 h-4 text-yellow-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 11V7a4 4 0 118 0m-4 8v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2z"
|
||||
></path>
|
||||
</svg>
|
||||
<span class="tooltip-text">双方未加密</span>
|
||||
</div>
|
||||
<div v-else-if="peer.online && [3,4,5].includes(peer.key_equal)" class="tooltip cursor-help group">
|
||||
<svg
|
||||
class="w-4 h-4 text-red-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
></path>
|
||||
</svg>
|
||||
<span class="tooltip-text">{{ peer.key_equal === 3 ? '己方加密对方未加密' : peer.key_equal === 4 ? '己方未加密对方加密' : peer.key_equal === 5 ? '密钥不一致' : '未知错误' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
class="px-6 py-4 whitespace-nowrap text-sm"
|
||||
>
|
||||
<span
|
||||
v-if="peer.online && peer.route"
|
||||
:class="getRouteModeClass(peer.route)"
|
||||
>{{ getRouteModeText(peer.route) }}</span
|
||||
>
|
||||
<span v-else-if="peer.online" class="text-yellow-400">服务器中继</span>
|
||||
<span v-else class="text-slate-600"
|
||||
>-</span
|
||||
>
|
||||
</td>
|
||||
<td
|
||||
class="px-6 py-4 whitespace-nowrap text-sm text-slate-400"
|
||||
>
|
||||
{{ peer.route ? peer.route.rtt + ' ms' : '-' }}
|
||||
</td>
|
||||
<td
|
||||
class="px-6 py-4 whitespace-nowrap text-sm"
|
||||
>
|
||||
<span v-if="peer.packet_loss"
|
||||
:class="peer.packet_loss.loss_rate > 10 ? 'text-red-400' : peer.packet_loss.loss_rate > 5 ? 'text-yellow-400' : 'text-green-400'"
|
||||
:title="'Sent: ' + peer.packet_loss.sent + ', Received: ' + peer.packet_loss.received"
|
||||
>{{ peer.packet_loss.loss_rate.toFixed(1) }}%</span>
|
||||
<span v-else class="text-slate-600">-</span>
|
||||
</td>
|
||||
<td
|
||||
class="px-6 py-4 whitespace-nowrap text-xs"
|
||||
>
|
||||
<div v-if="peer.traffic" class="leading-relaxed">
|
||||
<div class="text-green-400">↑ {{ formatBytes(peer.traffic.tx_bytes) }} ({{ formatSpeed(peer.traffic.tx_speed) }})</div>
|
||||
<div class="text-blue-400">↓ {{ formatBytes(peer.traffic.rx_bytes) }} ({{ formatSpeed(peer.traffic.rx_speed) }})</div>
|
||||
</div>
|
||||
<span v-else class="text-slate-600">-</span>
|
||||
</td>
|
||||
<td
|
||||
class="px-6 py-4 whitespace-nowrap text-sm text-slate-500 font-mono text-xs"
|
||||
>
|
||||
{{ formatTime(peer.last_connected_time)
|
||||
}}
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="expandedPeers[peer.ip]">
|
||||
<td :colspan="10" class="p-0">
|
||||
<div class="px-4 py-3 bg-slate-950/60 border-t border-slate-700/50">
|
||||
<div class="flex items-center space-x-4 mb-2 text-xs text-slate-400">
|
||||
<span class="flex items-center"><span class="inline-block w-3 h-0.5 bg-green-400 mr-1"></span>上传速度</span>
|
||||
<span class="flex items-center"><span class="inline-block w-3 h-0.5 bg-blue-400 mr-1"></span>下载速度</span>
|
||||
<span class="ml-auto" :id="'chart-max-' + peer.ip.replaceAll('.', '-')"></span>
|
||||
</div>
|
||||
<canvas :id="'chart-' + peer.ip.replaceAll('.', '-')" style="width:100%;height:150px;display:block;" class="rounded"></canvas>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 4. Routes (路由) -->
|
||||
<template id="tpl-routes">
|
||||
<div class="max-w-6xl mx-auto">
|
||||
<!-- 实例切换器 -->
|
||||
<div
|
||||
v-if="instanceList.length > 0"
|
||||
class="flex items-center space-x-2 mb-4 overflow-x-auto scrollbar-hide"
|
||||
>
|
||||
<button
|
||||
v-for="inst in instanceList"
|
||||
:key="inst.file_name"
|
||||
@click="selectedInstance = inst.file_name"
|
||||
:class="selectedInstance === inst.file_name ? 'bg-blue-600 text-white border-blue-500' : 'bg-slate-800 text-slate-300 border-slate-700 hover:bg-slate-700'"
|
||||
class="flex items-center px-4 py-2 rounded-lg border text-sm font-medium transition-colors shrink-0"
|
||||
>
|
||||
<span
|
||||
class="w-2 h-2 rounded-full mr-2"
|
||||
:class="inst.status === 'running' ? 'bg-green-500' : (inst.status === 'starting' ? 'bg-blue-400 animate-pulse' : 'bg-red-500')"
|
||||
></span>
|
||||
{{ inst.config_name || inst.file_name }}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-if="!selectedInstance"
|
||||
class="glass-panel rounded-xl p-12 text-center text-slate-500"
|
||||
>
|
||||
暂无运行中的组网实例
|
||||
</div>
|
||||
<div v-else class="glass-panel rounded-xl shadow-lg overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-slate-700">
|
||||
<h2 class="text-xl font-bold text-white">路由表</h2>
|
||||
</div>
|
||||
<div class="overflow-auto max-h-[600px] custom-scrollbar">
|
||||
<table class="min-w-full divide-y divide-slate-700">
|
||||
<thead class="bg-slate-800">
|
||||
<tr>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium text-slate-400 uppercase tracking-wider"
|
||||
>
|
||||
目标节点IP
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium text-slate-400 uppercase tracking-wider"
|
||||
>
|
||||
目标网络
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium text-slate-400 uppercase tracking-wider"
|
||||
>
|
||||
跳数
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium text-slate-400 uppercase tracking-wider"
|
||||
>
|
||||
延迟
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody
|
||||
class="divide-y divide-slate-700 bg-slate-900/30"
|
||||
>
|
||||
<template v-for="item in routes" :key="item.ip">
|
||||
<tr
|
||||
v-for="(route, rIdx) in item.routes"
|
||||
:key="rIdx"
|
||||
class="hover:bg-slate-800/50"
|
||||
>
|
||||
<td
|
||||
class="px-6 py-4 whitespace-nowrap font-mono text-sm text-blue-300"
|
||||
v-if="rIdx===0"
|
||||
:rowspan="item.routes.length"
|
||||
>
|
||||
{{ item.ip }}
|
||||
</td>
|
||||
<td
|
||||
class="px-6 py-4 whitespace-nowrap font-mono text-sm text-yellow-300"
|
||||
>
|
||||
{{ route.addr }}
|
||||
</td>
|
||||
<td
|
||||
class="px-6 py-4 whitespace-nowrap text-sm text-slate-400"
|
||||
>
|
||||
{{ route.metric }}
|
||||
</td>
|
||||
<td
|
||||
class="px-6 py-4 whitespace-nowrap text-sm text-slate-400"
|
||||
>
|
||||
{{ route.rtt }} ms
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
const {
|
||||
createApp,
|
||||
ref,
|
||||
reactive,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
computed,
|
||||
watch,
|
||||
nextTick,
|
||||
inject,
|
||||
provide,
|
||||
} = Vue;
|
||||
const {createRouter, createWebHashHistory} = VueRouter;
|
||||
|
||||
const API_BASE = "";
|
||||
|
||||
// 格式化时间工具
|
||||
const formatTime = (timestamp) => {
|
||||
if (!timestamp) return "-";
|
||||
const date = new Date(timestamp * 1000);
|
||||
const pad = (n) => (n < 10 ? "0" + n : n);
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
||||
};
|
||||
|
||||
// 格式化字节数
|
||||
const formatBytes = (bytes) => {
|
||||
if (bytes === 0 || bytes === undefined || bytes === null) return "0B";
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
let i = 0;
|
||||
let value = bytes;
|
||||
while (value >= 1024 && i < units.length - 1) {
|
||||
value /= 1024;
|
||||
i++;
|
||||
}
|
||||
return i === 0 ? value + units[i] : value.toFixed(2) + units[i];
|
||||
};
|
||||
|
||||
// 格式化速度(字节/秒)
|
||||
const formatSpeed = (bytesPerSecond) => {
|
||||
if (bytesPerSecond === 0 || bytesPerSecond === undefined || bytesPerSecond === null) return "0B/s";
|
||||
const units = ["B/s", "KB/s", "MB/s", "GB/s"];
|
||||
let i = 0;
|
||||
let value = bytesPerSecond;
|
||||
while (value >= 1024 && i < units.length - 1) {
|
||||
value /= 1024;
|
||||
i++;
|
||||
}
|
||||
return i === 0 ? value + units[i] : value.toFixed(2) + units[i];
|
||||
};
|
||||
|
||||
// --- 组件定义 ---
|
||||
|
||||
const GeneralView = {
|
||||
template: "#tpl-general",
|
||||
setup() {
|
||||
const instanceList = inject("instanceList");
|
||||
const instances = inject("instances");
|
||||
const configList = inject("configList");
|
||||
const startVnt = inject("startVnt");
|
||||
const stopVnt = inject("stopVnt");
|
||||
const restartVnt = inject("restartVnt");
|
||||
const dismissInstance = inject("dismissInstance");
|
||||
const openStartLog = inject("openStartLog");
|
||||
const loadingMap = inject("loadingMap");
|
||||
const selectedInstance = inject("selectedInstance");
|
||||
const localSelectedConfig = ref("");
|
||||
|
||||
// 只列出没有对应实例的配置(同一配置最多一个实例)
|
||||
const availableConfigs = computed(() =>
|
||||
configList.value.filter(
|
||||
(cfg) =>
|
||||
!instanceList.value.some(
|
||||
(inst) => inst.file_name === cfg.file_name,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const infoOf = (fileName) => instances.value[fileName] || {};
|
||||
|
||||
const selectedInfo = computed(() =>
|
||||
selectedInstance.value
|
||||
? infoOf(selectedInstance.value)
|
||||
: {},
|
||||
);
|
||||
|
||||
const selectedConfigName = computed(() => {
|
||||
if (!selectedInstance.value) return "";
|
||||
const inst = instanceList.value.find(
|
||||
(i) => i.file_name === selectedInstance.value,
|
||||
);
|
||||
return inst
|
||||
? inst.config_name || inst.file_name
|
||||
: selectedInstance.value;
|
||||
});
|
||||
|
||||
const selectInstance = (fileName) => {
|
||||
selectedInstance.value = fileName;
|
||||
};
|
||||
|
||||
const handleStart = () => {
|
||||
if (!localSelectedConfig.value) {
|
||||
alert("请先选择一个配置");
|
||||
return;
|
||||
}
|
||||
startVnt(localSelectedConfig.value);
|
||||
localSelectedConfig.value = "";
|
||||
};
|
||||
|
||||
const statusText = (status) =>
|
||||
status === "running"
|
||||
? "运行中"
|
||||
: status === "starting"
|
||||
? "启动中"
|
||||
: "已停止";
|
||||
const statusDotClass = (status) =>
|
||||
status === "running"
|
||||
? "bg-green-500"
|
||||
: status === "starting"
|
||||
? "bg-blue-500 animate-pulse"
|
||||
: "bg-red-500";
|
||||
const statusTextClass = (status) =>
|
||||
status === "running"
|
||||
? "text-green-400"
|
||||
: status === "starting"
|
||||
? "text-blue-400"
|
||||
: "text-red-400";
|
||||
|
||||
return {
|
||||
instanceList,
|
||||
availableConfigs,
|
||||
localSelectedConfig,
|
||||
loadingMap,
|
||||
selectedInstance,
|
||||
selectedInfo,
|
||||
selectedConfigName,
|
||||
infoOf,
|
||||
selectInstance,
|
||||
handleStart,
|
||||
stopVnt,
|
||||
restartVnt,
|
||||
dismissInstance,
|
||||
openStartLog,
|
||||
statusText,
|
||||
statusDotClass,
|
||||
statusTextClass,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const ConfigView = {
|
||||
template: "#tpl-config",
|
||||
setup() {
|
||||
const instanceList = inject("instanceList");
|
||||
const configList = inject("configList");
|
||||
const fetchConfigList = inject("fetchConfigList");
|
||||
|
||||
// 配置对应的实例运行状态(无实例返回 null)
|
||||
const instStatus = (fileName) => {
|
||||
const inst = instanceList.value.find(
|
||||
(i) => i.file_name === fileName,
|
||||
);
|
||||
return inst ? inst.status : null;
|
||||
};
|
||||
|
||||
// 编辑器状态
|
||||
const showEditor = ref(false);
|
||||
const editorContent = ref("");
|
||||
const editorFileName = ref("");
|
||||
const editorMode = ref("new");
|
||||
const editMode = ref("form"); // 'form' 或 'toml'
|
||||
const originalToml = ref(""); // 保存原始TOML内容(包含用户注释)
|
||||
const hasTomlChanges = ref(false); // 标记TOML是否被修改过
|
||||
const hasFormChanges = ref(false); // 标记表单是否被修改过
|
||||
const isParsingToml = ref(false); // 标记是否正在解析TOML(用于避免触发watch)
|
||||
|
||||
// 表单数据
|
||||
const formData = ref({
|
||||
config_name: "",
|
||||
network_code: "",
|
||||
server: [""],
|
||||
ip: "",
|
||||
mtu: null,
|
||||
rtx: false,
|
||||
fec: false,
|
||||
compress: false,
|
||||
no_punch: false,
|
||||
input: [],
|
||||
output: [],
|
||||
no_nat: false,
|
||||
no_tun: false,
|
||||
port_mapping: [],
|
||||
allow_mapping: false,
|
||||
device_name: "",
|
||||
device_id: "",
|
||||
tun_name: "",
|
||||
password: "",
|
||||
cert_mode: "skip",
|
||||
fingerprint: "",
|
||||
udp_stun: [],
|
||||
tcp_stun: [],
|
||||
tunnel_port: null
|
||||
});
|
||||
|
||||
// 从TOML解析到表单
|
||||
const parseTomlToForm = (toml) => {
|
||||
const data = {
|
||||
config_name: "",
|
||||
network_code: "",
|
||||
server: [""],
|
||||
ip: "",
|
||||
mtu: null,
|
||||
rtx: false,
|
||||
fec: false,
|
||||
compress: false,
|
||||
no_punch: false,
|
||||
input: [],
|
||||
output: [],
|
||||
no_nat: false,
|
||||
no_tun: false,
|
||||
port_mapping: [],
|
||||
allow_mapping: false,
|
||||
device_name: "",
|
||||
device_id: "",
|
||||
tun_name: "",
|
||||
password: "",
|
||||
cert_mode: "skip",
|
||||
fingerprint: "",
|
||||
udp_stun: [],
|
||||
tcp_stun: [],
|
||||
tunnel_port: null
|
||||
};
|
||||
|
||||
const lines = toml.split('\n');
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
|
||||
if (trimmed.includes('config_name')) {
|
||||
const match = trimmed.match(/config_name\s*=\s*"([^"]*)"/);
|
||||
if (match) data.config_name = match[1];
|
||||
} else if (trimmed.includes('network_code')) {
|
||||
const match = trimmed.match(/network_code\s*=\s*"([^"]*)"/);
|
||||
if (match) data.network_code = match[1];
|
||||
} else if (trimmed.startsWith('server')) {
|
||||
const match = trimmed.match(/server\s*=\s*\[(.*)\]/);
|
||||
if (match) {
|
||||
const items = match[1].match(/"([^"]*)"/g);
|
||||
if (items) data.server = items.map(s => s.replace(/"/g, ''));
|
||||
}
|
||||
} else if (trimmed.includes('ip =')) {
|
||||
const match = trimmed.match(/ip\s*=\s*"([^"]*)"/);
|
||||
if (match) data.ip = match[1];
|
||||
} else if (trimmed.includes('mtu =')) {
|
||||
const match = trimmed.match(/mtu\s*=\s*(\d+)/);
|
||||
if (match) data.mtu = parseInt(match[1]);
|
||||
} else if (trimmed.match(/^rtx\s*=/)) {
|
||||
data.rtx = trimmed.includes('true');
|
||||
} else if (trimmed.match(/^fec\s*=/)) {
|
||||
data.fec = trimmed.includes('true');
|
||||
} else if (trimmed.match(/^compress\s*=/)) {
|
||||
data.compress = trimmed.includes('true');
|
||||
} else if (trimmed.match(/^no_punch\s*=/)) {
|
||||
data.no_punch = trimmed.includes('true');
|
||||
} else if (trimmed.startsWith('input')) {
|
||||
const match = trimmed.match(/input\s*=\s*\[(.*)\]/);
|
||||
if (match) {
|
||||
const items = match[1].match(/"([^"]*)"/g);
|
||||
if (items) data.input = items.map(s => s.replace(/"/g, ''));
|
||||
}
|
||||
} else if (trimmed.startsWith('output')) {
|
||||
const match = trimmed.match(/output\s*=\s*\[(.*)\]/);
|
||||
if (match) {
|
||||
const items = match[1].match(/"([^"]*)"/g);
|
||||
if (items) data.output = items.map(s => s.replace(/"/g, ''));
|
||||
}
|
||||
} else if (trimmed.match(/^no_nat\s*=/)) {
|
||||
data.no_nat = trimmed.includes('true');
|
||||
} else if (trimmed.match(/^no_tun\s*=/)) {
|
||||
data.no_tun = trimmed.includes('true');
|
||||
} else if (trimmed.startsWith('port_mapping')) {
|
||||
const match = trimmed.match(/port_mapping\s*=\s*\[(.*)\]/);
|
||||
if (match) {
|
||||
const items = match[1].match(/"([^"]*)"/g);
|
||||
if (items) data.port_mapping = items.map(s => s.replace(/"/g, ''));
|
||||
}
|
||||
} else if (trimmed.match(/^allow_mapping\s*=/)) {
|
||||
data.allow_mapping = trimmed.includes('true');
|
||||
} else if (trimmed.includes('device_name')) {
|
||||
const match = trimmed.match(/device_name\s*=\s*"([^"]*)"/);
|
||||
if (match) data.device_name = match[1];
|
||||
} else if (trimmed.includes('device_id')) {
|
||||
const match = trimmed.match(/device_id\s*=\s*"([^"]*)"/);
|
||||
if (match) data.device_id = match[1];
|
||||
} else if (trimmed.includes('tun_name')) {
|
||||
const match = trimmed.match(/tun_name\s*=\s*"([^"]*)"/);
|
||||
if (match) data.tun_name = match[1];
|
||||
} else if (trimmed.includes('password =')) {
|
||||
const match = trimmed.match(/password\s*=\s*"([^"]*)"/);
|
||||
if (match) data.password = match[1];
|
||||
} else if (trimmed.includes('cert_mode')) {
|
||||
const match = trimmed.match(/cert_mode\s*=\s*"([^"]*)"/);
|
||||
if (match) {
|
||||
const value = match[1];
|
||||
if (value.startsWith('finger:')) {
|
||||
data.cert_mode = 'finger';
|
||||
data.fingerprint = value.substring(7); // 去掉 "finger:" 前缀
|
||||
} else {
|
||||
data.cert_mode = value;
|
||||
}
|
||||
}
|
||||
} else if (trimmed.startsWith('udp_stun')) {
|
||||
const match = trimmed.match(/udp_stun\s*=\s*\[(.*)\]/);
|
||||
if (match) {
|
||||
const items = match[1].match(/"([^"]*)"/g);
|
||||
if (items) data.udp_stun = items.map(s => s.replace(/"/g, ''));
|
||||
}
|
||||
} else if (trimmed.startsWith('tcp_stun')) {
|
||||
const match = trimmed.match(/tcp_stun\s*=\s*\[(.*)\]/);
|
||||
if (match) {
|
||||
const items = match[1].match(/"([^"]*)"/g);
|
||||
if (items) data.tcp_stun = items.map(s => s.replace(/"/g, ''));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
// 从表单生成TOML
|
||||
const formToToml = () => {
|
||||
let toml = '';
|
||||
|
||||
if (formData.value.config_name) {
|
||||
toml += `# 配置名称\nconfig_name = "${formData.value.config_name}"\n`;
|
||||
}
|
||||
|
||||
toml += '\n# --- 网络配置 ---\n';
|
||||
toml += '# 网络编号,相同网络编号的会组在同一个虚拟网 (必填)\n';
|
||||
toml += `network_code = "${formData.value.network_code}"\n\n`;
|
||||
|
||||
const servers = formData.value.server.filter(s => s.trim());
|
||||
if (servers.length > 0) {
|
||||
toml += '# 服务器地址列表(支持 quic / tcp / wss / dynamic) (必填)\n';
|
||||
toml += '# dynamic 协议使用dns txt解析记录值\n';
|
||||
toml += `server = [${servers.map(s => `"${s}"`).join(', ')}]\n`;
|
||||
}
|
||||
|
||||
if (formData.value.ip) {
|
||||
toml += '\n# 自定义虚拟 IP (可选)\n';
|
||||
toml += `ip = "${formData.value.ip}"\n`;
|
||||
}
|
||||
|
||||
if (formData.value.rtx) {
|
||||
toml += '\n# 是否启用quic优化传输 (默认 false)\n';
|
||||
toml += '# 开启后传输过程几乎不会丢包,但是延迟可能会有波动\n';
|
||||
toml += 'rtx = true\n';
|
||||
}
|
||||
|
||||
if (formData.value.fec) {
|
||||
toml += '\n# 是否启用 FEC 前向纠错 (默认 false)\n';
|
||||
toml += '# 开启后可以减少丢包率,损失带宽但是延迟比较稳定,带宽充足时可以使用此功能\n';
|
||||
toml += 'fec = true\n';
|
||||
}
|
||||
|
||||
if (formData.value.no_punch) {
|
||||
toml += '\n# 是否关闭 P2P 打洞 (默认 false)\n';
|
||||
toml += 'no_punch = true\n';
|
||||
}
|
||||
|
||||
if (formData.value.compress) {
|
||||
toml += '\n# 是否启用 LZ4 压缩 (默认 false)\n';
|
||||
toml += 'compress = true\n';
|
||||
}
|
||||
|
||||
const inputs = formData.value.input.filter(s => s.trim());
|
||||
if (inputs.length > 0) {
|
||||
toml += '\n# 入栈监听网段 (逗号分隔的 CIDR 和目标 IP),用于点对网,将指定网段的流量发送到目标节点\n';
|
||||
toml += '# 例如192.168.0.0/24,10.26.0.2 表示将192.168.0.0/24网段的数据转发到10.26.0.2\n';
|
||||
toml += `input = [${inputs.map(s => `"${s}"`).join(', ')}]\n`;
|
||||
}
|
||||
|
||||
const outputs = formData.value.output.filter(s => s.trim());
|
||||
if (outputs.length > 0) {
|
||||
toml += '\n# 出栈允许网段,用于点对网,允许指定网段的转发\n';
|
||||
toml += `output = [${outputs.map(s => `"${s}"`).join(', ')}]\n`;
|
||||
}
|
||||
|
||||
if (formData.value.no_nat) {
|
||||
toml += '\n# 是否关闭内置子网NAT,关闭后需要配置网卡转发,否则无法使用点对网\n';
|
||||
toml += '# 通常关闭内置子网NAT,使用系统的网卡转发,点对网性能会更好\n';
|
||||
toml += 'no_nat = true\n';
|
||||
}
|
||||
|
||||
if (formData.value.no_tun) {
|
||||
toml += '\n# 是否关闭TUN虚拟网卡,关闭后只能充当流量出口或者进行端口映射,关闭后无需管理员权限\n';
|
||||
toml += 'no_tun = true\n';
|
||||
}
|
||||
|
||||
const portMappings = formData.value.port_mapping.filter(s => s.trim());
|
||||
if (portMappings.length > 0) {
|
||||
toml += '\n# 端口映射,格式为:协议://本地监听地址-目标虚拟IP-目标映射地址\n';
|
||||
toml += '# 端口映射用于在本地监听指定端口,并将收到的网络流量经由指定虚拟节点转发到目标地址\n';
|
||||
toml += '# 例如: tcp://0.0.0.0:81-10.0.0.2-10.0.0.2:80 表示将本地tcp的81端口的数据转发到10.0.0.2:80\n';
|
||||
toml += '# 例如: tcp://0.0.0.0:81-10.0.0.2-192.168.1.10:80 则表示将本地tcp的81端口的数据经过10.0.0.2转到192.168.1.10:80\n';
|
||||
toml += '# 例如: tcp://0.0.0.0:81-10.0.0.2-anyonehost:80 则表示将本地tcp的81端口的数据经过10.0.0.2转到anyonehost:80\n';
|
||||
toml += `port_mapping = [${portMappings.map(s => `"${s}"`).join(', ')}]\n`;
|
||||
}
|
||||
|
||||
if (formData.value.allow_mapping) {
|
||||
toml += '\n# 是否允许作为端口映射出口,开启后其他设备才可使用本设备的ip为"目标虚拟IP"\n';
|
||||
toml += '# 开启后虚拟网络其他设备可以使用此设备当跳板访问其他网络\n';
|
||||
toml += 'allow_mapping = true\n';
|
||||
}
|
||||
|
||||
if (formData.value.mtu) {
|
||||
toml += '\n# MTU 设置\n';
|
||||
toml += `mtu = ${formData.value.mtu}\n`;
|
||||
}
|
||||
|
||||
toml += '\n# --- 设备配置 ---\n';
|
||||
if (formData.value.device_name) {
|
||||
toml += '\n# 设备名称 (可选,默认读取本机 hostname)\n';
|
||||
toml += `device_name = "${formData.value.device_name}"\n`;
|
||||
}
|
||||
if (formData.value.device_id) {
|
||||
toml += '\n# 设备 ID (可选,不填自动生成,不同设备ID不能相同)\n';
|
||||
toml += `device_id = "${formData.value.device_id}"\n`;
|
||||
}
|
||||
if (formData.value.tun_name) {
|
||||
toml += '\n# 虚拟网卡名称\n';
|
||||
toml += `tun_name = "${formData.value.tun_name}"\n`;
|
||||
}
|
||||
|
||||
toml += '\n# --- 安全配置 ---\n';
|
||||
if (formData.value.password) {
|
||||
toml += '\n# 组网加密密码 (可选)\n';
|
||||
toml += `password = "${formData.value.password}"\n`;
|
||||
}
|
||||
if (formData.value.cert_mode && formData.value.cert_mode !== 'skip') {
|
||||
toml += '\n# 证书校验方式:\n';
|
||||
toml += '# skip 跳过验证(默认)\n';
|
||||
toml += '# standard 使用系统证书验证\n';
|
||||
toml += '# finger 使用证书指纹验证,服务端启动时日志会输出指纹\n';
|
||||
if (formData.value.cert_mode === 'finger' && formData.value.fingerprint) {
|
||||
toml += `cert_mode = "finger:${formData.value.fingerprint}"\n`;
|
||||
} else {
|
||||
toml += `cert_mode = "${formData.value.cert_mode}"\n`;
|
||||
}
|
||||
}
|
||||
|
||||
const udpStuns = formData.value.udp_stun.filter(s => s.trim());
|
||||
if (udpStuns.length > 0) {
|
||||
toml += '\n# 自定义UDP STUN地址,不设置则用默认stun\n';
|
||||
toml += `udp_stun = [${udpStuns.map(s => `"${s}"`).join(', ')}]\n`;
|
||||
}
|
||||
|
||||
const tcpStuns = formData.value.tcp_stun.filter(s => s.trim());
|
||||
if (tcpStuns.length > 0) {
|
||||
toml += '\n# 自定义TCP STUN地址,不设置则用默认stun\n';
|
||||
toml += `tcp_stun = [${tcpStuns.map(s => `"${s}"`).join(', ')}]\n`;
|
||||
}
|
||||
|
||||
return toml;
|
||||
};
|
||||
|
||||
// 切换到表单模式
|
||||
const switchToFormMode = () => {
|
||||
if (editMode.value === 'toml') {
|
||||
editMode.value = 'form';
|
||||
// 从TOML解析到表单
|
||||
isParsingToml.value = true;
|
||||
formData.value = parseTomlToForm(editorContent.value);
|
||||
nextTick(() => {
|
||||
isParsingToml.value = false;
|
||||
});
|
||||
} else {
|
||||
editMode.value = 'form';
|
||||
}
|
||||
};
|
||||
|
||||
// 切换到TOML模式
|
||||
const switchToTomlMode = () => {
|
||||
if (editMode.value === 'form') {
|
||||
// 如果表单被修改过,生成新的TOML
|
||||
if (hasFormChanges.value) {
|
||||
editorContent.value = formToToml();
|
||||
// 重置标记,因为表单修改已经应用到TOML了
|
||||
hasFormChanges.value = false;
|
||||
} else if (originalToml.value && !hasTomlChanges.value) {
|
||||
// 如果表单没被修改,且TOML也没被修改,使用原始TOML(保留用户注释)
|
||||
editorContent.value = originalToml.value;
|
||||
} else {
|
||||
// 其他情况生成新的TOML
|
||||
editorContent.value = formToToml();
|
||||
}
|
||||
}
|
||||
editMode.value = 'toml';
|
||||
};
|
||||
|
||||
// 监听TOML内容变化(只在TOML模式下)
|
||||
watch(editorContent, (newVal, oldVal) => {
|
||||
if (editMode.value === 'toml' && oldVal !== undefined) {
|
||||
hasTomlChanges.value = true;
|
||||
}
|
||||
});
|
||||
|
||||
// 监听表单数据变化
|
||||
watch(formData, () => {
|
||||
if (editMode.value === 'form' && showEditor.value && !isParsingToml.value) {
|
||||
hasFormChanges.value = true;
|
||||
}
|
||||
}, { deep: true });
|
||||
|
||||
const openEditor = async (fileName) => {
|
||||
editorFileName.value = fileName || "";
|
||||
editorMode.value = fileName ? "edit" : "new";
|
||||
editMode.value = "form"; // 默认表单模式
|
||||
hasTomlChanges.value = false; // 重置TOML修改标记
|
||||
hasFormChanges.value = false; // 重置表单修改标记
|
||||
|
||||
if (fileName) {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${API_BASE}/api/config?file_name=${fileName}`,
|
||||
);
|
||||
const json = await res.json();
|
||||
if (json.code === 0) {
|
||||
editorContent.value = json.data;
|
||||
originalToml.value = json.data; // 保存原始TOML
|
||||
isParsingToml.value = true;
|
||||
formData.value = parseTomlToForm(json.data);
|
||||
nextTick(() => {
|
||||
isParsingToml.value = false;
|
||||
});
|
||||
showEditor.value = true;
|
||||
} else alert("获取配置失败: " + json.msg);
|
||||
} catch (e) {
|
||||
alert("网络错误");
|
||||
}
|
||||
} else {
|
||||
// 新建配置,初始化表单
|
||||
originalToml.value = ""; // 新建时清空原始TOML
|
||||
formData.value = {
|
||||
config_name: "",
|
||||
network_code: "",
|
||||
server: [""],
|
||||
ip: "",
|
||||
mtu: null,
|
||||
rtx: false,
|
||||
fec: false,
|
||||
compress: false,
|
||||
no_punch: false,
|
||||
input: [],
|
||||
output: [],
|
||||
no_nat: false,
|
||||
no_tun: false,
|
||||
port_mapping: [],
|
||||
allow_mapping: false,
|
||||
device_name: "",
|
||||
device_id: "",
|
||||
tun_name: "",
|
||||
password: "",
|
||||
cert_mode: "skip",
|
||||
fingerprint: "",
|
||||
udp_stun: [],
|
||||
tcp_stun: [],
|
||||
tunnel_port: null
|
||||
};
|
||||
editorContent.value = `# config_name = "配置名称"
|
||||
# --- 网络配置 ---
|
||||
# 网络编号,相同网络编号的会组在同一个虚拟网 (必填)
|
||||
network_code = "your_network_code"
|
||||
|
||||
# 服务器地址列表(支持 quic / tcp / wss / dynamic) (必填)
|
||||
# dynamic 协议使用dns txt解析记录值
|
||||
server = ["quic://1.2.3.4:29872"]
|
||||
|
||||
# ===简单使用以下参数可以不动===
|
||||
|
||||
# 自定义虚拟 IP (可选)
|
||||
# ip = "10.10.0.2"
|
||||
|
||||
# 是否启用quic优化传输 (默认 false,设置为true时开启)
|
||||
# 开启后传输过程几乎不会丢包,但是延迟会有波动
|
||||
# rtx = false
|
||||
|
||||
# 是否启用 FEC 前向纠错,损失一定带宽来提升网络稳定性(默认 false,设置为true时开启)
|
||||
# 开启后可以减少丢包率,损失带宽但是延迟比较稳定,带宽充足时可以使用此功能
|
||||
# fec = false
|
||||
|
||||
# 是否关闭 P2P 打洞 (默认 false,设置为true时关闭)
|
||||
# no_punch = false
|
||||
|
||||
# 是否启用 LZ4 压缩 (默认 false,设置为true时开启)
|
||||
# compress = false
|
||||
|
||||
# 入栈监听网段 (逗号分隔的 CIDR 和目标 IP),用于点对网,将指定网段的流量发送到目标节点
|
||||
# input = ["192.168.0.0/24,10.26.0.2", "192.168.1.0/24,10.26.0.3"]
|
||||
|
||||
# 出栈允许网段,用于点对网,允许指定网段的转发
|
||||
# output = ["0.0.0.0/0"]
|
||||
|
||||
# 是否关闭内置子网NAT,关闭(设为true)后需要配置网卡转发,否则无法使用点对网。通常关闭内置子网NAT,使用系统的网卡转发,点对网性能会更好
|
||||
# no_nat = false
|
||||
|
||||
# 是否关闭TUN虚拟网卡,关闭(设为true)后只能充当流量出口或者进行端口映射,关闭后无需管理员权限
|
||||
# no_tun = false
|
||||
|
||||
# 端口映射,格式为:协议://本地监听地址-目标虚拟IP-目标映射地址
|
||||
# 端口映射用于在本地监听指定端口,并将收到的网络流量经由指定虚拟节点转发到目标地址,从而实现跨网络或内网服务访问
|
||||
# 例如 port_mapping = ["tcp://0.0.0.0:81-10.0.0.2-10.0.0.2:80"]
|
||||
# tcp://0.0.0.0:81-10.0.0.2-10.0.0.2:80 则表示将本地tcp的81端口的数据转发到10.0.0.2:80
|
||||
# tcp://0.0.0.0:81-10.0.0.2-192.168.1.10:80 则表示将本地tcp的81端口的数据经过10.0.0.2转到192.168.1.10:80
|
||||
# tcp://0.0.0.0:81-10.0.0.2-anyonehost:80 则表示将本地tcp的81端口的数据经过10.0.0.2转到anyonehost:80
|
||||
# port_mapping = []
|
||||
|
||||
# 是否允许作为端口映射出口,开启(设置为true)后其他设备才可使用本设备的ip为"目标虚拟IP"
|
||||
# 开启后虚拟网络其他设备可以使用此设备当跳板访问其他网络
|
||||
# allow_mapping = false
|
||||
|
||||
# MTU 设置
|
||||
# mtu = 1400
|
||||
|
||||
# --- 设备配置 ---
|
||||
|
||||
# 设备名称 (可选,默认读取本机 hostname)
|
||||
# device_name = "my-device"
|
||||
|
||||
# 设备 ID (可选,不填自动生成,不同设备ID不能相同)
|
||||
# device_id = "device-id-xxxx"
|
||||
|
||||
# 虚拟网卡名称
|
||||
# tun_name = "vnt-tun"
|
||||
|
||||
# --- 安全配置 ---
|
||||
|
||||
# 加密密码 (可选)
|
||||
# password = "123456"
|
||||
|
||||
# 证书校验方式:
|
||||
# skip 跳过验证(默认)
|
||||
# standard 使用系统证书验证
|
||||
# finger 使用证书指纹验证,服务端启动时日志会输出指纹,
|
||||
# 例如 finger:3bdd8675606837cdf95d5e13445606315762315a78555f9da652940a25feaec1
|
||||
# cert_mode = "skip"
|
||||
|
||||
# --- 其他配置 ---
|
||||
# 自定义stun地址,分别用于udp打洞和tcp打洞,需要单独配置,不设置则用默认stun
|
||||
# udp_stun = ["stun.chat.bilibili.com"]
|
||||
# tcp_stun = ["stun.nextcloud.com:443"]`;
|
||||
showEditor.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
const saveConfig = async () => {
|
||||
try {
|
||||
// 如果是表单模式,先转换为TOML
|
||||
let configContent = editorContent.value;
|
||||
if (editMode.value === 'form') {
|
||||
// 验证必填项
|
||||
if (!formData.value.network_code.trim()) {
|
||||
alert('请填写网络编号');
|
||||
return;
|
||||
}
|
||||
const servers = formData.value.server.filter(s => s.trim());
|
||||
if (servers.length === 0) {
|
||||
alert('请至少填写一个服务器地址');
|
||||
return;
|
||||
}
|
||||
configContent = formToToml();
|
||||
}
|
||||
|
||||
const payload = {
|
||||
file_name: editorFileName.value || null,
|
||||
config: configContent,
|
||||
};
|
||||
const res = await fetch(`${API_BASE}/api/config`, {
|
||||
method: "POST",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (json.code === 0) {
|
||||
showEditor.value = false;
|
||||
fetchConfigList();
|
||||
} else alert("保存失败: " + json.msg);
|
||||
} catch (e) {
|
||||
alert("保存失败: " + e.message);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteConfig = async (fileName) => {
|
||||
if (!confirm(`确定要删除配置 ${fileName} 吗?`)) return;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${API_BASE}/api/config?file_name=${fileName}`,
|
||||
{method: "DELETE"},
|
||||
);
|
||||
const json = await res.json();
|
||||
if (json.code === 0) fetchConfigList();
|
||||
else alert(json.msg);
|
||||
} catch (e) {
|
||||
alert(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
instStatus,
|
||||
configList,
|
||||
showEditor,
|
||||
editorContent,
|
||||
editorFileName,
|
||||
editorMode,
|
||||
editMode,
|
||||
formData,
|
||||
openEditor,
|
||||
saveConfig,
|
||||
deleteConfig,
|
||||
switchToFormMode,
|
||||
switchToTomlMode,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const PeersView = {
|
||||
template: "#tpl-peers",
|
||||
setup() {
|
||||
const peers = ref([]);
|
||||
const instanceList = inject("instanceList");
|
||||
const selectedInstance = inject("selectedInstance");
|
||||
const isPageVisible = inject("isPageVisible");
|
||||
const showTooltipGlobal = inject("showPeerTooltip");
|
||||
const hideTooltipGlobal = inject("hidePeerTooltip");
|
||||
let timer = null;
|
||||
// 记录上次流量数据和时间,用于前端计算网速
|
||||
let lastTrafficMap = {};
|
||||
let lastFetchTime = 0;
|
||||
// 展开状态和网速历史
|
||||
const expandedPeers = reactive({});
|
||||
const speedHistoryMap = {};
|
||||
const HISTORY_SIZE = 60;
|
||||
|
||||
const toggleExpand = (ip) => {
|
||||
expandedPeers[ip] = !expandedPeers[ip];
|
||||
if (expandedPeers[ip]) {
|
||||
nextTick(() => drawChart(ip));
|
||||
}
|
||||
};
|
||||
|
||||
const drawChart = (ip) => {
|
||||
const canvasId = 'chart-' + ip.replaceAll('.', '-');
|
||||
const canvas = document.getElementById(canvasId);
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
const history = speedHistoryMap[ip];
|
||||
const txArr = history ? history.tx : [];
|
||||
const rxArr = history ? history.rx : [];
|
||||
|
||||
// 高清适配
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
canvas.width = rect.width * dpr;
|
||||
canvas.height = rect.height * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
const w = rect.width;
|
||||
const h = rect.height;
|
||||
|
||||
const padTop = 8, padBottom = 4, padLeft = 0, padRight = 0;
|
||||
const chartW = w - padLeft - padRight;
|
||||
const chartH = h - padTop - padBottom;
|
||||
|
||||
// 背景
|
||||
ctx.fillStyle = '#0c1222';
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
||||
// 计算Y轴最大值
|
||||
const allValues = [...txArr, ...rxArr];
|
||||
let maxVal = allValues.length > 0 ? Math.max(...allValues) : 0;
|
||||
if (maxVal < 1024) maxVal = 1024; // 最小1KB
|
||||
// 向上取整到合适的刻度
|
||||
const niceMax = niceNumber(maxVal);
|
||||
|
||||
// 更新最大值标签
|
||||
const maxLabel = document.getElementById('chart-max-' + ip.replaceAll('.', '-'));
|
||||
if (maxLabel) maxLabel.textContent = '峰值: ' + formatSpeed(niceMax);
|
||||
|
||||
// 网格线
|
||||
const gridLines = 4;
|
||||
ctx.strokeStyle = 'rgba(71, 85, 105, 0.3)';
|
||||
ctx.lineWidth = 1;
|
||||
for (let i = 0; i <= gridLines; i++) {
|
||||
const y = padTop + (chartH / gridLines) * i;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(padLeft, y);
|
||||
ctx.lineTo(padLeft + chartW, y);
|
||||
ctx.stroke();
|
||||
}
|
||||
// 垂直网格线
|
||||
const vLines = 6;
|
||||
for (let i = 0; i <= vLines; i++) {
|
||||
const x = padLeft + (chartW / vLines) * i;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, padTop);
|
||||
ctx.lineTo(x, padTop + chartH);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// 绘制曲线
|
||||
const drawLine = (data, strokeColor, fillColor) => {
|
||||
if (data.length < 2) return;
|
||||
const step = chartW / (HISTORY_SIZE - 1);
|
||||
const offset = HISTORY_SIZE - data.length;
|
||||
|
||||
// 填充区域
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(padLeft + offset * step, padTop + chartH);
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const x = padLeft + (offset + i) * step;
|
||||
const y = padTop + chartH - (data[i] / niceMax) * chartH;
|
||||
if (i === 0) ctx.lineTo(x, y);
|
||||
else ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.lineTo(padLeft + (offset + data.length - 1) * step, padTop + chartH);
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = fillColor;
|
||||
ctx.fill();
|
||||
|
||||
// 线条
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const x = padLeft + (offset + i) * step;
|
||||
const y = padTop + chartH - (data[i] / niceMax) * chartH;
|
||||
if (i === 0) ctx.moveTo(x, y);
|
||||
else ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.strokeStyle = strokeColor;
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.stroke();
|
||||
};
|
||||
|
||||
drawLine(rxArr, '#60a5fa', 'rgba(96, 165, 250, 0.15)');
|
||||
drawLine(txArr, '#4ade80', 'rgba(74, 222, 128, 0.15)');
|
||||
};
|
||||
|
||||
// 将数值取整到适合的刻度
|
||||
const niceNumber = (val) => {
|
||||
const units = [
|
||||
1024, // 1KB
|
||||
10 * 1024, // 10KB
|
||||
100 * 1024, // 100KB
|
||||
1024 * 1024, // 1MB
|
||||
10 * 1024 * 1024, // 10MB
|
||||
100 * 1024 * 1024,// 100MB
|
||||
1024 * 1024 * 1024,// 1GB
|
||||
];
|
||||
for (const u of units) {
|
||||
if (val <= u) return u;
|
||||
}
|
||||
return Math.ceil(val / (1024 * 1024 * 1024)) * 1024 * 1024 * 1024;
|
||||
};
|
||||
|
||||
const currentStatus = () => {
|
||||
const inst = instanceList.value.find(
|
||||
(i) => i.file_name === selectedInstance.value,
|
||||
);
|
||||
return inst ? inst.status : null;
|
||||
};
|
||||
|
||||
const resetPeerState = () => {
|
||||
peers.value = [];
|
||||
lastTrafficMap = {};
|
||||
lastFetchTime = 0;
|
||||
for (const key in speedHistoryMap)
|
||||
delete speedHistoryMap[key];
|
||||
for (const key in expandedPeers) delete expandedPeers[key];
|
||||
};
|
||||
|
||||
const fetchPeers = async () => {
|
||||
if (
|
||||
!selectedInstance.value ||
|
||||
currentStatus() !== "running"
|
||||
) {
|
||||
resetPeerState();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${API_BASE}/api/peers?file_name=${encodeURIComponent(selectedInstance.value)}`,
|
||||
);
|
||||
const json = await res.json();
|
||||
if (json.code === 0) {
|
||||
const now = Date.now();
|
||||
const list = json.data || [];
|
||||
const elapsed = lastFetchTime > 0 ? (now - lastFetchTime) / 1000 : 0;
|
||||
const newTrafficMap = {};
|
||||
for (const peer of list) {
|
||||
if (peer.traffic) {
|
||||
const key = peer.ip;
|
||||
const prev = lastTrafficMap[key];
|
||||
if (prev && elapsed > 0) {
|
||||
const txDiff = Math.max(0, peer.traffic.tx_bytes - prev.tx_bytes);
|
||||
const rxDiff = Math.max(0, peer.traffic.rx_bytes - prev.rx_bytes);
|
||||
peer.traffic.tx_speed = Math.round(txDiff / elapsed);
|
||||
peer.traffic.rx_speed = Math.round(rxDiff / elapsed);
|
||||
} else {
|
||||
peer.traffic.tx_speed = 0;
|
||||
peer.traffic.rx_speed = 0;
|
||||
}
|
||||
newTrafficMap[key] = { tx_bytes: peer.traffic.tx_bytes, rx_bytes: peer.traffic.rx_bytes };
|
||||
// 记录速度历史
|
||||
if (!speedHistoryMap[key]) speedHistoryMap[key] = { tx: [], rx: [] };
|
||||
speedHistoryMap[key].tx.push(peer.traffic.tx_speed);
|
||||
speedHistoryMap[key].rx.push(peer.traffic.rx_speed);
|
||||
if (speedHistoryMap[key].tx.length > HISTORY_SIZE) {
|
||||
speedHistoryMap[key].tx.shift();
|
||||
speedHistoryMap[key].rx.shift();
|
||||
}
|
||||
}
|
||||
}
|
||||
lastTrafficMap = newTrafficMap;
|
||||
lastFetchTime = now;
|
||||
peers.value = list;
|
||||
// 重绘所有展开的图表
|
||||
nextTick(() => {
|
||||
for (const ip in expandedPeers) {
|
||||
if (expandedPeers[ip]) drawChart(ip);
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchPeers();
|
||||
timer = setInterval(() => {
|
||||
if (isPageVisible.value) fetchPeers();
|
||||
}, 3000);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timer) clearInterval(timer);
|
||||
});
|
||||
|
||||
// 切换实例时重置并重新拉取
|
||||
watch(selectedInstance, () => {
|
||||
resetPeerState();
|
||||
fetchPeers();
|
||||
});
|
||||
|
||||
// 监听选中实例状态变化,当变为 running 时立即获取数据
|
||||
watch(currentStatus, (newStatus) => {
|
||||
if (newStatus === "running") {
|
||||
fetchPeers();
|
||||
}
|
||||
});
|
||||
|
||||
const getRouteModeClass = (route) => {
|
||||
// route 包含 addr, protocol, metric, rtt
|
||||
// 判断是否直连:metric === 1
|
||||
const isDirect = route.metric === 1;
|
||||
|
||||
if (isDirect) {
|
||||
return 'text-purple-400 font-medium'; // 直连 - 紫色
|
||||
} else {
|
||||
return 'text-blue-400'; // 客户端中继 - 蓝色
|
||||
}
|
||||
};
|
||||
|
||||
const getRouteModeText = (route) => {
|
||||
const isDirect = route.metric === 1;
|
||||
const isTcp = route.protocol.includes('Tcp');
|
||||
|
||||
if (isDirect) {
|
||||
return isTcp ? '打洞TCP直连' : '打洞UDP直连';
|
||||
} else {
|
||||
return isTcp ? '客户端TCP中继' : '客户端UDP中继';
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
peers,
|
||||
instanceList,
|
||||
selectedInstance,
|
||||
expandedPeers,
|
||||
toggleExpand,
|
||||
formatTime,
|
||||
formatBytes,
|
||||
formatSpeed,
|
||||
getRouteModeClass,
|
||||
getRouteModeText,
|
||||
showPeerTooltip: showTooltipGlobal,
|
||||
hidePeerTooltip: hideTooltipGlobal,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const RoutesView = {
|
||||
template: "#tpl-routes",
|
||||
setup() {
|
||||
const routes = ref([]);
|
||||
const instanceList = inject("instanceList");
|
||||
const selectedInstance = inject("selectedInstance");
|
||||
const isPageVisible = inject("isPageVisible");
|
||||
let timer = null;
|
||||
|
||||
const currentStatus = () => {
|
||||
const inst = instanceList.value.find(
|
||||
(i) => i.file_name === selectedInstance.value,
|
||||
);
|
||||
return inst ? inst.status : null;
|
||||
};
|
||||
|
||||
const fetchRoutes = async () => {
|
||||
if (
|
||||
!selectedInstance.value ||
|
||||
currentStatus() !== "running"
|
||||
) {
|
||||
routes.value = [];
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${API_BASE}/api/routes?file_name=${encodeURIComponent(selectedInstance.value)}`,
|
||||
);
|
||||
const json = await res.json();
|
||||
if (json.code === 0) routes.value = json.data || [];
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchRoutes();
|
||||
timer = setInterval(() => {
|
||||
if (isPageVisible.value) fetchRoutes();
|
||||
}, 3000);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timer) clearInterval(timer);
|
||||
});
|
||||
|
||||
// 切换实例时重新拉取
|
||||
watch(selectedInstance, () => {
|
||||
fetchRoutes();
|
||||
});
|
||||
|
||||
// 监听选中实例状态变化,当变为 running 时立即获取数据
|
||||
watch(currentStatus, (newStatus) => {
|
||||
if (newStatus === "running") {
|
||||
fetchRoutes();
|
||||
}
|
||||
});
|
||||
|
||||
return {routes, instanceList, selectedInstance};
|
||||
},
|
||||
};
|
||||
|
||||
// --- 路由配置 ---
|
||||
|
||||
const routes = [
|
||||
{path: "/", redirect: "/general"},
|
||||
{path: "/general", component: GeneralView},
|
||||
{path: "/config", component: ConfigView},
|
||||
{path: "/peers", component: PeersView},
|
||||
{path: "/routes", component: RoutesView},
|
||||
];
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHashHistory(),
|
||||
routes,
|
||||
});
|
||||
|
||||
// --- 主应用 ---
|
||||
|
||||
createApp({
|
||||
setup() {
|
||||
// 多实例状态:key=file_name, value=该实例 info
|
||||
const instances = ref({});
|
||||
const instanceList = ref([]);
|
||||
const selectedInstance = ref(null);
|
||||
const configList = ref([]);
|
||||
const loadingMap = ref({});
|
||||
|
||||
// 启动日志相关
|
||||
const showStartLog = ref(false);
|
||||
const startLogs = ref([]);
|
||||
const startStatus = ref("stopped");
|
||||
const logFileName = ref(null);
|
||||
const logContainer = ref(null);
|
||||
let statusInterval = null;
|
||||
let infoTimer = null;
|
||||
|
||||
// Tooltip 相关
|
||||
const tooltipState = ref({
|
||||
show: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
info: null,
|
||||
});
|
||||
let tooltipHideTimer = null;
|
||||
const showPeerTooltip = (event, peer) => {
|
||||
if (!peer.nat_info) return;
|
||||
if (tooltipHideTimer) {
|
||||
clearTimeout(tooltipHideTimer);
|
||||
tooltipHideTimer = null;
|
||||
}
|
||||
const rect =
|
||||
event.currentTarget.getBoundingClientRect();
|
||||
tooltipState.value = {
|
||||
show: true,
|
||||
x: rect.left + rect.width / 2,
|
||||
y: rect.bottom + 10,
|
||||
info: peer.nat_info,
|
||||
};
|
||||
};
|
||||
const hidePeerTooltip = () => {
|
||||
tooltipHideTimer = setTimeout(() => {
|
||||
tooltipState.value.show = false;
|
||||
}, 100);
|
||||
};
|
||||
const onTooltipEnter = () => {
|
||||
if (tooltipHideTimer) {
|
||||
clearTimeout(tooltipHideTimer);
|
||||
tooltipHideTimer = null;
|
||||
}
|
||||
};
|
||||
const onTooltipLeave = () => {
|
||||
tooltipState.value.show = false;
|
||||
};
|
||||
|
||||
// 计算属性
|
||||
const runningCount = computed(
|
||||
() =>
|
||||
instanceList.value.filter(
|
||||
(i) => i.status === "running",
|
||||
).length,
|
||||
);
|
||||
const startingCount = computed(
|
||||
() =>
|
||||
instanceList.value.filter(
|
||||
(i) => i.status === "starting",
|
||||
).length,
|
||||
);
|
||||
const headerStatusText = computed(() => {
|
||||
if (runningCount.value > 0)
|
||||
return `运行中 x${runningCount.value}`;
|
||||
if (startingCount.value > 0) return "启动中...";
|
||||
return "未启动";
|
||||
});
|
||||
const selectedInfo = computed(() =>
|
||||
selectedInstance.value
|
||||
? instances.value[selectedInstance.value] || null
|
||||
: null,
|
||||
);
|
||||
const selectedConfigName = computed(() => {
|
||||
if (!selectedInstance.value) return "";
|
||||
const inst = instanceList.value.find(
|
||||
(i) => i.file_name === selectedInstance.value,
|
||||
);
|
||||
return inst
|
||||
? inst.config_name || inst.file_name
|
||||
: selectedInstance.value;
|
||||
});
|
||||
const version = computed(() => {
|
||||
for (const key in instances.value) {
|
||||
const item = instances.value[key];
|
||||
if (item && item.version) return item.version;
|
||||
}
|
||||
return "";
|
||||
});
|
||||
const logConfigName = computed(() => {
|
||||
if (!logFileName.value) return "";
|
||||
const inst = instanceList.value.find(
|
||||
(i) => i.file_name === logFileName.value,
|
||||
);
|
||||
if (inst) return inst.config_name || inst.file_name;
|
||||
const cfg = configList.value.find(
|
||||
(c) => c.file_name === logFileName.value,
|
||||
);
|
||||
return cfg
|
||||
? cfg.config_name || cfg.file_name
|
||||
: logFileName.value;
|
||||
});
|
||||
const isServerConnected = computed(
|
||||
() =>
|
||||
!!(
|
||||
selectedInfo.value &&
|
||||
selectedInfo.value.server_info &&
|
||||
selectedInfo.value.server_info.some((s) => s.connected)
|
||||
),
|
||||
);
|
||||
const serverStatusText = computed(() => {
|
||||
const si = selectedInfo.value;
|
||||
if (!si || !si.server_info || !si.server_info.length)
|
||||
return "未配置服务器";
|
||||
return `${si.server_info.filter((s) => s.connected).length} / ${si.server_info.length} 已连接`;
|
||||
});
|
||||
|
||||
// 基础 API
|
||||
const fetchInstanceInfo = async (fileName) => {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${API_BASE}/api/info?file_name=${encodeURIComponent(fileName)}`,
|
||||
);
|
||||
const json = await res.json();
|
||||
if (json.code === 0)
|
||||
instances.value[fileName] = json.data;
|
||||
} catch (e) {
|
||||
console.error("Fetch info error", e);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchInstances = async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/instances`);
|
||||
const json = await res.json();
|
||||
if (json.code !== 0) return;
|
||||
const list = json.data || [];
|
||||
instanceList.value = list;
|
||||
// 清理已消失实例的 info 缓存
|
||||
for (const key of Object.keys(instances.value)) {
|
||||
if (!list.some((i) => i.file_name === key)) {
|
||||
delete instances.value[key];
|
||||
}
|
||||
}
|
||||
// 默认选中第一个 running 实例
|
||||
if (
|
||||
!selectedInstance.value ||
|
||||
!list.some(
|
||||
(i) => i.file_name === selectedInstance.value,
|
||||
)
|
||||
) {
|
||||
const running = list.find(
|
||||
(i) => i.status === "running",
|
||||
);
|
||||
selectedInstance.value = running
|
||||
? running.file_name
|
||||
: list.length > 0
|
||||
? list[0].file_name
|
||||
: null;
|
||||
}
|
||||
// 拉取 running 实例的详情
|
||||
for (const inst of list) {
|
||||
if (inst.status === "running") {
|
||||
fetchInstanceInfo(inst.file_name);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Fetch instances error", e);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchConfigList = async () => {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${API_BASE}/api/config/list`,
|
||||
);
|
||||
const json = await res.json();
|
||||
if (json.code === 0) configList.value = json.data;
|
||||
} catch (e) {
|
||||
console.error("Fetch list error", e);
|
||||
}
|
||||
};
|
||||
|
||||
// 启动流程控制
|
||||
const pollStartStatus = async () => {
|
||||
if (!logFileName.value) return;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${API_BASE}/api/start/status?file_name=${encodeURIComponent(logFileName.value)}`,
|
||||
);
|
||||
const json = await res.json();
|
||||
if (json.code === 0) {
|
||||
startLogs.value = json.data.logs || [];
|
||||
startStatus.value = json.data.status;
|
||||
nextTick(() => {
|
||||
if (logContainer.value)
|
||||
logContainer.value.scrollTop =
|
||||
logContainer.value.scrollHeight;
|
||||
});
|
||||
|
||||
if (startStatus.value === "running") {
|
||||
stopPolling();
|
||||
fetchInstances();
|
||||
showStartLog.value = false;
|
||||
} else if (
|
||||
startStatus.value === "stopped" &&
|
||||
startLogs.value.length > 0
|
||||
) {
|
||||
stopPolling();
|
||||
fetchInstances();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
const stopPolling = () => {
|
||||
if (statusInterval) {
|
||||
clearInterval(statusInterval);
|
||||
statusInterval = null;
|
||||
}
|
||||
};
|
||||
const startPolling = () => {
|
||||
stopPolling();
|
||||
statusInterval = setInterval(pollStartStatus, 1000);
|
||||
pollStartStatus();
|
||||
};
|
||||
|
||||
const openStartLog = (fileName) => {
|
||||
logFileName.value = fileName;
|
||||
startLogs.value = [];
|
||||
startStatus.value = "starting";
|
||||
showStartLog.value = true;
|
||||
startPolling();
|
||||
};
|
||||
|
||||
const startVnt = async (fileName) => {
|
||||
if (!fileName) {
|
||||
alert("请先选择一个配置");
|
||||
return;
|
||||
}
|
||||
if (loadingMap.value[fileName]) return;
|
||||
loadingMap.value[fileName] = true;
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/start`, {
|
||||
method: "POST",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify({
|
||||
file_name: fileName,
|
||||
}),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (json.code !== 0) {
|
||||
alert("启动失败: " + json.msg);
|
||||
return;
|
||||
}
|
||||
openStartLog(fileName);
|
||||
fetchInstances();
|
||||
} catch (e) {
|
||||
alert("网络请求失败: " + e.message);
|
||||
} finally {
|
||||
loadingMap.value[fileName] = false;
|
||||
}
|
||||
};
|
||||
|
||||
const stopVnt = async (fileName) => {
|
||||
if (!fileName || loadingMap.value[fileName]) return;
|
||||
loadingMap.value[fileName] = true;
|
||||
try {
|
||||
await fetch(`${API_BASE}/api/stop`, {
|
||||
method: "POST",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify({
|
||||
file_name: fileName,
|
||||
}),
|
||||
});
|
||||
if (logFileName.value === fileName) {
|
||||
stopPolling();
|
||||
showStartLog.value = false;
|
||||
}
|
||||
fetchInstances();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
loadingMap.value[fileName] = false;
|
||||
}
|
||||
};
|
||||
|
||||
const cancelStart = async () => {
|
||||
stopPolling();
|
||||
const fileName = logFileName.value;
|
||||
if (fileName) {
|
||||
try {
|
||||
await fetch(`${API_BASE}/api/stop`, {
|
||||
method: "POST",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify({
|
||||
file_name: fileName,
|
||||
}),
|
||||
});
|
||||
startLogs.value.push("启动已手动取消");
|
||||
} catch (e) {
|
||||
}
|
||||
}
|
||||
startStatus.value = "stopped";
|
||||
fetchInstances();
|
||||
};
|
||||
|
||||
// 移除已停止(启动失败残留)的实例条目
|
||||
const dismissInstance = async (fileName) => {
|
||||
if (!fileName || loadingMap.value[fileName]) return;
|
||||
loadingMap.value[fileName] = true;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${API_BASE}/api/instance?file_name=${encodeURIComponent(fileName)}`,
|
||||
{method: "DELETE"},
|
||||
);
|
||||
const json = await res.json();
|
||||
if (json.code !== 0) {
|
||||
alert("移除失败: " + json.msg);
|
||||
return;
|
||||
}
|
||||
if (logFileName.value === fileName) {
|
||||
stopPolling();
|
||||
showStartLog.value = false;
|
||||
}
|
||||
if (selectedInstance.value === fileName) {
|
||||
selectedInstance.value = null;
|
||||
}
|
||||
fetchInstances();
|
||||
} catch (e) {
|
||||
alert("网络请求失败: " + e.message);
|
||||
} finally {
|
||||
loadingMap.value[fileName] = false;
|
||||
}
|
||||
};
|
||||
|
||||
const restartVnt = async (fileName) => {
|
||||
if (!fileName || loadingMap.value[fileName]) return;
|
||||
loadingMap.value[fileName] = true;
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/restart`, {
|
||||
method: "POST",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify({
|
||||
file_name: fileName,
|
||||
}),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (json.code !== 0) {
|
||||
alert("重启失败: " + json.msg);
|
||||
return;
|
||||
}
|
||||
openStartLog(fileName);
|
||||
fetchInstances();
|
||||
} catch (e) {
|
||||
alert("网络请求失败: " + e.message);
|
||||
} finally {
|
||||
loadingMap.value[fileName] = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 导航辅助
|
||||
const navClass = (isActive) =>
|
||||
isActive
|
||||
? "bg-blue-600 text-white shadow-lg shadow-blue-500/30"
|
||||
: "text-slate-400 hover:bg-slate-800 hover:text-white";
|
||||
|
||||
// 页面可见性处理
|
||||
const isPageVisible = ref(!document.hidden);
|
||||
const visibilityHandler = () => {
|
||||
isPageVisible.value = !document.hidden;
|
||||
};
|
||||
|
||||
// 依赖注入
|
||||
provide("instanceList", instanceList);
|
||||
provide("instances", instances);
|
||||
provide("selectedInstance", selectedInstance);
|
||||
provide("isPageVisible", isPageVisible);
|
||||
provide("configList", configList);
|
||||
provide("fetchConfigList", fetchConfigList);
|
||||
provide("startVnt", startVnt);
|
||||
provide("stopVnt", stopVnt);
|
||||
provide("restartVnt", restartVnt);
|
||||
provide("dismissInstance", dismissInstance);
|
||||
provide("openStartLog", openStartLog);
|
||||
provide("loadingMap", loadingMap);
|
||||
provide("showPeerTooltip", showPeerTooltip);
|
||||
provide("hidePeerTooltip", hidePeerTooltip);
|
||||
|
||||
onMounted(async () => {
|
||||
document.addEventListener(
|
||||
"visibilitychange",
|
||||
visibilityHandler,
|
||||
);
|
||||
await fetchInstances();
|
||||
fetchConfigList();
|
||||
// 页面加载时若有正在启动的实例,恢复其日志弹窗
|
||||
const starting = instanceList.value.find(
|
||||
(i) => i.status === "starting",
|
||||
);
|
||||
if (starting) openStartLog(starting.file_name);
|
||||
|
||||
// 全局轮询实例列表及 running 实例详情
|
||||
infoTimer = setInterval(() => {
|
||||
if (isPageVisible.value) fetchInstances();
|
||||
}, 3000);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener(
|
||||
"visibilitychange",
|
||||
visibilityHandler,
|
||||
);
|
||||
stopPolling();
|
||||
if (infoTimer) clearInterval(infoTimer);
|
||||
});
|
||||
|
||||
return {
|
||||
version,
|
||||
runningCount,
|
||||
startingCount,
|
||||
headerStatusText,
|
||||
selectedInfo,
|
||||
selectedConfigName,
|
||||
isServerConnected,
|
||||
serverStatusText,
|
||||
navClass,
|
||||
showStartLog,
|
||||
startStatus,
|
||||
startLogs,
|
||||
logFileName,
|
||||
logConfigName,
|
||||
logContainer,
|
||||
cancelStart,
|
||||
tooltipState,
|
||||
onTooltipEnter,
|
||||
onTooltipLeave,
|
||||
};
|
||||
},
|
||||
})
|
||||
.use(router)
|
||||
.mount("#app");
|
||||
</script>
|
||||
</body>
|
||||
<link
|
||||
rel="icon"
|
||||
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect x='0' y='0' width='64' height='64' rx='16' fill='%23ffffff' stroke='%23e2e8f0' stroke-width='2'/%3E%3Cpath d='M16 20 L32 48 L48 20' fill='none' stroke='%234f46e5' stroke-width='6' stroke-linecap='round' stroke-linejoin='round'/%3E%3Ccircle cx='16' cy='20' r='6' fill='%236366f1'/%3E%3Ccircle cx='48' cy='20' r='6' fill='%236366f1'/%3E%3Ccircle cx='32' cy='48' r='6' fill='%2322c55e'/%3E%3C/svg%3E"
|
||||
type="image/svg+xml"
|
||||
/>
|
||||
<script>
|
||||
// 首屏前应用主题,避免闪烁
|
||||
(function () {
|
||||
var t = localStorage.getItem("vnt-theme");
|
||||
var dark = t
|
||||
? t === "dark"
|
||||
: window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
if (dark) document.documentElement.classList.add("dark");
|
||||
})();
|
||||
</script>
|
||||
<script type="module" crossorigin src="/assets/index-DHCLVx7I.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DVj6b74x.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,27 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>VNT Dashboard</title>
|
||||
<link
|
||||
rel="icon"
|
||||
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect x='0' y='0' width='64' height='64' rx='16' fill='%23ffffff' stroke='%23e2e8f0' stroke-width='2'/%3E%3Cpath d='M16 20 L32 48 L48 20' fill='none' stroke='%234f46e5' stroke-width='6' stroke-linecap='round' stroke-linejoin='round'/%3E%3Ccircle cx='16' cy='20' r='6' fill='%236366f1'/%3E%3Ccircle cx='48' cy='20' r='6' fill='%236366f1'/%3E%3Ccircle cx='32' cy='48' r='6' fill='%2322c55e'/%3E%3C/svg%3E"
|
||||
type="image/svg+xml"
|
||||
/>
|
||||
<script>
|
||||
// 首屏前应用主题,避免闪烁
|
||||
(function () {
|
||||
var t = localStorage.getItem("vnt-theme");
|
||||
var dark = t
|
||||
? t === "dark"
|
||||
: window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
if (dark) document.documentElement.classList.add("dark");
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "vnt-web-ui",
|
||||
"private": true,
|
||||
"version": "2.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build"
|
||||
},
|
||||
"dependencies": {
|
||||
"pinia": "^3.0.3",
|
||||
"vue": "^3.5.18",
|
||||
"vue-router": "^4.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.1.12",
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"tailwindcss": "^4.1.12",
|
||||
"vite": "^7.1.3"
|
||||
}
|
||||
}
|
||||
Generated
+1312
@@ -0,0 +1,1312 @@
|
||||
lockfileVersion: '9.0'
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
pinia:
|
||||
specifier: ^3.0.3
|
||||
version: 3.0.4([email protected])
|
||||
vue:
|
||||
specifier: ^3.5.18
|
||||
version: 3.5.41
|
||||
vue-router:
|
||||
specifier: ^4.5.1
|
||||
version: 4.6.4([email protected])
|
||||
devDependencies:
|
||||
'@tailwindcss/vite':
|
||||
specifier: ^4.1.12
|
||||
version: 4.3.3([email protected]([email protected])([email protected]))
|
||||
'@vitejs/plugin-vue':
|
||||
specifier: ^6.0.1
|
||||
version: 6.0.8([email protected]([email protected])([email protected]))([email protected])
|
||||
tailwindcss:
|
||||
specifier: ^4.1.12
|
||||
version: 4.3.3
|
||||
vite:
|
||||
specifier: ^7.1.3
|
||||
version: 7.3.6([email protected])([email protected])
|
||||
|
||||
packages:
|
||||
|
||||
'@babel/[email protected]':
|
||||
resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/[email protected]':
|
||||
resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/[email protected]':
|
||||
resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
hasBin: true
|
||||
|
||||
'@babel/[email protected]':
|
||||
resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ppc64]
|
||||
os: [aix]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [freebsd]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ia32]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [mips64el]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [netbsd]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [netbsd]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [openbsd]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [openbsd]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [openharmony]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [sunos]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@jridgewell/[email protected]':
|
||||
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
|
||||
|
||||
'@jridgewell/[email protected]':
|
||||
resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==}
|
||||
|
||||
'@jridgewell/[email protected]':
|
||||
resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
|
||||
'@jridgewell/[email protected]':
|
||||
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
|
||||
|
||||
'@jridgewell/[email protected]':
|
||||
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
||||
|
||||
'@napi-rs/[email protected]':
|
||||
resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==}
|
||||
engines: {node: ^22.20 || ^24.12 || >=25}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/[email protected]':
|
||||
resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==}
|
||||
|
||||
'@rollup/[email protected]':
|
||||
resolution: {integrity: sha512-jfkGfTwhQpsiSckPF8r9bU3pn3vyd72NlWaO+TgEO6WPSDnUhXzrNYCHBMOYj0ACaUgjm6eERLF+XV9a6RstoA==}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@rollup/[email protected]':
|
||||
resolution: {integrity: sha512-oGVqyQlxnrz9/ty89oHpU857VUHEl5/Xu4R2lS+aivCTrNnSsbiENzTnNaBsjxH0CNWGPhzHArOLFwo+oKXveA==}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@rollup/[email protected]':
|
||||
resolution: {integrity: sha512-bW7B8xMEq8n99Q3ieEcPRGuphurdZAaFzQc9Efyyw3FL6DZO6pMy9xhdN+kBoD7Sy05xNXSr4OyPPnpkYriS/A==}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@rollup/[email protected]':
|
||||
resolution: {integrity: sha512-YSwBS86QeHOGlrxJ1PSOIZSkzRL/JmKeunhc+lV6M1a6En8QuVCD/T/qIA0J4Gd2Y86RIOBYrLcOUtqGh9+/1w==}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@rollup/[email protected]':
|
||||
resolution: {integrity: sha512-2fST8lILgl7cKbme/1KDdPCmbXbG+gqoV3bHp19L0ypX/3akYMBVdOunPleRCwonoLnXOZ/0F+Mt/v8POFmfcQ==}
|
||||
cpu: [arm64]
|
||||
os: [freebsd]
|
||||
|
||||
'@rollup/[email protected]':
|
||||
resolution: {integrity: sha512-cpIxQCP9J+EVad0a6LO1kY3ZGODlk80VlI+2I96B8xMcdHZ4pLVhfQ49JFpYqjPF91FFkQWftf57YlDcTiw9yQ==}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@rollup/[email protected]':
|
||||
resolution: {integrity: sha512-r9fGh3eFs3e/udWh5ZjXQtxiYK/xoFxQaYR/cELxac/Udkl5Th+IsFm0CX3Kl9hmUH/we7EoMpjJgeQNnE0+IA==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/[email protected]':
|
||||
resolution: {integrity: sha512-xdvFdp7OM6KLJviJT2g/YuRSUjnZgGHk4RNgwIbN7X6cPugOucV60DdHXWzsBVCUdrGb6qSXnJQrrAKMmQuj3Q==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/[email protected]':
|
||||
resolution: {integrity: sha512-rRqILAndyzHzP7T9NFQrq+4HFWNhqkqkKur7eiBpfLmz01PO0JKx5Vchu3YllE4YXI/Ftgq/szrDWg5GJ0mI8g==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/[email protected]':
|
||||
resolution: {integrity: sha512-Gf4X3qVMucayUvux6aXXPgXovocSFUC0rrffDuPI/S2nHhNMhjcZxsrAFYCOF350PRreW1XwzFj3CT/3bKsWCw==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/[email protected]':
|
||||
resolution: {integrity: sha512-+s5qA0TNM0qm8PK/a5gt/1Hpx+NV08uSuCncvhziIlQzT6AEV2fnUQo7eBtFTFO0nA9scauvoR2HusfXmQnO4w==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/[email protected]':
|
||||
resolution: {integrity: sha512-ybb6QvWwWJCbBWqERpc8K3pYVGIrXlG8MEQ8IIuJY6Y9KdHQxoFoNyfkAOtKn1VHu3KuLidXvwrvGR1mEjeWCw==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/[email protected]':
|
||||
resolution: {integrity: sha512-nZb1DtnOyhCmYvsC8A2CwOkopVg+IS1+fPUa7rMOAXtNw5+lLCLLPqd6XAiNrGtoQKsbvIBOwsHnBH/3wnb4HQ==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/[email protected]':
|
||||
resolution: {integrity: sha512-yMbj63Sp89ryrXLWyz+sy+fYD2HpOnMCLGbe4Oa1smclFSUukdtD/BgdiHaAetJNb74URD8U4hM+qG5KVzMEkg==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/[email protected]':
|
||||
resolution: {integrity: sha512-mhoan3OJw2kYV/e1jtIdmvUZgyBFeA6zGWsOswmR0Tg19TQbowZuR+JMLID6spbbBN7Zee2ejrgmy3+FxGrIdA==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/[email protected]':
|
||||
resolution: {integrity: sha512-5ZTLmjWbb1VZdjuyhe83K/8QO0/h11midQCBP+X5OYn32ra7eOBoM0ZqtaY4nkgNsYgmdVhMYPoyVPTjUpHf3w==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/[email protected]':
|
||||
resolution: {integrity: sha512-m53kG+br6PGxOTmgBEM2DHSDs9RVjsyEbUwjJPJGTFm1grWOG8EKJggDCTb60unD4Tjby8fi7/m9XfkEWasVWg==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/[email protected]':
|
||||
resolution: {integrity: sha512-6RHPJR1g/uvdYU8uXBnfq3nlqyZCP82Fr6NHgfGoaIeSh0YEqnX/x6uA9MmJJbnSH7swqX4F+CkGdUF+6doiQA==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/[email protected]':
|
||||
resolution: {integrity: sha512-xs+OXQtEXgpXT0DmA5+U3qnRZHdCST/5HRQxS8wSPZTUZN/EMWeHuSIod32LQklTBZBV9DyfncKBQ8n5V3eFdw==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/[email protected]':
|
||||
resolution: {integrity: sha512-e7hD+sl3s+mcLQDZ8pbudBVsdG6r5yN4w3LqG2TJ8sQHDpblWj5lrJs/3m01Cvlxbt4x13zu5thLjgypgtkYzw==}
|
||||
cpu: [x64]
|
||||
os: [openbsd]
|
||||
|
||||
'@rollup/[email protected]':
|
||||
resolution: {integrity: sha512-GiyJaCf+WpMub/17aPcKk27QMl5W6f+KhdPTjlFOn5akH5Wa/DCM9Stdx5cDfmasyKB08MqpVQ1uJE2RkkpbXg==}
|
||||
cpu: [arm64]
|
||||
os: [openharmony]
|
||||
|
||||
'@rollup/[email protected]':
|
||||
resolution: {integrity: sha512-+OQ8U2DdoEfXl8T4Fb18AjmEwbXMerKDKCL8yCPAYhKCEEKoul7rkbeGCBFCbAlaGaa7pmtRTpkAJM2LE/i5FA==}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@rollup/[email protected]':
|
||||
resolution: {integrity: sha512-KanvAZrPKbDBFwrgiU9yEVpQoox9QPV1WZOXX7HudJQY+eSlu82CtWxDU8WtuRRvtN5EGkLczkd6Y6DTcvm9wA==}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@rollup/[email protected]':
|
||||
resolution: {integrity: sha512-1aC3UEWTtRl3RK3VpDJ/Tqk1XI4SLTmXIthAq6wRWo8XiSXJNd+VprJM4/1P4+i6HIaFEFlVi9sTTziniD2tOQ==}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@rollup/[email protected]':
|
||||
resolution: {integrity: sha512-/gDJaRs4gl0NPIwqCz+6PkpmhhjRAD2j6P4rSNHBzUkO3naEx2mIU0pRle1vUNRQ7mE/+8OOeXLTv/J56FKiQg==}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@tailwindcss/[email protected]':
|
||||
resolution: {integrity: sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==}
|
||||
|
||||
'@tailwindcss/[email protected]':
|
||||
resolution: {integrity: sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@tailwindcss/[email protected]':
|
||||
resolution: {integrity: sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@tailwindcss/[email protected]':
|
||||
resolution: {integrity: sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@tailwindcss/[email protected]':
|
||||
resolution: {integrity: sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@tailwindcss/[email protected]':
|
||||
resolution: {integrity: sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@tailwindcss/[email protected]':
|
||||
resolution: {integrity: sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@tailwindcss/[email protected]':
|
||||
resolution: {integrity: sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@tailwindcss/[email protected]':
|
||||
resolution: {integrity: sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@tailwindcss/[email protected]':
|
||||
resolution: {integrity: sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@tailwindcss/[email protected]':
|
||||
resolution: {integrity: sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
cpu: [wasm32]
|
||||
bundledDependencies:
|
||||
- '@napi-rs/wasm-runtime'
|
||||
- '@emnapi/core'
|
||||
- '@emnapi/runtime'
|
||||
- '@tybys/wasm-util'
|
||||
- '@emnapi/wasi-threads'
|
||||
- tslib
|
||||
|
||||
'@tailwindcss/[email protected]':
|
||||
resolution: {integrity: sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@tailwindcss/[email protected]':
|
||||
resolution: {integrity: sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@tailwindcss/[email protected]':
|
||||
resolution: {integrity: sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==}
|
||||
engines: {node: '>= 20'}
|
||||
|
||||
'@tailwindcss/[email protected]':
|
||||
resolution: {integrity: sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==}
|
||||
peerDependencies:
|
||||
vite: ^5.2.0 || ^6 || ^7 || ^8
|
||||
|
||||
'@types/[email protected]':
|
||||
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
|
||||
|
||||
'@vitejs/[email protected]':
|
||||
resolution: {integrity: sha512-0ZjgOg7oO6farnNGup7yvoM/YXZV84OZxHAwtflItNa/6zzQyVb5LNxyea3FEKEX2XlagIKzrlH7wwxkKgtiew==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
peerDependencies:
|
||||
vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0
|
||||
vue: ^3.2.25
|
||||
|
||||
'@vue/[email protected]':
|
||||
resolution: {integrity: sha512-q0Xtv/F9w2YO/7htQhtiL+Ev2WCJbe5N2hc+XfgyKkEKqWpSxknmT8QOuGdEKNdjPq0c3F7rNpFkTo3Kfrm7pg==}
|
||||
|
||||
'@vue/[email protected]':
|
||||
resolution: {integrity: sha512-oKacVfNglLvGjnS6BXOlGL7EyG2h8X03pqXCjzotRZUaXGjbrTJUnVAQjrCqUnS+lyu31nwQjZY/d817GmCnfw==}
|
||||
|
||||
'@vue/[email protected]':
|
||||
resolution: {integrity: sha512-XJhip7R2wy6vX3knCxdZN4KracFaZUef58s1KYewqluedHIJaPIVfXoYT7MF1F8nCvv6k8bWWxDC8opMkg1VTQ==}
|
||||
|
||||
'@vue/[email protected]':
|
||||
resolution: {integrity: sha512-U3v5OejKEGqOI0Wy0+Sz7hGuIFZHA4LSXzrNM3IMIeDyJEBBfTpX26n3SDgToRpP2bLc9FfI2j/kSgcJ8Emq5A==}
|
||||
|
||||
'@vue/[email protected]':
|
||||
resolution: {integrity: sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==}
|
||||
|
||||
'@vue/[email protected]':
|
||||
resolution: {integrity: sha512-KxtEpUOOpFz/qOGRrAwA36QF7DqIA+FXgCYit9mk9wjbaZt0sXOFz81ElOZtKA4HbWHUdwNjZHBFsFFyp5BZiA==}
|
||||
|
||||
'@vue/[email protected]':
|
||||
resolution: {integrity: sha512-3WNi2Kq4tbpVbmhml7RiphmAt0279oh3fKNeWMQIrltfX8Q91b4i5PL8DtyNKdwmcsGrV4fg+erwWOmD05CLIw==}
|
||||
|
||||
'@vue/[email protected]':
|
||||
resolution: {integrity: sha512-wOPslzB8vTvpxwdaOcR2qAbwmuSP0L+rhpoC6Cf56V3Jip+HWb7PQQXOUPgBNQARpXsbQX/+mvi8kKucmBGRwQ==}
|
||||
|
||||
'@vue/[email protected]':
|
||||
resolution: {integrity: sha512-rznsqKM0np0x18EjzF8x88MpEhdNsffbvFbckLL5+oUKz1BxAImEmO7J1ArRYSyo6aQaVoBDp7jEkT91OOxydA==}
|
||||
|
||||
'@vue/[email protected]':
|
||||
resolution: {integrity: sha512-Vcry58hiAKwGen9Z1jUZE0feFsNArPCMOImYI8el48A9Idf6DuQYD0U05zZIF2Iad1hGhPSvcbBbAOhNr55fhg==}
|
||||
|
||||
'@vue/[email protected]':
|
||||
resolution: {integrity: sha512-3vVBahVBS9+U6cmXBLyb8nE6/yYo4J/CGI9eVFs3KiMc0YHuudwKyShTD65jtJy/L9PUUxNAFu4cj4LiJ0UFbw==}
|
||||
|
||||
'@vue/[email protected]':
|
||||
resolution: {integrity: sha512-n6hx/pNFfbD6SuyeuMVkvqox8bwf/ET9JlA/kAz/imw8sw++wkqKe2mHX5KutjPpbKE4Z56yTHszoOjGMI9igQ==}
|
||||
|
||||
'@vue/[email protected]':
|
||||
resolution: {integrity: sha512-IOnwSCma8j+9xJT6b8H0dEYidC80NsYmNMlZxRsukYcSoGaDBohog5hDxzeUXdFeGWFA++vWvxqOmrr96VlqMA==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-ufbM3smX/Jbnpk5wcQjzd1MgBpzmqfNETUAyZNrGwU9foRlyHoGzMMBBCRzEhQLBjZfFDE1W2ufPXX2vdWkV8Q==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
|
||||
engines: {node: '>=0.12'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
peerDependencies:
|
||||
picomatch: ^3 || ^4
|
||||
peerDependenciesMeta:
|
||||
picomatch:
|
||||
optional: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
os: [darwin]
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==}
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==}
|
||||
peerDependencies:
|
||||
typescript: '>=4.5.0'
|
||||
vue: ^3.5.11
|
||||
peerDependenciesMeta:
|
||||
typescript:
|
||||
optional: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-/tqMfgP7GPA3PHhCmuiS4vIjrSVhHLgY++i+dhbG462euyAj7FpM4D9uq1X3BgjlqRdpcOrYhcQtfiQLNc8tqw==}
|
||||
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==}
|
||||
engines: {node: '>=16'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@types/node': ^20.19.0 || >=22.12.0
|
||||
jiti: '>=1.21.0'
|
||||
less: ^4.0.0
|
||||
lightningcss: ^1.21.0
|
||||
sass: ^1.70.0
|
||||
sass-embedded: ^1.70.0
|
||||
stylus: '>=0.54.8'
|
||||
sugarss: ^5.0.0
|
||||
terser: ^5.16.0
|
||||
tsx: ^4.8.1
|
||||
yaml: ^2.4.2
|
||||
peerDependenciesMeta:
|
||||
'@types/node':
|
||||
optional: true
|
||||
jiti:
|
||||
optional: true
|
||||
less:
|
||||
optional: true
|
||||
lightningcss:
|
||||
optional: true
|
||||
sass:
|
||||
optional: true
|
||||
sass-embedded:
|
||||
optional: true
|
||||
stylus:
|
||||
optional: true
|
||||
sugarss:
|
||||
optional: true
|
||||
terser:
|
||||
optional: true
|
||||
tsx:
|
||||
optional: true
|
||||
yaml:
|
||||
optional: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==}
|
||||
peerDependencies:
|
||||
vue: ^3.5.0
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-2laE0p+aK+/AOPG/XL/WepOs/GlK755LJ1XECi9kDUrz1FKNw8rb2Xzlw9JS1rqEV55nb0ttsKxVlTCcd+R5cg==}
|
||||
peerDependencies:
|
||||
typescript: '*'
|
||||
peerDependenciesMeta:
|
||||
typescript:
|
||||
optional: true
|
||||
|
||||
snapshots:
|
||||
|
||||
'@babel/[email protected]': {}
|
||||
|
||||
'@babel/[email protected]': {}
|
||||
|
||||
'@babel/[email protected]':
|
||||
dependencies:
|
||||
'@babel/types': 7.29.8
|
||||
|
||||
'@babel/[email protected]':
|
||||
dependencies:
|
||||
'@babel/helper-string-parser': 7.29.7
|
||||
'@babel/helper-validator-identifier': 7.29.7
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@jridgewell/[email protected]':
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
|
||||
'@jridgewell/[email protected]':
|
||||
dependencies:
|
||||
'@jridgewell/gen-mapping': 0.3.13
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
|
||||
'@jridgewell/[email protected]': {}
|
||||
|
||||
'@jridgewell/[email protected]': {}
|
||||
|
||||
'@jridgewell/[email protected]':
|
||||
dependencies:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
'@napi-rs/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@rolldown/[email protected]': {}
|
||||
|
||||
'@rollup/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@rollup/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@rollup/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@rollup/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@rollup/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@rollup/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@rollup/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@rollup/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@rollup/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@rollup/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@rollup/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@rollup/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@rollup/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@rollup/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@rollup/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@rollup/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@rollup/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@rollup/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@rollup/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@rollup/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@rollup/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@rollup/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@rollup/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@rollup/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@rollup/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/[email protected]':
|
||||
dependencies:
|
||||
'@jridgewell/remapping': 2.3.5
|
||||
enhanced-resolve: 5.24.5
|
||||
jiti: 2.7.0
|
||||
lightningcss: 1.32.0
|
||||
magic-string: 0.30.21
|
||||
source-map-js: 1.2.1
|
||||
tailwindcss: 4.3.3
|
||||
|
||||
'@tailwindcss/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/[email protected]':
|
||||
optionalDependencies:
|
||||
'@tailwindcss/oxide-android-arm64': 4.3.3
|
||||
'@tailwindcss/oxide-darwin-arm64': 4.3.3
|
||||
'@tailwindcss/oxide-darwin-x64': 4.3.3
|
||||
'@tailwindcss/oxide-freebsd-x64': 4.3.3
|
||||
'@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.3
|
||||
'@tailwindcss/oxide-linux-arm64-gnu': 4.3.3
|
||||
'@tailwindcss/oxide-linux-arm64-musl': 4.3.3
|
||||
'@tailwindcss/oxide-linux-x64-gnu': 4.3.3
|
||||
'@tailwindcss/oxide-linux-x64-musl': 4.3.3
|
||||
'@tailwindcss/oxide-wasm32-wasi': 4.3.3
|
||||
'@tailwindcss/oxide-win32-arm64-msvc': 4.3.3
|
||||
'@tailwindcss/oxide-win32-x64-msvc': 4.3.3
|
||||
|
||||
'@tailwindcss/[email protected]([email protected]([email protected])([email protected]))':
|
||||
dependencies:
|
||||
'@tailwindcss/node': 4.3.3
|
||||
'@tailwindcss/oxide': 4.3.3
|
||||
tailwindcss: 4.3.3
|
||||
vite: 7.3.6([email protected])([email protected])
|
||||
|
||||
'@types/[email protected]': {}
|
||||
|
||||
'@vitejs/[email protected]([email protected]([email protected])([email protected]))([email protected])':
|
||||
dependencies:
|
||||
'@rolldown/pluginutils': 1.0.1
|
||||
vite: 7.3.6([email protected])([email protected])
|
||||
vue: 3.5.41
|
||||
|
||||
'@vue/[email protected]':
|
||||
dependencies:
|
||||
'@babel/parser': 7.29.8
|
||||
'@vue/shared': 3.5.41
|
||||
entities: 7.0.1
|
||||
estree-walker: 2.0.2
|
||||
source-map-js: 1.2.1
|
||||
|
||||
'@vue/[email protected]':
|
||||
dependencies:
|
||||
'@vue/compiler-core': 3.5.41
|
||||
'@vue/shared': 3.5.41
|
||||
|
||||
'@vue/[email protected]':
|
||||
dependencies:
|
||||
'@babel/parser': 7.29.8
|
||||
'@vue/compiler-core': 3.5.41
|
||||
'@vue/compiler-dom': 3.5.41
|
||||
'@vue/compiler-ssr': 3.5.41
|
||||
'@vue/shared': 3.5.41
|
||||
estree-walker: 2.0.2
|
||||
magic-string: 0.30.21
|
||||
postcss: 8.5.26
|
||||
source-map-js: 1.2.1
|
||||
|
||||
'@vue/[email protected]':
|
||||
dependencies:
|
||||
'@vue/compiler-dom': 3.5.41
|
||||
'@vue/shared': 3.5.41
|
||||
|
||||
'@vue/[email protected]': {}
|
||||
|
||||
'@vue/[email protected]':
|
||||
dependencies:
|
||||
'@vue/devtools-kit': 7.7.10
|
||||
|
||||
'@vue/[email protected]':
|
||||
dependencies:
|
||||
'@vue/devtools-shared': 7.7.10
|
||||
birpc: 2.9.0
|
||||
hookable: 5.5.3
|
||||
mitt: 3.0.1
|
||||
perfect-debounce: 1.0.0
|
||||
speakingurl: 14.0.1
|
||||
superjson: 2.2.6
|
||||
|
||||
'@vue/[email protected]':
|
||||
dependencies:
|
||||
rfdc: 1.4.1
|
||||
|
||||
'@vue/[email protected]':
|
||||
dependencies:
|
||||
'@vue/shared': 3.5.41
|
||||
|
||||
'@vue/[email protected]':
|
||||
dependencies:
|
||||
'@vue/reactivity': 3.5.41
|
||||
'@vue/shared': 3.5.41
|
||||
|
||||
'@vue/[email protected]':
|
||||
dependencies:
|
||||
'@vue/reactivity': 3.5.41
|
||||
'@vue/runtime-core': 3.5.41
|
||||
'@vue/shared': 3.5.41
|
||||
csstype: 3.2.3
|
||||
|
||||
'@vue/[email protected]':
|
||||
dependencies:
|
||||
'@vue/compiler-ssr': 3.5.41
|
||||
'@vue/runtime-dom': 3.5.41
|
||||
'@vue/shared': 3.5.41
|
||||
|
||||
'@vue/[email protected]': {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
graceful-fs: 4.2.11
|
||||
tapable: 2.3.3
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
optionalDependencies:
|
||||
'@esbuild/aix-ppc64': 0.28.2
|
||||
'@esbuild/android-arm': 0.28.2
|
||||
'@esbuild/android-arm64': 0.28.2
|
||||
'@esbuild/android-x64': 0.28.2
|
||||
'@esbuild/darwin-arm64': 0.28.2
|
||||
'@esbuild/darwin-x64': 0.28.2
|
||||
'@esbuild/freebsd-arm64': 0.28.2
|
||||
'@esbuild/freebsd-x64': 0.28.2
|
||||
'@esbuild/linux-arm': 0.28.2
|
||||
'@esbuild/linux-arm64': 0.28.2
|
||||
'@esbuild/linux-ia32': 0.28.2
|
||||
'@esbuild/linux-loong64': 0.28.2
|
||||
'@esbuild/linux-mips64el': 0.28.2
|
||||
'@esbuild/linux-ppc64': 0.28.2
|
||||
'@esbuild/linux-riscv64': 0.28.2
|
||||
'@esbuild/linux-s390x': 0.28.2
|
||||
'@esbuild/linux-x64': 0.28.2
|
||||
'@esbuild/netbsd-arm64': 0.28.2
|
||||
'@esbuild/netbsd-x64': 0.28.2
|
||||
'@esbuild/openbsd-arm64': 0.28.2
|
||||
'@esbuild/openbsd-x64': 0.28.2
|
||||
'@esbuild/openharmony-arm64': 0.28.2
|
||||
'@esbuild/sunos-x64': 0.28.2
|
||||
'@esbuild/win32-arm64': 0.28.2
|
||||
'@esbuild/win32-ia32': 0.28.2
|
||||
'@esbuild/win32-x64': 0.28.2
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]([email protected]):
|
||||
optionalDependencies:
|
||||
picomatch: 4.0.5
|
||||
|
||||
[email protected]:
|
||||
optional: true
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
optional: true
|
||||
|
||||
[email protected]:
|
||||
optional: true
|
||||
|
||||
[email protected]:
|
||||
optional: true
|
||||
|
||||
[email protected]:
|
||||
optional: true
|
||||
|
||||
[email protected]:
|
||||
optional: true
|
||||
|
||||
[email protected]:
|
||||
optional: true
|
||||
|
||||
[email protected]:
|
||||
optional: true
|
||||
|
||||
[email protected]:
|
||||
optional: true
|
||||
|
||||
[email protected]:
|
||||
optional: true
|
||||
|
||||
[email protected]:
|
||||
optional: true
|
||||
|
||||
[email protected]:
|
||||
optional: true
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
detect-libc: 2.1.2
|
||||
optionalDependencies:
|
||||
lightningcss-android-arm64: 1.32.0
|
||||
lightningcss-darwin-arm64: 1.32.0
|
||||
lightningcss-darwin-x64: 1.32.0
|
||||
lightningcss-freebsd-x64: 1.32.0
|
||||
lightningcss-linux-arm-gnueabihf: 1.32.0
|
||||
lightningcss-linux-arm64-gnu: 1.32.0
|
||||
lightningcss-linux-arm64-musl: 1.32.0
|
||||
lightningcss-linux-x64-gnu: 1.32.0
|
||||
lightningcss-linux-x64-musl: 1.32.0
|
||||
lightningcss-win32-arm64-msvc: 1.32.0
|
||||
lightningcss-win32-x64-msvc: 1.32.0
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]([email protected]):
|
||||
dependencies:
|
||||
'@vue/devtools-api': 7.7.10
|
||||
vue: 3.5.41
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
nanoid: 3.3.18
|
||||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.1
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.9
|
||||
optionalDependencies:
|
||||
'@napi-rs/lzma-linux-x64-gnu': 1.5.1
|
||||
'@rollup/rollup-android-arm-eabi': 4.62.5
|
||||
'@rollup/rollup-android-arm64': 4.62.5
|
||||
'@rollup/rollup-darwin-arm64': 4.62.5
|
||||
'@rollup/rollup-darwin-x64': 4.62.5
|
||||
'@rollup/rollup-freebsd-arm64': 4.62.5
|
||||
'@rollup/rollup-freebsd-x64': 4.62.5
|
||||
'@rollup/rollup-linux-arm-gnueabihf': 4.62.5
|
||||
'@rollup/rollup-linux-arm-musleabihf': 4.62.5
|
||||
'@rollup/rollup-linux-arm64-gnu': 4.62.5
|
||||
'@rollup/rollup-linux-arm64-musl': 4.62.5
|
||||
'@rollup/rollup-linux-loong64-gnu': 4.62.5
|
||||
'@rollup/rollup-linux-loong64-musl': 4.62.5
|
||||
'@rollup/rollup-linux-ppc64-gnu': 4.62.5
|
||||
'@rollup/rollup-linux-ppc64-musl': 4.62.5
|
||||
'@rollup/rollup-linux-riscv64-gnu': 4.62.5
|
||||
'@rollup/rollup-linux-riscv64-musl': 4.62.5
|
||||
'@rollup/rollup-linux-s390x-gnu': 4.62.5
|
||||
'@rollup/rollup-linux-x64-gnu': 4.62.5
|
||||
'@rollup/rollup-linux-x64-musl': 4.62.5
|
||||
'@rollup/rollup-openbsd-x64': 4.62.5
|
||||
'@rollup/rollup-openharmony-arm64': 4.62.5
|
||||
'@rollup/rollup-win32-arm64-msvc': 4.62.5
|
||||
'@rollup/rollup-win32-ia32-msvc': 4.62.5
|
||||
'@rollup/rollup-win32-x64-gnu': 4.62.5
|
||||
'@rollup/rollup-win32-x64-msvc': 4.62.5
|
||||
fsevents: 2.3.3
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
copy-anything: 4.1.0
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
fdir: 6.5.0([email protected])
|
||||
picomatch: 4.0.5
|
||||
|
||||
[email protected]([email protected])([email protected]):
|
||||
dependencies:
|
||||
esbuild: 0.28.2
|
||||
fdir: 6.5.0([email protected])
|
||||
picomatch: 4.0.5
|
||||
postcss: 8.5.26
|
||||
rollup: 4.62.5
|
||||
tinyglobby: 0.2.17
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.3
|
||||
jiti: 2.7.0
|
||||
lightningcss: 1.32.0
|
||||
|
||||
[email protected]([email protected]):
|
||||
dependencies:
|
||||
'@vue/devtools-api': 6.6.4
|
||||
vue: 3.5.41
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@vue/compiler-dom': 3.5.41
|
||||
'@vue/compiler-sfc': 3.5.41
|
||||
'@vue/runtime-dom': 3.5.41
|
||||
'@vue/server-renderer': 3.5.41
|
||||
'@vue/shared': 3.5.41
|
||||
@@ -0,0 +1,276 @@
|
||||
<script setup>
|
||||
import { ref, provide } from "vue";
|
||||
import { useAppStore } from "./stores/app";
|
||||
import { useStartLogStore } from "./stores/startLog";
|
||||
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";
|
||||
|
||||
const app = useAppStore();
|
||||
const startLog = useStartLogStore();
|
||||
|
||||
const tooltipRef = ref(null);
|
||||
provide("peerTooltip", tooltipRef);
|
||||
|
||||
// 主题切换:localStorage 持久化,默认跟随系统
|
||||
const isDark = ref(document.documentElement.classList.contains("dark"));
|
||||
const toggleTheme = () => {
|
||||
isDark.value = !isDark.value;
|
||||
document.documentElement.classList.toggle("dark", isDark.value);
|
||||
localStorage.setItem("vnt-theme", isDark.value ? "dark" : "light");
|
||||
};
|
||||
|
||||
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",
|
||||
},
|
||||
];
|
||||
</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">
|
||||
<!-- 移动端汉堡 -->
|
||||
<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"
|
||||
>
|
||||
<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>
|
||||
</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>
|
||||
|
||||
<!-- 桌面导航 -->
|
||||
<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>
|
||||
<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 ? '切换到浅色模式' : '切换到深色模式'"
|
||||
@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>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 移动端下拉导航 -->
|
||||
<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="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'"
|
||||
:esc-closable="startLog.startStatus !== 'starting'"
|
||||
panel-class="w-full max-w-2xl"
|
||||
@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"
|
||||
? "启动成功"
|
||||
: "启动失败"
|
||||
}}
|
||||
</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>
|
||||
</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"
|
||||
>
|
||||
<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>
|
||||
请检查配置或网络连接。
|
||||
</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>
|
||||
</template>
|
||||
</AppModal>
|
||||
|
||||
<ToastHost />
|
||||
<ConfirmHost />
|
||||
<AppTooltip ref="tooltipRef" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,73 @@
|
||||
// fetch 封装:统一解包 ApiResponse{code,msg,data},code!==0 抛出带 msg 的错误
|
||||
const request = async (url, options) => {
|
||||
const res = await fetch(url, options);
|
||||
const json = await res.json();
|
||||
if (json.code !== 0) {
|
||||
throw new Error(json.msg || "请求失败");
|
||||
}
|
||||
return json.data;
|
||||
};
|
||||
|
||||
const jsonHeaders = { "Content-Type": "application/json" };
|
||||
|
||||
// GET /api/info?file_name=
|
||||
export const getInstanceInfo = (fileName) =>
|
||||
request(`/api/info?file_name=${encodeURIComponent(fileName)}`);
|
||||
|
||||
// GET /api/peers?file_name=
|
||||
export const getPeers = (fileName) =>
|
||||
request(`/api/peers?file_name=${encodeURIComponent(fileName)}`);
|
||||
|
||||
// GET /api/routes?file_name=
|
||||
export const getRoutes = (fileName) =>
|
||||
request(`/api/routes?file_name=${encodeURIComponent(fileName)}`);
|
||||
|
||||
// GET /api/start/status?file_name=
|
||||
export const getStartStatus = (fileName) =>
|
||||
request(`/api/start/status?file_name=${encodeURIComponent(fileName)}`);
|
||||
|
||||
// GET /api/instances
|
||||
export const getInstances = () => request("/api/instances");
|
||||
|
||||
// DELETE /api/instance?file_name=
|
||||
export const deleteInstance = (fileName) =>
|
||||
request(`/api/instance?file_name=${encodeURIComponent(fileName)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
const postAction = (path, fileName) =>
|
||||
request(path, {
|
||||
method: "POST",
|
||||
headers: jsonHeaders,
|
||||
body: JSON.stringify({ file_name: fileName }),
|
||||
});
|
||||
|
||||
// POST /api/start
|
||||
export const startVntApi = (fileName) => postAction("/api/start", fileName);
|
||||
|
||||
// POST /api/stop
|
||||
export const stopVntApi = (fileName) => postAction("/api/stop", fileName);
|
||||
|
||||
// POST /api/restart
|
||||
export const restartVntApi = (fileName) => postAction("/api/restart", fileName);
|
||||
|
||||
// GET /api/config/list
|
||||
export const getConfigList = () => request("/api/config/list");
|
||||
|
||||
// GET /api/config?file_name= -> TOML 原文字符串
|
||||
export const getConfig = (fileName) =>
|
||||
request(`/api/config?file_name=${encodeURIComponent(fileName)}`);
|
||||
|
||||
// POST /api/config, JSON {file_name?, config}
|
||||
export const saveConfig = (fileName, config) =>
|
||||
request("/api/config", {
|
||||
method: "POST",
|
||||
headers: jsonHeaders,
|
||||
body: JSON.stringify({ file_name: fileName || null, config }),
|
||||
});
|
||||
|
||||
// DELETE /api/config?file_name=
|
||||
export const deleteConfig = (fileName) =>
|
||||
request(`/api/config?file_name=${encodeURIComponent(fileName)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
<script setup>
|
||||
import { watch, onUnmounted } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
show: { type: Boolean, default: false },
|
||||
// 点击遮罩是否关闭
|
||||
maskClosable: { type: Boolean, default: true },
|
||||
// ESC 是否关闭
|
||||
escClosable: { type: Boolean, default: true },
|
||||
panelClass: { type: String, default: "w-full max-w-2xl" },
|
||||
});
|
||||
|
||||
const emit = defineEmits(["close"]);
|
||||
|
||||
const onKeydown = (e) => {
|
||||
if (props.escClosable && e.key === "Escape") emit("close");
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(val) => {
|
||||
if (val) window.addEventListener("keydown", onKeydown);
|
||||
else window.removeEventListener("keydown", onKeydown);
|
||||
},
|
||||
);
|
||||
|
||||
onUnmounted(() => window.removeEventListener("keydown", onKeydown));
|
||||
|
||||
const onMaskClick = () => {
|
||||
if (props.maskClosable) emit("close");
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<teleport to="body">
|
||||
<transition name="modal">
|
||||
<div
|
||||
v-if="show"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/40 p-4 backdrop-blur-sm"
|
||||
@click.self="onMaskClick"
|
||||
>
|
||||
<div
|
||||
class="modal-panel flex max-h-[90vh] flex-col overflow-hidden rounded-xl border border-slate-200 bg-white shadow-2xl dark:border-slate-700 dark:bg-slate-900"
|
||||
:class="panelClass"
|
||||
>
|
||||
<div
|
||||
v-if="$slots.header"
|
||||
class="flex shrink-0 items-center justify-between gap-3 border-b border-slate-200 bg-slate-50/60 px-6 py-4 dark:border-slate-700 dark:bg-slate-800/50"
|
||||
>
|
||||
<slot name="header" />
|
||||
</div>
|
||||
<div class="custom-scrollbar min-h-0 flex-1 overflow-y-auto">
|
||||
<slot name="body" />
|
||||
</div>
|
||||
<div
|
||||
v-if="$slots.footer"
|
||||
class="flex shrink-0 items-center justify-end gap-3 border-t border-slate-200 bg-slate-50/60 px-6 py-4 dark:border-slate-700 dark:bg-slate-800/50"
|
||||
>
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</teleport>
|
||||
</template>
|
||||
@@ -0,0 +1,89 @@
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
|
||||
// 全局 NAT tooltip(保留原定位/悬停逻辑)
|
||||
const tooltipState = ref({ show: false, x: 0, y: 0, info: null });
|
||||
let tooltipHideTimer = null;
|
||||
|
||||
const showPeerTooltip = (event, peer) => {
|
||||
if (!peer.nat_info) return;
|
||||
if (tooltipHideTimer) {
|
||||
clearTimeout(tooltipHideTimer);
|
||||
tooltipHideTimer = null;
|
||||
}
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
tooltipState.value = {
|
||||
show: true,
|
||||
x: rect.left + rect.width / 2,
|
||||
y: rect.bottom + 10,
|
||||
info: peer.nat_info,
|
||||
};
|
||||
};
|
||||
|
||||
const hidePeerTooltip = () => {
|
||||
tooltipHideTimer = setTimeout(() => {
|
||||
tooltipState.value.show = false;
|
||||
}, 100);
|
||||
};
|
||||
|
||||
const onTooltipEnter = () => {
|
||||
if (tooltipHideTimer) {
|
||||
clearTimeout(tooltipHideTimer);
|
||||
tooltipHideTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const onTooltipLeave = () => {
|
||||
tooltipState.value.show = false;
|
||||
};
|
||||
|
||||
defineExpose({ showPeerTooltip, hidePeerTooltip });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<teleport to="body">
|
||||
<div
|
||||
v-if="tooltipState.show"
|
||||
:style="{ top: tooltipState.y + 'px', left: tooltipState.x + 'px' }"
|
||||
class="fixed z-[9999] mt-1 -translate-x-1/2 transform"
|
||||
@mouseenter="onTooltipEnter"
|
||||
@mouseleave="onTooltipLeave"
|
||||
>
|
||||
<div
|
||||
class="w-auto min-w-[260px] max-w-[320px] rounded-lg border border-slate-200 bg-white p-4 text-left text-sm text-slate-600 shadow-2xl dark:border-slate-600 dark:bg-slate-800 dark:text-slate-200"
|
||||
>
|
||||
<div
|
||||
class="absolute -top-2 left-1/2 h-4 w-4 -translate-x-1/2 rotate-45 transform border-l border-t border-slate-200 bg-white dark:border-slate-600 dark:bg-slate-800"
|
||||
></div>
|
||||
<div class="relative z-10 mb-2 flex items-center justify-between border-b border-slate-200 pb-2 dark:border-slate-600">
|
||||
<span class="text-xs font-bold uppercase text-slate-400">NAT Type</span>
|
||||
<span class="badge-green border border-green-200 dark:border-green-800">{{
|
||||
tooltipState.info.nat_type
|
||||
}}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="tooltipState.info.public_ips && tooltipState.info.public_ips.length > 0"
|
||||
class="relative z-10 mb-3"
|
||||
>
|
||||
<span class="mb-1 block text-xs text-slate-400">Public IPv4:</span>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<span
|
||||
v-for="pip in tooltipState.info.public_ips"
|
||||
:key="pip"
|
||||
class="rounded border border-slate-200 bg-slate-100 px-1.5 py-0.5 font-mono text-xs tabular-nums text-slate-600 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-200"
|
||||
>{{ pip }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="tooltipState.info.ipv6" class="relative z-10">
|
||||
<span class="mb-1 block text-xs text-slate-400">IPv6:</span>
|
||||
<div
|
||||
class="whitespace-normal break-all rounded border border-slate-200 bg-slate-50 p-1.5 font-mono text-xs leading-relaxed text-slate-600 dark:border-slate-700/50 dark:bg-slate-900/50 dark:text-slate-200"
|
||||
>
|
||||
{{ tooltipState.info.ipv6 }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</teleport>
|
||||
</template>
|
||||
@@ -0,0 +1,48 @@
|
||||
<script setup>
|
||||
import { useUiStore } from "../stores/ui";
|
||||
import AppModal from "./AppModal.vue";
|
||||
|
||||
const ui = useUiStore();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppModal
|
||||
:show="ui.confirmState.show"
|
||||
panel-class="w-full max-w-sm"
|
||||
@close="ui.confirmCancel"
|
||||
>
|
||||
<template #header>
|
||||
<h3 class="text-lg font-bold text-slate-900 flex items-center gap-2 dark:text-white">
|
||||
<svg
|
||||
v-if="ui.confirmState.danger"
|
||||
class="w-5 h-5 text-red-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
{{ ui.confirmState.title }}
|
||||
</h3>
|
||||
</template>
|
||||
<template #body>
|
||||
<p class="px-6 py-5 text-sm text-slate-600 break-all dark:text-slate-300">
|
||||
{{ ui.confirmState.message }}
|
||||
</p>
|
||||
</template>
|
||||
<template #footer>
|
||||
<button class="btn-ghost" @click="ui.confirmCancel">取消</button>
|
||||
<button
|
||||
:class="ui.confirmState.danger ? 'btn-danger' : 'btn-primary'"
|
||||
@click="ui.confirmOk"
|
||||
>
|
||||
{{ ui.confirmState.confirmText }}
|
||||
</button>
|
||||
</template>
|
||||
</AppModal>
|
||||
</template>
|
||||
@@ -0,0 +1,24 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
text: { type: String, default: "暂无数据" },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card p-12 text-center text-slate-500 dark:text-slate-400">
|
||||
<svg
|
||||
class="w-12 h-12 mx-auto mb-3 text-slate-300 dark:text-slate-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"
|
||||
/>
|
||||
</svg>
|
||||
<p>{{ text }}</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,130 @@
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
import { useAppStore } from "../stores/app";
|
||||
import { useUiStore } from "../stores/ui";
|
||||
|
||||
const props = defineProps({
|
||||
inst: { type: Object, required: true },
|
||||
// 是否显示选中高亮(实例页用)
|
||||
selectable: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const app = useAppStore();
|
||||
const ui = useUiStore();
|
||||
|
||||
const info = computed(() => app.infoOf(props.inst.file_name));
|
||||
const loading = computed(() => !!app.loadingMap[props.inst.file_name]);
|
||||
|
||||
const statusBadgeClass = (status) =>
|
||||
status === "running" ? "badge-green" : status === "starting" ? "badge-blue" : "badge-gray";
|
||||
const statusText = (status) =>
|
||||
status === "running" ? "运行中" : status === "starting" ? "启动中" : "已停止";
|
||||
|
||||
const select = () => {
|
||||
if (props.selectable) app.selectedInstance = props.inst.file_name;
|
||||
};
|
||||
|
||||
const confirmStop = async () => {
|
||||
const ok = await ui.confirm({
|
||||
title: "停止组网",
|
||||
message: `确定要停止 ${props.inst.file_name} 吗?`,
|
||||
danger: true,
|
||||
confirmText: "停止",
|
||||
});
|
||||
if (ok) app.stopVnt(props.inst.file_name);
|
||||
};
|
||||
|
||||
const confirmRestart = async () => {
|
||||
const ok = await ui.confirm({
|
||||
title: "重启组网",
|
||||
message: `确定要重启 ${props.inst.file_name} 吗?`,
|
||||
});
|
||||
if (ok) app.restartVnt(props.inst.file_name);
|
||||
};
|
||||
|
||||
const confirmDismiss = async () => {
|
||||
const ok = await ui.confirm({
|
||||
title: "移除实例",
|
||||
message: `确定要移除已停止的实例 ${props.inst.file_name} 吗?`,
|
||||
danger: true,
|
||||
confirmText: "移除",
|
||||
});
|
||||
if (ok) app.dismissInstance(props.inst.file_name);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="card"
|
||||
:class="[
|
||||
selectable ? 'cursor-pointer' : '',
|
||||
selectable && app.selectedInstance === inst.file_name
|
||||
? 'ring-2 ring-indigo-500 dark:ring-indigo-400'
|
||||
: '',
|
||||
]"
|
||||
@click="select"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<h3 class="truncate text-base font-bold text-slate-900 dark:text-white" :title="inst.file_name">
|
||||
{{ inst.config_name || inst.file_name }}
|
||||
</h3>
|
||||
<span class="shrink-0" :class="statusBadgeClass(inst.status)">{{ statusText(inst.status) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 text-sm">
|
||||
<span class="muted">虚拟 IP:</span>
|
||||
<span class="ml-1 font-mono tabular-nums text-indigo-600 dark:text-indigo-400">{{
|
||||
info.ip || "-"
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex gap-4 text-xs muted">
|
||||
<span>
|
||||
在线
|
||||
<span class="font-bold tabular-nums text-blue-600 dark:text-blue-400">{{
|
||||
info.online_client_num || 0
|
||||
}}</span>
|
||||
</span>
|
||||
<span>
|
||||
直连
|
||||
<span class="font-bold tabular-nums text-green-600 dark:text-green-400">{{
|
||||
info.direct_client_num || 0
|
||||
}}</span>
|
||||
</span>
|
||||
<span>
|
||||
离线
|
||||
<span class="font-bold tabular-nums text-slate-400">{{ info.offline_client_num || 0 }}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-wrap justify-end gap-2">
|
||||
<button
|
||||
v-if="inst.status === 'running' || inst.status === 'stopped'"
|
||||
class="btn-primary btn-sm"
|
||||
:disabled="loading"
|
||||
@click.stop="confirmRestart"
|
||||
>
|
||||
<span v-if="loading" class="animate-spin">⟳</span>
|
||||
{{ inst.status === "stopped" ? "重新启动" : "重启" }}
|
||||
</button>
|
||||
<button
|
||||
v-if="inst.status !== 'stopped'"
|
||||
class="btn-danger btn-sm"
|
||||
:disabled="loading"
|
||||
@click.stop="confirmStop"
|
||||
>
|
||||
<span v-if="loading" class="animate-spin">⟳</span>
|
||||
停止
|
||||
</button>
|
||||
<button
|
||||
v-if="inst.status === 'stopped'"
|
||||
class="btn-ghost btn-sm"
|
||||
:disabled="loading"
|
||||
@click.stop="confirmDismiss"
|
||||
>
|
||||
<span v-if="loading" class="animate-spin">⟳</span>
|
||||
移除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,136 @@
|
||||
<script setup>
|
||||
import { ref, watch, onMounted, nextTick } from "vue";
|
||||
import { formatSpeed, niceNumber } from "../utils/format";
|
||||
|
||||
const props = defineProps({
|
||||
history: { type: Object, required: true }, // { tx: [], rx: [] }
|
||||
size: { type: Number, default: 60 }, // 历史点数
|
||||
});
|
||||
|
||||
const canvasRef = ref(null);
|
||||
const maxLabel = ref("");
|
||||
|
||||
const draw = () => {
|
||||
const canvas = canvasRef.value;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
const txArr = props.history ? props.history.tx : [];
|
||||
const rxArr = props.history ? props.history.rx : [];
|
||||
const HISTORY_SIZE = props.size;
|
||||
|
||||
// 高清适配
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
if (rect.width === 0) return;
|
||||
canvas.width = rect.width * dpr;
|
||||
canvas.height = rect.height * dpr;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
const w = rect.width;
|
||||
const h = rect.height;
|
||||
const isDark = document.documentElement.classList.contains("dark");
|
||||
const colors = isDark
|
||||
? { bg: "#0c1222", grid: "rgba(71, 85, 105, 0.3)", rx: "#60a5fa", rxFill: "rgba(96, 165, 250, 0.15)", tx: "#4ade80", txFill: "rgba(74, 222, 128, 0.15)" }
|
||||
: { bg: "#f8fafc", grid: "rgba(148, 163, 184, 0.35)", rx: "#3b82f6", rxFill: "rgba(59, 130, 246, 0.12)", tx: "#22c55e", txFill: "rgba(34, 197, 94, 0.12)" };
|
||||
|
||||
const padTop = 8,
|
||||
padBottom = 4,
|
||||
padLeft = 0,
|
||||
padRight = 0;
|
||||
const chartW = w - padLeft - padRight;
|
||||
const chartH = h - padTop - padBottom;
|
||||
|
||||
// 背景
|
||||
ctx.fillStyle = colors.bg;
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
||||
// 计算Y轴最大值
|
||||
const allValues = [...txArr, ...rxArr];
|
||||
let maxVal = allValues.length > 0 ? Math.max(...allValues) : 0;
|
||||
if (maxVal < 1024) maxVal = 1024; // 最小1KB
|
||||
const niceMax = niceNumber(maxVal);
|
||||
|
||||
maxLabel.value = "峰值: " + formatSpeed(niceMax);
|
||||
|
||||
// 网格线
|
||||
const gridLines = 4;
|
||||
ctx.strokeStyle = colors.grid;
|
||||
ctx.lineWidth = 1;
|
||||
for (let i = 0; i <= gridLines; i++) {
|
||||
const y = padTop + (chartH / gridLines) * i;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(padLeft, y);
|
||||
ctx.lineTo(padLeft + chartW, y);
|
||||
ctx.stroke();
|
||||
}
|
||||
// 垂直网格线
|
||||
const vLines = 6;
|
||||
for (let i = 0; i <= vLines; i++) {
|
||||
const x = padLeft + (chartW / vLines) * i;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, padTop);
|
||||
ctx.lineTo(x, padTop + chartH);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// 绘制曲线
|
||||
const drawLine = (data, strokeColor, fillColor) => {
|
||||
if (data.length < 2) return;
|
||||
const step = chartW / (HISTORY_SIZE - 1);
|
||||
const offset = HISTORY_SIZE - data.length;
|
||||
|
||||
// 填充区域
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(padLeft + offset * step, padTop + chartH);
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const x = padLeft + (offset + i) * step;
|
||||
const y = padTop + chartH - (data[i] / niceMax) * chartH;
|
||||
if (i === 0) ctx.lineTo(x, y);
|
||||
else ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.lineTo(padLeft + (offset + data.length - 1) * step, padTop + chartH);
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = fillColor;
|
||||
ctx.fill();
|
||||
|
||||
// 线条
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const x = padLeft + (offset + i) * step;
|
||||
const y = padTop + chartH - (data[i] / niceMax) * chartH;
|
||||
if (i === 0) ctx.moveTo(x, y);
|
||||
else ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.strokeStyle = strokeColor;
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.stroke();
|
||||
};
|
||||
|
||||
drawLine(rxArr, colors.rx, colors.rxFill);
|
||||
drawLine(txArr, colors.tx, colors.txFill);
|
||||
};
|
||||
|
||||
onMounted(() => nextTick(draw));
|
||||
|
||||
// 历史数据更新时重绘(数组原地 push/shift,监听引用内每个点)
|
||||
watch(
|
||||
() => [props.history?.tx?.length, props.history?.rx?.length, props.history?.tx?.at(-1), props.history?.rx?.at(-1)],
|
||||
() => nextTick(draw),
|
||||
);
|
||||
|
||||
defineExpose({ draw });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center gap-4 mb-2 text-xs text-slate-400">
|
||||
<span class="flex items-center"
|
||||
><span class="inline-block w-3 h-0.5 bg-green-400 mr-1"></span>上传速度</span
|
||||
>
|
||||
<span class="flex items-center"
|
||||
><span class="inline-block w-3 h-0.5 bg-blue-400 mr-1"></span>下载速度</span
|
||||
>
|
||||
<span class="ml-auto">{{ maxLabel }}</span>
|
||||
</div>
|
||||
<canvas ref="canvasRef" class="rounded w-full block h-[150px]"></canvas>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,72 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch } from "vue";
|
||||
import { useAppStore } from "../stores/app";
|
||||
import { useUiStore } from "../stores/ui";
|
||||
|
||||
// 启动组网面板:选择配置 + 启动,总览页与实例页复用
|
||||
const app = useAppStore();
|
||||
const ui = useUiStore();
|
||||
|
||||
const localSelectedConfig = ref("");
|
||||
|
||||
// 只列出没有对应实例的配置(同一配置最多一个实例)
|
||||
const availableConfigs = computed(() =>
|
||||
app.configList.filter(
|
||||
(cfg) => !app.instanceList.some((inst) => inst.file_name === cfg.file_name),
|
||||
),
|
||||
);
|
||||
|
||||
// 默认选中第一个可用配置;当前选中项不可用时(如已启动)自动切到下一个
|
||||
watch(
|
||||
availableConfigs,
|
||||
(list) => {
|
||||
if (!list.some((cfg) => cfg.file_name === localSelectedConfig.value)) {
|
||||
localSelectedConfig.value = list.length ? list[0].file_name : "";
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const handleStart = () => {
|
||||
if (!localSelectedConfig.value) {
|
||||
ui.toast.error("请先选择一个配置");
|
||||
return;
|
||||
}
|
||||
app.startVnt(localSelectedConfig.value);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card">
|
||||
<h2 class="mb-4 text-base font-bold text-slate-900 dark:text-white">启动组网</h2>
|
||||
|
||||
<div v-if="app.configList.length === 0" class="flex flex-wrap items-center justify-between gap-3">
|
||||
<p class="text-sm muted">还没有任何配置,先创建一个组网配置吧。</p>
|
||||
<router-link to="/config" class="btn-primary btn-sm">去新建配置</router-link>
|
||||
</div>
|
||||
|
||||
<div v-else-if="availableConfigs.length === 0" class="text-sm muted">
|
||||
所有配置均已启动。
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col gap-3 sm:flex-row sm:items-end">
|
||||
<div class="flex-1">
|
||||
<label class="mb-1.5 block text-xs font-medium muted">选择配置</label>
|
||||
<select v-model="localSelectedConfig" class="input">
|
||||
<option value="" disabled>请选择配置...</option>
|
||||
<option v-for="cfg in availableConfigs" :key="cfg.file_name" :value="cfg.file_name">
|
||||
{{ cfg.config_name || cfg.file_name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
class="btn-primary px-8"
|
||||
:disabled="!localSelectedConfig || !!app.loadingMap[localSelectedConfig]"
|
||||
@click="handleStart"
|
||||
>
|
||||
<span v-if="app.loadingMap[localSelectedConfig]" class="animate-spin">⟳</span>
|
||||
启动
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
status: { type: String, default: "stopped" },
|
||||
size: { type: String, default: "w-2.5 h-2.5" },
|
||||
});
|
||||
|
||||
const dotClass = (status) =>
|
||||
status === "running"
|
||||
? "bg-green-500"
|
||||
: status === "starting"
|
||||
? "bg-blue-500 animate-pulse"
|
||||
: "bg-slate-400 dark:bg-slate-500";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="rounded-full inline-block shrink-0" :class="[size, dotClass(status)]" />
|
||||
</template>
|
||||
@@ -0,0 +1,45 @@
|
||||
<script setup>
|
||||
import { useUiStore } from "../stores/ui";
|
||||
|
||||
const ui = useUiStore();
|
||||
|
||||
// 浅色卡片 + 左侧色条
|
||||
const barClass = (type) =>
|
||||
type === "success" ? "bg-green-500" : type === "error" ? "bg-red-500" : "bg-indigo-500";
|
||||
|
||||
const iconClass = (type) =>
|
||||
type === "success"
|
||||
? "text-green-500"
|
||||
: type === "error"
|
||||
? "text-red-500"
|
||||
: "text-indigo-500";
|
||||
|
||||
const iconPath = (type) =>
|
||||
type === "success"
|
||||
? "M5 13l4 4L19 7"
|
||||
: type === "error"
|
||||
? "M6 18L18 6M6 6l12 12"
|
||||
: "M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<teleport to="body">
|
||||
<div class="pointer-events-none fixed right-4 top-4 z-[100] flex flex-col items-end gap-2">
|
||||
<transition-group name="toast">
|
||||
<div
|
||||
v-for="t in ui.toasts"
|
||||
:key="t.id"
|
||||
class="pointer-events-auto flex max-w-sm items-stretch overflow-hidden rounded-lg border border-slate-200 bg-white shadow-lg dark:border-slate-700 dark:bg-slate-800"
|
||||
>
|
||||
<span class="w-1 shrink-0" :class="barClass(t.type)"></span>
|
||||
<div class="flex items-center gap-2 px-4 py-2.5">
|
||||
<svg class="h-4 w-4 shrink-0" :class="iconClass(t.type)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" :d="iconPath(t.type)" />
|
||||
</svg>
|
||||
<span class="break-all text-sm text-slate-700 dark:text-slate-200">{{ t.message }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</transition-group>
|
||||
</div>
|
||||
</teleport>
|
||||
</template>
|
||||
@@ -0,0 +1,7 @@
|
||||
import { createApp } from "vue";
|
||||
import { createPinia } from "pinia";
|
||||
import App from "./App.vue";
|
||||
import router from "./router";
|
||||
import "./style.css";
|
||||
|
||||
createApp(App).use(createPinia()).use(router).mount("#app");
|
||||
@@ -0,0 +1,21 @@
|
||||
import { createRouter, createWebHashHistory } from "vue-router";
|
||||
import DashboardView from "../views/DashboardView.vue";
|
||||
import InstancesView from "../views/InstancesView.vue";
|
||||
import ConfigView from "../views/ConfigView.vue";
|
||||
import PeersView from "../views/PeersView.vue";
|
||||
import RoutesView from "../views/RoutesView.vue";
|
||||
|
||||
const routes = [
|
||||
{ path: "/", component: DashboardView },
|
||||
{ path: "/instances", component: InstancesView },
|
||||
// 兼容旧路由
|
||||
{ path: "/general", redirect: "/instances" },
|
||||
{ path: "/config", component: ConfigView },
|
||||
{ path: "/peers", component: PeersView },
|
||||
{ path: "/routes", component: RoutesView },
|
||||
];
|
||||
|
||||
export default createRouter({
|
||||
history: createWebHashHistory(),
|
||||
routes,
|
||||
});
|
||||
@@ -0,0 +1,252 @@
|
||||
import { defineStore } from "pinia";
|
||||
import { ref, computed, onMounted, onUnmounted, getCurrentInstance } from "vue";
|
||||
import {
|
||||
getInstances,
|
||||
getInstanceInfo,
|
||||
getConfigList,
|
||||
startVntApi,
|
||||
stopVntApi,
|
||||
restartVntApi,
|
||||
deleteInstance,
|
||||
} from "../api";
|
||||
import { useUiStore } from "./ui";
|
||||
import { useStartLogStore } from "./startLog";
|
||||
|
||||
export const useAppStore = defineStore("app", () => {
|
||||
const ui = useUiStore();
|
||||
const startLog = useStartLogStore();
|
||||
|
||||
// 多实例状态:key=file_name, value=该实例 info
|
||||
const instances = ref({});
|
||||
const instanceList = ref([]);
|
||||
const selectedInstance = ref(null);
|
||||
const configList = ref([]);
|
||||
const loadingMap = ref({});
|
||||
|
||||
// 页面可见性
|
||||
const isPageVisible = ref(!document.hidden);
|
||||
const visibilityHandler = () => {
|
||||
isPageVisible.value = !document.hidden;
|
||||
};
|
||||
|
||||
let infoTimer = null;
|
||||
|
||||
const runningCount = computed(
|
||||
() => instanceList.value.filter((i) => i.status === "running").length,
|
||||
);
|
||||
const startingCount = computed(
|
||||
() => instanceList.value.filter((i) => i.status === "starting").length,
|
||||
);
|
||||
const headerStatusText = computed(() => {
|
||||
if (runningCount.value > 0) return `运行中 x${runningCount.value}`;
|
||||
if (startingCount.value > 0) return "启动中...";
|
||||
return "未启动";
|
||||
});
|
||||
const selectedInfo = computed(() =>
|
||||
selectedInstance.value ? instances.value[selectedInstance.value] || null : null,
|
||||
);
|
||||
const selectedConfigName = computed(() => {
|
||||
if (!selectedInstance.value) return "";
|
||||
const inst = instanceList.value.find(
|
||||
(i) => i.file_name === selectedInstance.value,
|
||||
);
|
||||
return inst ? inst.config_name || inst.file_name : selectedInstance.value;
|
||||
});
|
||||
const version = computed(() => {
|
||||
for (const key in instances.value) {
|
||||
const item = instances.value[key];
|
||||
if (item && item.version) return item.version;
|
||||
}
|
||||
return "";
|
||||
});
|
||||
const isServerConnected = computed(
|
||||
() =>
|
||||
!!(
|
||||
selectedInfo.value &&
|
||||
selectedInfo.value.server_info &&
|
||||
selectedInfo.value.server_info.some((s) => s.connected)
|
||||
),
|
||||
);
|
||||
const serverStatusText = computed(() => {
|
||||
const si = selectedInfo.value;
|
||||
if (!si || !si.server_info || !si.server_info.length) return "未配置服务器";
|
||||
return `${si.server_info.filter((s) => s.connected).length} / ${si.server_info.length} 已连接`;
|
||||
});
|
||||
|
||||
const infoOf = (fileName) => instances.value[fileName] || {};
|
||||
|
||||
const fetchInstanceInfo = async (fileName) => {
|
||||
try {
|
||||
instances.value[fileName] = await getInstanceInfo(fileName);
|
||||
} catch (e) {
|
||||
console.error("Fetch info error", e);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchInstances = async () => {
|
||||
try {
|
||||
const list = (await getInstances()) || [];
|
||||
instanceList.value = list;
|
||||
// 清理已消失实例的 info 缓存
|
||||
for (const key of Object.keys(instances.value)) {
|
||||
if (!list.some((i) => i.file_name === key)) {
|
||||
delete instances.value[key];
|
||||
}
|
||||
}
|
||||
// 默认选中逻辑:当前选中失效时优先第一个 running,否则第一个,否则 null
|
||||
if (
|
||||
!selectedInstance.value ||
|
||||
!list.some((i) => i.file_name === selectedInstance.value)
|
||||
) {
|
||||
const running = list.find((i) => i.status === "running");
|
||||
selectedInstance.value = running
|
||||
? running.file_name
|
||||
: list.length > 0
|
||||
? list[0].file_name
|
||||
: null;
|
||||
}
|
||||
// 拉取 running 实例的详情
|
||||
for (const inst of list) {
|
||||
if (inst.status === "running") {
|
||||
fetchInstanceInfo(inst.file_name);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Fetch instances error", e);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchConfigList = async () => {
|
||||
try {
|
||||
configList.value = (await getConfigList()) || [];
|
||||
} catch (e) {
|
||||
console.error("Fetch list error", e);
|
||||
}
|
||||
};
|
||||
|
||||
const startVnt = async (fileName) => {
|
||||
if (!fileName) {
|
||||
ui.toast.error("请先选择一个配置");
|
||||
return;
|
||||
}
|
||||
if (loadingMap.value[fileName]) return;
|
||||
loadingMap.value[fileName] = true;
|
||||
try {
|
||||
await startVntApi(fileName);
|
||||
startLog.openStartLog(fileName);
|
||||
fetchInstances();
|
||||
} catch (e) {
|
||||
ui.toast.error("启动失败: " + e.message);
|
||||
} finally {
|
||||
loadingMap.value[fileName] = false;
|
||||
}
|
||||
};
|
||||
|
||||
const stopVnt = async (fileName) => {
|
||||
if (!fileName || loadingMap.value[fileName]) return;
|
||||
loadingMap.value[fileName] = true;
|
||||
try {
|
||||
await stopVntApi(fileName);
|
||||
ui.toast.success("已停止");
|
||||
if (startLog.logFileName === fileName) {
|
||||
startLog.stopPolling();
|
||||
startLog.showStartLog = false;
|
||||
}
|
||||
fetchInstances();
|
||||
} catch (e) {
|
||||
ui.toast.error("停止失败: " + e.message);
|
||||
console.error(e);
|
||||
} finally {
|
||||
loadingMap.value[fileName] = false;
|
||||
}
|
||||
};
|
||||
|
||||
const restartVnt = async (fileName) => {
|
||||
if (!fileName || loadingMap.value[fileName]) return;
|
||||
loadingMap.value[fileName] = true;
|
||||
try {
|
||||
await restartVntApi(fileName);
|
||||
startLog.openStartLog(fileName);
|
||||
fetchInstances();
|
||||
} catch (e) {
|
||||
ui.toast.error("重启失败: " + e.message);
|
||||
} finally {
|
||||
loadingMap.value[fileName] = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 移除已停止(启动失败残留)的实例条目
|
||||
const dismissInstance = async (fileName) => {
|
||||
if (!fileName || loadingMap.value[fileName]) return;
|
||||
loadingMap.value[fileName] = true;
|
||||
try {
|
||||
await deleteInstance(fileName);
|
||||
ui.toast.success("已移除");
|
||||
if (startLog.logFileName === fileName) {
|
||||
startLog.stopPolling();
|
||||
startLog.showStartLog = false;
|
||||
}
|
||||
if (selectedInstance.value === fileName) {
|
||||
selectedInstance.value = null;
|
||||
}
|
||||
fetchInstances();
|
||||
} catch (e) {
|
||||
ui.toast.error("移除失败: " + e.message);
|
||||
} finally {
|
||||
loadingMap.value[fileName] = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 注入依赖,供启动日志 store 刷新实例与解析配置名
|
||||
startLog.bindApp({ fetchInstances, instanceList, configList });
|
||||
|
||||
const init = async () => {
|
||||
document.addEventListener("visibilitychange", visibilityHandler);
|
||||
await fetchInstances();
|
||||
fetchConfigList();
|
||||
// 页面加载时若有正在启动的实例,恢复其日志弹窗
|
||||
const starting = instanceList.value.find((i) => i.status === "starting");
|
||||
if (starting) startLog.openStartLog(starting.file_name);
|
||||
// 全局 3s 轮询实例列表及 running 实例详情(仅页面可见)
|
||||
infoTimer = setInterval(() => {
|
||||
if (isPageVisible.value) fetchInstances();
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
const destroy = () => {
|
||||
document.removeEventListener("visibilitychange", visibilityHandler);
|
||||
startLog.stopPolling();
|
||||
if (infoTimer) clearInterval(infoTimer);
|
||||
};
|
||||
|
||||
// store 在组件 setup 中被使用时自动挂接生命周期
|
||||
if (getCurrentInstance()) {
|
||||
onMounted(init);
|
||||
onUnmounted(destroy);
|
||||
}
|
||||
|
||||
return {
|
||||
instances,
|
||||
instanceList,
|
||||
selectedInstance,
|
||||
configList,
|
||||
loadingMap,
|
||||
isPageVisible,
|
||||
runningCount,
|
||||
startingCount,
|
||||
headerStatusText,
|
||||
selectedInfo,
|
||||
selectedConfigName,
|
||||
version,
|
||||
isServerConnected,
|
||||
serverStatusText,
|
||||
infoOf,
|
||||
fetchInstances,
|
||||
fetchInstanceInfo,
|
||||
fetchConfigList,
|
||||
startVnt,
|
||||
stopVnt,
|
||||
restartVnt,
|
||||
dismissInstance,
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import { defineStore } from "pinia";
|
||||
import { ref, computed, nextTick } from "vue";
|
||||
import { getStartStatus, stopVntApi } from "../api";
|
||||
|
||||
// 启动日志弹窗状态
|
||||
export const useStartLogStore = defineStore("startLog", () => {
|
||||
const showStartLog = ref(false);
|
||||
const startLogs = ref([]);
|
||||
const startStatus = ref("stopped");
|
||||
const logFileName = ref(null);
|
||||
const logContainer = ref(null);
|
||||
let statusInterval = null;
|
||||
|
||||
// 由 app store 注入,避免循环依赖
|
||||
let fetchInstancesFn = null;
|
||||
let instanceListRef = null;
|
||||
let configListRef = null;
|
||||
const bindApp = ({ fetchInstances, instanceList, configList }) => {
|
||||
fetchInstancesFn = fetchInstances;
|
||||
instanceListRef = instanceList;
|
||||
configListRef = configList;
|
||||
};
|
||||
|
||||
const logConfigName = computed(() => {
|
||||
if (!logFileName.value) return "";
|
||||
const inst = (instanceListRef?.value || []).find(
|
||||
(i) => i.file_name === logFileName.value,
|
||||
);
|
||||
if (inst) return inst.config_name || inst.file_name;
|
||||
const cfg = (configListRef?.value || []).find(
|
||||
(c) => c.file_name === logFileName.value,
|
||||
);
|
||||
return cfg ? cfg.config_name || cfg.file_name : logFileName.value;
|
||||
});
|
||||
|
||||
const pollStartStatus = async () => {
|
||||
if (!logFileName.value) return;
|
||||
try {
|
||||
const data = await getStartStatus(logFileName.value);
|
||||
startLogs.value = data.logs || [];
|
||||
startStatus.value = data.status;
|
||||
nextTick(() => {
|
||||
if (logContainer.value)
|
||||
logContainer.value.scrollTop = logContainer.value.scrollHeight;
|
||||
});
|
||||
|
||||
if (startStatus.value === "running") {
|
||||
stopPolling();
|
||||
fetchInstancesFn && fetchInstancesFn();
|
||||
showStartLog.value = false;
|
||||
} else if (startStatus.value === "stopped" && startLogs.value.length > 0) {
|
||||
stopPolling();
|
||||
fetchInstancesFn && fetchInstancesFn();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
const stopPolling = () => {
|
||||
if (statusInterval) {
|
||||
clearInterval(statusInterval);
|
||||
statusInterval = null;
|
||||
}
|
||||
};
|
||||
|
||||
const startPolling = () => {
|
||||
stopPolling();
|
||||
statusInterval = setInterval(pollStartStatus, 1000);
|
||||
pollStartStatus();
|
||||
};
|
||||
|
||||
const openStartLog = (fileName) => {
|
||||
logFileName.value = fileName;
|
||||
startLogs.value = [];
|
||||
startStatus.value = "starting";
|
||||
showStartLog.value = true;
|
||||
startPolling();
|
||||
};
|
||||
|
||||
// 取消组网:停止轮询并 POST /api/stop
|
||||
const cancelStart = async () => {
|
||||
stopPolling();
|
||||
const fileName = logFileName.value;
|
||||
if (fileName) {
|
||||
try {
|
||||
await stopVntApi(fileName);
|
||||
startLogs.value.push("启动已手动取消");
|
||||
} catch (e) {
|
||||
// 忽略取消时的网络错误
|
||||
}
|
||||
}
|
||||
startStatus.value = "stopped";
|
||||
fetchInstancesFn && fetchInstancesFn();
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
showStartLog.value = false;
|
||||
};
|
||||
|
||||
return {
|
||||
showStartLog,
|
||||
startLogs,
|
||||
startStatus,
|
||||
logFileName,
|
||||
logContainer,
|
||||
logConfigName,
|
||||
bindApp,
|
||||
openStartLog,
|
||||
pollStartStatus,
|
||||
startPolling,
|
||||
stopPolling,
|
||||
cancelStart,
|
||||
close,
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { defineStore } from "pinia";
|
||||
import { reactive, ref } from "vue";
|
||||
|
||||
let toastId = 0;
|
||||
|
||||
export const useUiStore = defineStore("ui", () => {
|
||||
// toast 队列
|
||||
const toasts = ref([]);
|
||||
|
||||
const pushToast = (type, message, duration = 3000) => {
|
||||
const id = ++toastId;
|
||||
toasts.value.push({ id, type, message });
|
||||
setTimeout(() => {
|
||||
toasts.value = toasts.value.filter((t) => t.id !== id);
|
||||
}, duration);
|
||||
};
|
||||
|
||||
const toast = {
|
||||
success: (msg) => pushToast("success", msg),
|
||||
error: (msg) => pushToast("error", msg, 4500),
|
||||
info: (msg) => pushToast("info", msg),
|
||||
};
|
||||
|
||||
// confirm 弹窗:返回 Promise<boolean>
|
||||
const confirmState = reactive({
|
||||
show: false,
|
||||
title: "",
|
||||
message: "",
|
||||
danger: false,
|
||||
confirmText: "确定",
|
||||
resolve: null,
|
||||
});
|
||||
|
||||
const confirm = ({ title = "确认操作", message = "", danger = false, confirmText = "确定" } = {}) =>
|
||||
new Promise((resolve) => {
|
||||
confirmState.show = true;
|
||||
confirmState.title = title;
|
||||
confirmState.message = message;
|
||||
confirmState.danger = danger;
|
||||
confirmState.confirmText = confirmText;
|
||||
confirmState.resolve = resolve;
|
||||
});
|
||||
|
||||
const confirmOk = () => {
|
||||
confirmState.show = false;
|
||||
confirmState.resolve && confirmState.resolve(true);
|
||||
confirmState.resolve = null;
|
||||
};
|
||||
|
||||
const confirmCancel = () => {
|
||||
confirmState.show = false;
|
||||
confirmState.resolve && confirmState.resolve(false);
|
||||
confirmState.resolve = null;
|
||||
};
|
||||
|
||||
return {
|
||||
toasts,
|
||||
toast,
|
||||
confirmState,
|
||||
confirm,
|
||||
confirmOk,
|
||||
confirmCancel,
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,229 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
@theme {
|
||||
/* 全局唯一主色 */
|
||||
--color-accent: var(--color-indigo-600);
|
||||
--color-accent-hover: var(--color-indigo-500);
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-slate-50 text-slate-700 antialiased dark:bg-slate-950 dark:text-slate-200;
|
||||
}
|
||||
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
/* 自定义滚动条 */
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
@apply rounded bg-slate-300 dark:bg-slate-600;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
@apply bg-slate-400 dark:bg-slate-500;
|
||||
}
|
||||
|
||||
.custom-scrollbar {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--color-slate-300) transparent;
|
||||
}
|
||||
|
||||
.dark .custom-scrollbar {
|
||||
scrollbar-color: var(--color-slate-600) transparent;
|
||||
}
|
||||
|
||||
/* 路由切换动画 */
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* 弹窗动画 */
|
||||
.modal-enter-active,
|
||||
.modal-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.modal-enter-active .modal-panel,
|
||||
.modal-leave-active .modal-panel {
|
||||
transition: transform 0.2s ease, opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.modal-enter-from,
|
||||
.modal-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.modal-enter-from .modal-panel,
|
||||
.modal-leave-to .modal-panel {
|
||||
transform: scale(0.96);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Toast 动画 */
|
||||
.toast-enter-active,
|
||||
.toast-leave-active {
|
||||
transition: opacity 0.25s ease, transform 0.25s ease;
|
||||
}
|
||||
|
||||
.toast-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
|
||||
.toast-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
/* 移动导航动画 */
|
||||
.navdrop-enter-active,
|
||||
.navdrop-leave-active {
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.navdrop-enter-from,
|
||||
.navdrop-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-6px);
|
||||
}
|
||||
|
||||
@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
|
||||
disabled:opacity-50 disabled:cursor-not-allowed disabled:active:scale-100
|
||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500/40;
|
||||
}
|
||||
|
||||
@utility badge {
|
||||
@apply inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium;
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.btn-primary {
|
||||
@apply btn bg-indigo-600 text-white shadow-sm hover:bg-indigo-500 dark:bg-indigo-500 dark:hover:bg-indigo-400;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
@apply btn border border-slate-300 bg-white text-slate-700 shadow-sm hover:bg-slate-50
|
||||
dark:border-slate-600 dark:bg-slate-800 dark:text-slate-200 dark:hover:bg-slate-700;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
@apply btn bg-red-600 text-white shadow-sm hover:bg-red-500;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
@apply px-3 py-1.5 text-xs;
|
||||
}
|
||||
|
||||
.card {
|
||||
@apply rounded-xl border border-slate-200 bg-white p-5 shadow-sm transition-shadow duration-200
|
||||
hover:shadow-md dark:border-slate-700/80 dark:bg-slate-900;
|
||||
}
|
||||
|
||||
.input {
|
||||
@apply w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900
|
||||
placeholder-slate-400 transition-colors
|
||||
focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/25
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
dark:border-slate-600 dark:bg-slate-800 dark:text-white dark:placeholder-slate-500;
|
||||
}
|
||||
|
||||
/* 语义徽章:浅色底深色字,dark 下深底浅字 */
|
||||
.badge-green {
|
||||
@apply badge bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300;
|
||||
}
|
||||
|
||||
.badge-red {
|
||||
@apply badge bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300;
|
||||
}
|
||||
|
||||
.badge-yellow {
|
||||
@apply badge bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300;
|
||||
}
|
||||
|
||||
.badge-blue {
|
||||
@apply badge bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300;
|
||||
}
|
||||
|
||||
.badge-purple {
|
||||
@apply badge bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300;
|
||||
}
|
||||
|
||||
.badge-gray {
|
||||
@apply badge bg-slate-100 text-slate-500 dark:bg-slate-700 dark:text-slate-300;
|
||||
}
|
||||
|
||||
.table {
|
||||
@apply min-w-full divide-y divide-slate-200 dark:divide-slate-700;
|
||||
}
|
||||
|
||||
.table thead {
|
||||
@apply bg-slate-50 dark:bg-slate-800/60;
|
||||
}
|
||||
|
||||
.table thead th {
|
||||
@apply px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-slate-500 whitespace-nowrap
|
||||
dark:text-slate-400;
|
||||
}
|
||||
|
||||
.table tbody {
|
||||
@apply divide-y divide-slate-200 bg-white dark:divide-slate-700 dark:bg-transparent;
|
||||
}
|
||||
|
||||
.table tbody td {
|
||||
@apply px-4 py-3 text-sm text-slate-600 whitespace-nowrap dark:text-slate-300;
|
||||
}
|
||||
|
||||
/* 表格内静态 tooltip */
|
||||
.tooltip {
|
||||
@apply relative inline-flex items-center justify-center;
|
||||
}
|
||||
|
||||
.tooltip .tooltip-text {
|
||||
@apply invisible absolute bottom-[125%] left-1/2 z-10 w-36 -translate-x-1/2 rounded-md
|
||||
border border-slate-200 bg-white px-2 py-1.5 text-center text-xs text-slate-600 shadow-lg
|
||||
opacity-0 transition-opacity duration-300 pointer-events-none
|
||||
dark:border-slate-600 dark:bg-slate-800 dark:text-slate-200;
|
||||
}
|
||||
|
||||
.tooltip:hover .tooltip-text {
|
||||
@apply visible opacity-100;
|
||||
}
|
||||
|
||||
/* 页面标题区 */
|
||||
.page-title {
|
||||
@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;
|
||||
}
|
||||
|
||||
/* 弱文本 */
|
||||
.muted {
|
||||
@apply text-slate-500 dark:text-slate-400;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// 格式化时间
|
||||
export const formatTime = (timestamp) => {
|
||||
if (!timestamp) return "-";
|
||||
const date = new Date(timestamp * 1000);
|
||||
const pad = (n) => (n < 10 ? "0" + n : n);
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
||||
};
|
||||
|
||||
// 格式化字节数
|
||||
export const formatBytes = (bytes) => {
|
||||
if (bytes === 0 || bytes === undefined || bytes === null) return "0B";
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
let i = 0;
|
||||
let value = bytes;
|
||||
while (value >= 1024 && i < units.length - 1) {
|
||||
value /= 1024;
|
||||
i++;
|
||||
}
|
||||
return i === 0 ? value + units[i] : value.toFixed(2) + units[i];
|
||||
};
|
||||
|
||||
// 格式化速度(字节/秒)
|
||||
export const formatSpeed = (bytesPerSecond) => {
|
||||
if (
|
||||
bytesPerSecond === 0 ||
|
||||
bytesPerSecond === undefined ||
|
||||
bytesPerSecond === null
|
||||
)
|
||||
return "0B/s";
|
||||
const units = ["B/s", "KB/s", "MB/s", "GB/s"];
|
||||
let i = 0;
|
||||
let value = bytesPerSecond;
|
||||
while (value >= 1024 && i < units.length - 1) {
|
||||
value /= 1024;
|
||||
i++;
|
||||
}
|
||||
return i === 0 ? value + units[i] : value.toFixed(2) + units[i];
|
||||
};
|
||||
|
||||
// 将数值取整到适合的刻度
|
||||
export const niceNumber = (val) => {
|
||||
const units = [
|
||||
1024, // 1KB
|
||||
10 * 1024, // 10KB
|
||||
100 * 1024, // 100KB
|
||||
1024 * 1024, // 1MB
|
||||
10 * 1024 * 1024, // 10MB
|
||||
100 * 1024 * 1024, // 100MB
|
||||
1024 * 1024 * 1024, // 1GB
|
||||
];
|
||||
for (const u of units) {
|
||||
if (val <= u) return u;
|
||||
}
|
||||
return Math.ceil(val / (1024 * 1024 * 1024)) * 1024 * 1024 * 1024;
|
||||
};
|
||||
@@ -0,0 +1,349 @@
|
||||
// 从旧 index.html 原样迁移的 TOML <-> 表单双向解析逻辑
|
||||
|
||||
export const emptyFormData = () => ({
|
||||
config_name: "",
|
||||
network_code: "",
|
||||
server: [""],
|
||||
ip: "",
|
||||
mtu: null,
|
||||
rtx: false,
|
||||
fec: false,
|
||||
compress: false,
|
||||
no_punch: false,
|
||||
input: [],
|
||||
output: [],
|
||||
no_nat: false,
|
||||
no_tun: false,
|
||||
port_mapping: [],
|
||||
allow_mapping: false,
|
||||
device_name: "",
|
||||
device_id: "",
|
||||
tun_name: "",
|
||||
password: "",
|
||||
cert_mode: "skip",
|
||||
fingerprint: "",
|
||||
udp_stun: [],
|
||||
tcp_stun: [],
|
||||
tunnel_port: null,
|
||||
});
|
||||
|
||||
// 从TOML解析到表单
|
||||
export const parseTomlToForm = (toml) => {
|
||||
const data = emptyFormData();
|
||||
|
||||
const lines = toml.split("\n");
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||
|
||||
if (trimmed.includes("config_name")) {
|
||||
const match = trimmed.match(/config_name\s*=\s*"([^"]*)"/);
|
||||
if (match) data.config_name = match[1];
|
||||
} else if (trimmed.includes("network_code")) {
|
||||
const match = trimmed.match(/network_code\s*=\s*"([^"]*)"/);
|
||||
if (match) data.network_code = match[1];
|
||||
} else if (trimmed.startsWith("server")) {
|
||||
const match = trimmed.match(/server\s*=\s*\[(.*)\]/);
|
||||
if (match) {
|
||||
const items = match[1].match(/"([^"]*)"/g);
|
||||
if (items) data.server = items.map((s) => s.replace(/"/g, ""));
|
||||
}
|
||||
} else if (trimmed.includes("ip =")) {
|
||||
const match = trimmed.match(/ip\s*=\s*"([^"]*)"/);
|
||||
if (match) data.ip = match[1];
|
||||
} else if (trimmed.includes("mtu =")) {
|
||||
const match = trimmed.match(/mtu\s*=\s*(\d+)/);
|
||||
if (match) data.mtu = parseInt(match[1]);
|
||||
} else if (trimmed.match(/^rtx\s*=/)) {
|
||||
data.rtx = trimmed.includes("true");
|
||||
} else if (trimmed.match(/^fec\s*=/)) {
|
||||
data.fec = trimmed.includes("true");
|
||||
} else if (trimmed.match(/^compress\s*=/)) {
|
||||
data.compress = trimmed.includes("true");
|
||||
} else if (trimmed.match(/^no_punch\s*=/)) {
|
||||
data.no_punch = trimmed.includes("true");
|
||||
} else if (trimmed.startsWith("input")) {
|
||||
const match = trimmed.match(/input\s*=\s*\[(.*)\]/);
|
||||
if (match) {
|
||||
const items = match[1].match(/"([^"]*)"/g);
|
||||
if (items) data.input = items.map((s) => s.replace(/"/g, ""));
|
||||
}
|
||||
} else if (trimmed.startsWith("output")) {
|
||||
const match = trimmed.match(/output\s*=\s*\[(.*)\]/);
|
||||
if (match) {
|
||||
const items = match[1].match(/"([^"]*)"/g);
|
||||
if (items) data.output = items.map((s) => s.replace(/"/g, ""));
|
||||
}
|
||||
} else if (trimmed.match(/^no_nat\s*=/)) {
|
||||
data.no_nat = trimmed.includes("true");
|
||||
} else if (trimmed.match(/^no_tun\s*=/)) {
|
||||
data.no_tun = trimmed.includes("true");
|
||||
} else if (trimmed.startsWith("port_mapping")) {
|
||||
const match = trimmed.match(/port_mapping\s*=\s*\[(.*)\]/);
|
||||
if (match) {
|
||||
const items = match[1].match(/"([^"]*)"/g);
|
||||
if (items) data.port_mapping = items.map((s) => s.replace(/"/g, ""));
|
||||
}
|
||||
} else if (trimmed.match(/^allow_mapping\s*=/)) {
|
||||
data.allow_mapping = trimmed.includes("true");
|
||||
} else if (trimmed.includes("device_name")) {
|
||||
const match = trimmed.match(/device_name\s*=\s*"([^"]*)"/);
|
||||
if (match) data.device_name = match[1];
|
||||
} else if (trimmed.includes("device_id")) {
|
||||
const match = trimmed.match(/device_id\s*=\s*"([^"]*)"/);
|
||||
if (match) data.device_id = match[1];
|
||||
} else if (trimmed.includes("tun_name")) {
|
||||
const match = trimmed.match(/tun_name\s*=\s*"([^"]*)"/);
|
||||
if (match) data.tun_name = match[1];
|
||||
} else if (trimmed.includes("password =")) {
|
||||
const match = trimmed.match(/password\s*=\s*"([^"]*)"/);
|
||||
if (match) data.password = match[1];
|
||||
} else if (trimmed.includes("cert_mode")) {
|
||||
const match = trimmed.match(/cert_mode\s*=\s*"([^"]*)"/);
|
||||
if (match) {
|
||||
const value = match[1];
|
||||
if (value.startsWith("finger:")) {
|
||||
data.cert_mode = "finger";
|
||||
data.fingerprint = value.substring(7); // 去掉 "finger:" 前缀
|
||||
} else {
|
||||
data.cert_mode = value;
|
||||
}
|
||||
}
|
||||
} else if (trimmed.startsWith("udp_stun")) {
|
||||
const match = trimmed.match(/udp_stun\s*=\s*\[(.*)\]/);
|
||||
if (match) {
|
||||
const items = match[1].match(/"([^"]*)"/g);
|
||||
if (items) data.udp_stun = items.map((s) => s.replace(/"/g, ""));
|
||||
}
|
||||
} else if (trimmed.startsWith("tcp_stun")) {
|
||||
const match = trimmed.match(/tcp_stun\s*=\s*\[(.*)\]/);
|
||||
if (match) {
|
||||
const items = match[1].match(/"([^"]*)"/g);
|
||||
if (items) data.tcp_stun = items.map((s) => s.replace(/"/g, ""));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
// 从表单生成TOML
|
||||
export const formToToml = (formData) => {
|
||||
let toml = "";
|
||||
|
||||
if (formData.config_name) {
|
||||
toml += `# 配置名称\nconfig_name = "${formData.config_name}"\n`;
|
||||
}
|
||||
|
||||
toml += "\n# --- 网络配置 ---\n";
|
||||
toml += "# 网络编号,相同网络编号的会组在同一个虚拟网 (必填)\n";
|
||||
toml += `network_code = "${formData.network_code}"\n\n`;
|
||||
|
||||
const servers = formData.server.filter((s) => s.trim());
|
||||
if (servers.length > 0) {
|
||||
toml += "# 服务器地址列表(支持 quic / tcp / wss / dynamic) (必填)\n";
|
||||
toml += "# dynamic 协议使用dns txt解析记录值\n";
|
||||
toml += `server = [${servers.map((s) => `"${s}"`).join(", ")}]\n`;
|
||||
}
|
||||
|
||||
if (formData.ip) {
|
||||
toml += "\n# 自定义虚拟 IP (可选)\n";
|
||||
toml += `ip = "${formData.ip}"\n`;
|
||||
}
|
||||
|
||||
if (formData.rtx) {
|
||||
toml += "\n# 是否启用quic优化传输 (默认 false)\n";
|
||||
toml += "# 开启后传输过程几乎不会丢包,但是延迟可能会有波动\n";
|
||||
toml += "rtx = true\n";
|
||||
}
|
||||
|
||||
if (formData.fec) {
|
||||
toml += "\n# 是否启用 FEC 前向纠错 (默认 false)\n";
|
||||
toml += "# 开启后可以减少丢包率,损失带宽但是延迟比较稳定,带宽充足时可以使用此功能\n";
|
||||
toml += "fec = true\n";
|
||||
}
|
||||
|
||||
if (formData.no_punch) {
|
||||
toml += "\n# 是否关闭 P2P 打洞 (默认 false)\n";
|
||||
toml += "no_punch = true\n";
|
||||
}
|
||||
|
||||
if (formData.compress) {
|
||||
toml += "\n# 是否启用 LZ4 压缩 (默认 false)\n";
|
||||
toml += "compress = true\n";
|
||||
}
|
||||
|
||||
const inputs = formData.input.filter((s) => s.trim());
|
||||
if (inputs.length > 0) {
|
||||
toml += "\n# 入栈监听网段 (逗号分隔的 CIDR 和目标 IP),用于点对网,将指定网段的流量发送到目标节点\n";
|
||||
toml += "# 例如192.168.0.0/24,10.26.0.2 表示将192.168.0.0/24网段的数据转发到10.26.0.2\n";
|
||||
toml += `input = [${inputs.map((s) => `"${s}"`).join(", ")}]\n`;
|
||||
}
|
||||
|
||||
const outputs = formData.output.filter((s) => s.trim());
|
||||
if (outputs.length > 0) {
|
||||
toml += "\n# 出栈允许网段,用于点对网,允许指定网段的转发\n";
|
||||
toml += `output = [${outputs.map((s) => `"${s}"`).join(", ")}]\n`;
|
||||
}
|
||||
|
||||
if (formData.no_nat) {
|
||||
toml += "\n# 是否关闭内置子网NAT,关闭后需要配置网卡转发,否则无法使用点对网\n";
|
||||
toml += "# 通常关闭内置子网NAT,使用系统的网卡转发,点对网性能会更好\n";
|
||||
toml += "no_nat = true\n";
|
||||
}
|
||||
|
||||
if (formData.no_tun) {
|
||||
toml += "\n# 是否关闭TUN虚拟网卡,关闭后只能充当流量出口或者进行端口映射,关闭后无需管理员权限\n";
|
||||
toml += "no_tun = true\n";
|
||||
}
|
||||
|
||||
const portMappings = formData.port_mapping.filter((s) => s.trim());
|
||||
if (portMappings.length > 0) {
|
||||
toml += "\n# 端口映射,格式为:协议://本地监听地址-目标虚拟IP-目标映射地址\n";
|
||||
toml += "# 端口映射用于在本地监听指定端口,并将收到的网络流量经由指定虚拟节点转发到目标地址\n";
|
||||
toml += "# 例如: tcp://0.0.0.0:81-10.0.0.2-10.0.0.2:80 表示将本地tcp的81端口的数据转发到10.0.0.2:80\n";
|
||||
toml += "# 例如: tcp://0.0.0.0:81-10.0.0.2-192.168.1.10:80 则表示将本地tcp的81端口的数据经过10.0.0.2转到192.168.1.10:80\n";
|
||||
toml += "# 例如: tcp://0.0.0.0:81-10.0.0.2-anyonehost:80 则表示将本地tcp的81端口的数据经过10.0.0.2转到anyonehost:80\n";
|
||||
toml += `port_mapping = [${portMappings.map((s) => `"${s}"`).join(", ")}]\n`;
|
||||
}
|
||||
|
||||
if (formData.allow_mapping) {
|
||||
toml += '\n# 是否允许作为端口映射出口,开启后其他设备才可使用本设备的ip为"目标虚拟IP"\n';
|
||||
toml += "# 开启后虚拟网络其他设备可以使用此设备当跳板访问其他网络\n";
|
||||
toml += "allow_mapping = true\n";
|
||||
}
|
||||
|
||||
if (formData.mtu) {
|
||||
toml += "\n# MTU 设置\n";
|
||||
toml += `mtu = ${formData.mtu}\n`;
|
||||
}
|
||||
|
||||
toml += "\n# --- 设备配置 ---\n";
|
||||
if (formData.device_name) {
|
||||
toml += "\n# 设备名称 (可选,默认读取本机 hostname)\n";
|
||||
toml += `device_name = "${formData.device_name}"\n`;
|
||||
}
|
||||
if (formData.device_id) {
|
||||
toml += "\n# 设备 ID (可选,不填自动生成,不同设备ID不能相同)\n";
|
||||
toml += `device_id = "${formData.device_id}"\n`;
|
||||
}
|
||||
if (formData.tun_name) {
|
||||
toml += "\n# 虚拟网卡名称\n";
|
||||
toml += `tun_name = "${formData.tun_name}"\n`;
|
||||
}
|
||||
|
||||
toml += "\n# --- 安全配置 ---\n";
|
||||
if (formData.password) {
|
||||
toml += "\n# 组网加密密码 (可选)\n";
|
||||
toml += `password = "${formData.password}"\n`;
|
||||
}
|
||||
if (formData.cert_mode && formData.cert_mode !== "skip") {
|
||||
toml += "\n# 证书校验方式:\n";
|
||||
toml += "# skip 跳过验证(默认)\n";
|
||||
toml += "# standard 使用系统证书验证\n";
|
||||
toml += "# finger 使用证书指纹验证,服务端启动时日志会输出指纹\n";
|
||||
if (formData.cert_mode === "finger" && formData.fingerprint) {
|
||||
toml += `cert_mode = "finger:${formData.fingerprint}"\n`;
|
||||
} else {
|
||||
toml += `cert_mode = "${formData.cert_mode}"\n`;
|
||||
}
|
||||
}
|
||||
|
||||
const udpStuns = formData.udp_stun.filter((s) => s.trim());
|
||||
if (udpStuns.length > 0) {
|
||||
toml += "\n# 自定义UDP STUN地址,不设置则用默认stun\n";
|
||||
toml += `udp_stun = [${udpStuns.map((s) => `"${s}"`).join(", ")}]\n`;
|
||||
}
|
||||
|
||||
const tcpStuns = formData.tcp_stun.filter((s) => s.trim());
|
||||
if (tcpStuns.length > 0) {
|
||||
toml += "\n# 自定义TCP STUN地址,不设置则用默认stun\n";
|
||||
toml += `tcp_stun = [${tcpStuns.map((s) => `"${s}"`).join(", ")}]\n`;
|
||||
}
|
||||
|
||||
return toml;
|
||||
};
|
||||
|
||||
// 新建配置的 TOML 模板(从旧代码逐字迁移)
|
||||
export const NEW_CONFIG_TEMPLATE = `# config_name = "配置名称"
|
||||
# --- 网络配置 ---
|
||||
# 网络编号,相同网络编号的会组在同一个虚拟网 (必填)
|
||||
network_code = "your_network_code"
|
||||
|
||||
# 服务器地址列表(支持 quic / tcp / wss / dynamic) (必填)
|
||||
# dynamic 协议使用dns txt解析记录值
|
||||
server = ["quic://1.2.3.4:29872"]
|
||||
|
||||
# ===简单使用以下参数可以不动===
|
||||
|
||||
# 自定义虚拟 IP (可选)
|
||||
# ip = "10.10.0.2"
|
||||
|
||||
# 是否启用quic优化传输 (默认 false,设置为true时开启)
|
||||
# 开启后传输过程几乎不会丢包,但是延迟会有波动
|
||||
# rtx = false
|
||||
|
||||
# 是否启用 FEC 前向纠错,损失一定带宽来提升网络稳定性(默认 false,设置为true时开启)
|
||||
# 开启后可以减少丢包率,损失带宽但是延迟比较稳定,带宽充足时可以使用此功能
|
||||
# fec = false
|
||||
|
||||
# 是否关闭 P2P 打洞 (默认 false,设置为true时关闭)
|
||||
# no_punch = false
|
||||
|
||||
# 是否启用 LZ4 压缩 (默认 false,设置为true时开启)
|
||||
# compress = false
|
||||
|
||||
# 入栈监听网段 (逗号分隔的 CIDR 和目标 IP),用于点对网,将指定网段的流量发送到目标节点
|
||||
# input = ["192.168.0.0/24,10.26.0.2", "192.168.1.0/24,10.26.0.3"]
|
||||
|
||||
# 出栈允许网段,用于点对网,允许指定网段的转发
|
||||
# output = ["0.0.0.0/0"]
|
||||
|
||||
# 是否关闭内置子网NAT,关闭(设为true)后需要配置网卡转发,否则无法使用点对网。通常关闭内置子网NAT,使用系统的网卡转发,点对网性能会更好
|
||||
# no_nat = false
|
||||
|
||||
# 是否关闭TUN虚拟网卡,关闭(设为true)后只能充当流量出口或者进行端口映射,关闭后无需管理员权限
|
||||
# no_tun = false
|
||||
|
||||
# 端口映射,格式为:协议://本地监听地址-目标虚拟IP-目标映射地址
|
||||
# 端口映射用于在本地监听指定端口,并将收到的网络流量经由指定虚拟节点转发到目标地址,从而实现跨网络或内网服务访问
|
||||
# 例如 port_mapping = ["tcp://0.0.0.0:81-10.0.0.2-10.0.0.2:80"]
|
||||
# tcp://0.0.0.0:81-10.0.0.2-10.0.0.2:80 则表示将本地tcp的81端口的数据转发到10.0.0.2:80
|
||||
# tcp://0.0.0.0:81-10.0.0.2-192.168.1.10:80 则表示将本地tcp的81端口的数据经过10.0.0.2转到192.168.1.10:80
|
||||
# tcp://0.0.0.0:81-10.0.0.2-anyonehost:80 则表示将本地tcp的81端口的数据经过10.0.0.2转到anyonehost:80
|
||||
# port_mapping = []
|
||||
|
||||
# 是否允许作为端口映射出口,开启(设置为true)后其他设备才可使用本设备的ip为"目标虚拟IP"
|
||||
# 开启后虚拟网络其他设备可以使用此设备当跳板访问其他网络
|
||||
# allow_mapping = false
|
||||
|
||||
# MTU 设置
|
||||
# mtu = 1400
|
||||
|
||||
# --- 设备配置 ---
|
||||
|
||||
# 设备名称 (可选,默认读取本机 hostname)
|
||||
# device_name = "my-device"
|
||||
|
||||
# 设备 ID (可选,不填自动生成,不同设备ID不能相同)
|
||||
# device_id = "device-id-xxxx"
|
||||
|
||||
# 虚拟网卡名称
|
||||
# tun_name = "vnt-tun"
|
||||
|
||||
# --- 安全配置 ---
|
||||
|
||||
# 加密密码 (可选)
|
||||
# password = "123456"
|
||||
|
||||
# 证书校验方式:
|
||||
# skip 跳过验证(默认)
|
||||
# standard 使用系统证书验证
|
||||
# finger 使用证书指纹验证,服务端启动时日志会输出指纹,
|
||||
# 例如 finger:3bdd8675606837cdf95d5e13445606315762315a78555f9da652940a25feaec1
|
||||
# cert_mode = "skip"
|
||||
|
||||
# --- 其他配置 ---
|
||||
# 自定义stun地址,分别用于udp打洞和tcp打洞,需要单独配置,不设置则用默认stun
|
||||
# udp_stun = ["stun.chat.bilibili.com"]
|
||||
# tcp_stun = ["stun.nextcloud.com:443"]`;
|
||||
@@ -0,0 +1,609 @@
|
||||
<script setup>
|
||||
import { ref, watch, nextTick } from "vue";
|
||||
import AppModal from "../components/AppModal.vue";
|
||||
import { useUiStore } from "../stores/ui";
|
||||
import { getConfig, saveConfig } from "../api";
|
||||
import { emptyFormData, parseTomlToForm, formToToml, NEW_CONFIG_TEMPLATE } from "../utils/toml";
|
||||
|
||||
const props = defineProps({
|
||||
show: { type: Boolean, default: false },
|
||||
// null 表示新建
|
||||
fileName: { type: String, default: null },
|
||||
});
|
||||
|
||||
const emit = defineEmits(["close", "saved"]);
|
||||
|
||||
const ui = useUiStore();
|
||||
|
||||
const editorContent = ref("");
|
||||
const editorFileName = ref("");
|
||||
const editorMode = ref("new"); // 'new' 或 'edit'
|
||||
const editMode = ref("form"); // 'form' 或 'toml'
|
||||
const originalToml = ref(""); // 保存原始TOML内容(包含用户注释)
|
||||
const hasTomlChanges = ref(false);
|
||||
const hasFormChanges = ref(false);
|
||||
const isParsingToml = ref(false);
|
||||
const formData = ref(emptyFormData());
|
||||
|
||||
// 打开时加载内容
|
||||
watch(
|
||||
() => props.show,
|
||||
async (val) => {
|
||||
if (!val) return;
|
||||
editorFileName.value = props.fileName || "";
|
||||
editorMode.value = props.fileName ? "edit" : "new";
|
||||
editMode.value = "form"; // 默认表单模式
|
||||
hasTomlChanges.value = false;
|
||||
hasFormChanges.value = false;
|
||||
|
||||
if (props.fileName) {
|
||||
try {
|
||||
const data = await getConfig(props.fileName);
|
||||
editorContent.value = data;
|
||||
originalToml.value = data; // 保存原始TOML
|
||||
isParsingToml.value = true;
|
||||
formData.value = parseTomlToForm(data);
|
||||
nextTick(() => {
|
||||
isParsingToml.value = false;
|
||||
});
|
||||
} catch (e) {
|
||||
ui.toast.error("获取配置失败: " + e.message);
|
||||
emit("close");
|
||||
}
|
||||
} else {
|
||||
// 新建配置,初始化表单
|
||||
originalToml.value = "";
|
||||
formData.value = emptyFormData();
|
||||
editorContent.value = NEW_CONFIG_TEMPLATE;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// 切换到表单模式
|
||||
const switchToFormMode = () => {
|
||||
if (editMode.value === "toml") {
|
||||
editMode.value = "form";
|
||||
// 从TOML解析到表单
|
||||
isParsingToml.value = true;
|
||||
formData.value = parseTomlToForm(editorContent.value);
|
||||
nextTick(() => {
|
||||
isParsingToml.value = false;
|
||||
});
|
||||
} else {
|
||||
editMode.value = "form";
|
||||
}
|
||||
};
|
||||
|
||||
// 切换到TOML模式
|
||||
const switchToTomlMode = () => {
|
||||
if (editMode.value === "form") {
|
||||
// 如果表单被修改过,生成新的TOML
|
||||
if (hasFormChanges.value) {
|
||||
editorContent.value = formToToml(formData.value);
|
||||
hasFormChanges.value = false;
|
||||
} else if (originalToml.value && !hasTomlChanges.value) {
|
||||
// 表单未修改且TOML未修改,使用原始TOML(保留用户注释)
|
||||
editorContent.value = originalToml.value;
|
||||
} else {
|
||||
editorContent.value = formToToml(formData.value);
|
||||
}
|
||||
}
|
||||
editMode.value = "toml";
|
||||
};
|
||||
|
||||
// 监听TOML内容变化(只在TOML模式下)
|
||||
watch(editorContent, (newVal, oldVal) => {
|
||||
if (editMode.value === "toml" && oldVal !== undefined) {
|
||||
hasTomlChanges.value = true;
|
||||
}
|
||||
});
|
||||
|
||||
// 监听表单数据变化
|
||||
watch(
|
||||
formData,
|
||||
() => {
|
||||
if (editMode.value === "form" && props.show && !isParsingToml.value) {
|
||||
hasFormChanges.value = true;
|
||||
}
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
// 表单模式先转换为TOML
|
||||
let configContent = editorContent.value;
|
||||
if (editMode.value === "form") {
|
||||
// 验证必填项
|
||||
if (!formData.value.network_code.trim()) {
|
||||
ui.toast.error("请填写网络编号");
|
||||
return;
|
||||
}
|
||||
const servers = formData.value.server.filter((s) => s.trim());
|
||||
if (servers.length === 0) {
|
||||
ui.toast.error("请至少填写一个服务器地址");
|
||||
return;
|
||||
}
|
||||
configContent = formToToml(formData.value);
|
||||
}
|
||||
|
||||
await saveConfig(editorFileName.value || null, configContent);
|
||||
ui.toast.success("配置已保存");
|
||||
emit("close");
|
||||
emit("saved");
|
||||
} catch (e) {
|
||||
ui.toast.error("保存失败: " + e.message);
|
||||
}
|
||||
};
|
||||
|
||||
// 表单分组内的小型重复控件
|
||||
const checkboxClass =
|
||||
"h-5 w-5 rounded border-slate-300 bg-white text-indigo-600 focus:ring-indigo-500 dark:border-slate-600 dark:bg-slate-700";
|
||||
const toggleLabelClass =
|
||||
"flex cursor-pointer items-center justify-between gap-2 rounded-lg border border-slate-200 bg-slate-50 p-3 transition-colors hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-800/50 dark:hover:bg-slate-800";
|
||||
const removeBtnClass =
|
||||
"shrink-0 rounded-lg bg-red-50 px-3 py-2 text-red-500 transition-colors hover:bg-red-100 dark:bg-red-900/20 dark:text-red-500 dark:hover:bg-red-900/40";
|
||||
const addBtnClass =
|
||||
"flex w-full items-center justify-center gap-1 rounded-lg bg-slate-100 px-3 py-2 text-sm text-slate-600 transition-colors hover:bg-slate-200 dark:bg-slate-700/50 dark:text-slate-300 dark:hover:bg-slate-700";
|
||||
const sectionTitleClass = "text-md mb-4 flex items-center font-bold text-slate-900 dark:text-white";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppModal
|
||||
:show="show"
|
||||
:mask-closable="false"
|
||||
panel-class="w-full max-w-6xl h-[85vh]"
|
||||
@close="emit('close')"
|
||||
>
|
||||
<template #header>
|
||||
<h3 class="text-lg font-bold text-slate-900 dark:text-white">
|
||||
{{ editorMode === "new" ? "新建配置" : "编辑配置" }}
|
||||
</h3>
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- 模式切换按钮 -->
|
||||
<div class="flex rounded-lg border border-slate-300 bg-slate-100 p-1 dark:border-slate-600 dark:bg-slate-800">
|
||||
<button
|
||||
@click="switchToFormMode"
|
||||
:class="editMode === 'form' ? 'bg-white text-slate-900 shadow-sm dark:bg-indigo-600 dark:text-white' : 'text-slate-500 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white'"
|
||||
class="px-4 py-1.5 rounded text-sm font-medium transition-colors flex items-center"
|
||||
>
|
||||
<svg class="w-4 h-4 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
表单模式
|
||||
</button>
|
||||
<button
|
||||
@click="switchToTomlMode"
|
||||
:class="editMode === 'toml' ? 'bg-white text-slate-900 shadow-sm dark:bg-indigo-600 dark:text-white' : 'text-slate-500 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white'"
|
||||
class="px-4 py-1.5 rounded text-sm font-medium transition-colors flex items-center"
|
||||
>
|
||||
<svg class="w-4 h-4 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"
|
||||
/>
|
||||
</svg>
|
||||
TOML模式
|
||||
</button>
|
||||
</div>
|
||||
<div class="text-sm text-slate-500 font-mono hidden md:block" v-if="editorFileName">
|
||||
{{ editorFileName }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #body>
|
||||
<!-- 表单模式 -->
|
||||
<div v-show="editMode === 'form'" class="h-full overflow-y-auto scrollbar-hide p-6">
|
||||
<div class="max-w-4xl mx-auto space-y-6">
|
||||
<!-- 基础配置 -->
|
||||
<div class="card">
|
||||
<h4 :class="sectionTitleClass">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
基础配置
|
||||
</h4>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-slate-600 dark:text-slate-300">配置名称</label>
|
||||
<input v-model="formData.config_name" type="text" placeholder="例如: 我的VPN配置" class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-slate-600 dark:text-slate-300">
|
||||
网络编号 <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input v-model="formData.network_code" type="text" placeholder="例如: my_network" required class="input" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<label class="mb-2 block text-sm font-medium text-slate-600 dark:text-slate-300">
|
||||
服务器地址 <span class="text-red-500">*</span>
|
||||
<span class="text-xs text-slate-500 ml-2">支持 quic:// tcp:// wss:// dynamic://</span>
|
||||
</label>
|
||||
<div class="space-y-2">
|
||||
<div v-for="(server, idx) in formData.server" :key="idx" class="flex gap-2">
|
||||
<input v-model="formData.server[idx]" type="text" placeholder="例如: quic://1.2.3.4:29872" class="input flex-1" />
|
||||
<button @click="formData.server.splice(idx, 1)" v-if="formData.server.length > 1" :class="removeBtnClass">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
@click="formData.server.push('')"
|
||||
:class="addBtnClass"
|
||||
>
|
||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
添加服务器
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 网络设置 -->
|
||||
<div class="card">
|
||||
<h4 :class="sectionTitleClass">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"
|
||||
/>
|
||||
</svg>
|
||||
网络设置
|
||||
</h4>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-slate-600 dark:text-slate-300">
|
||||
自定义虚拟IP
|
||||
<span class="text-xs text-slate-500 ml-1">(可选)</span>
|
||||
</label>
|
||||
<input v-model="formData.ip" type="text" placeholder="例如: 10.26.0.2" class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-slate-600 dark:text-slate-300">MTU</label>
|
||||
<input v-model.number="formData.mtu" type="number" placeholder="1380" class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-slate-600 dark:text-slate-300">隧道端口</label>
|
||||
<input v-model.number="formData.tunnel_port" type="number" placeholder="0 (自动分配)" class="input" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 传输优化 -->
|
||||
<div class="card">
|
||||
<h4 :class="sectionTitleClass">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
传输优化
|
||||
</h4>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<label :class="toggleLabelClass">
|
||||
<div class="flex-1">
|
||||
<div class="text-sm font-medium text-slate-800 dark:text-white">QUIC传输优化</div>
|
||||
<div class="text-xs text-slate-400 mt-0.5">重传丢包</div>
|
||||
</div>
|
||||
<input v-model="formData.rtx" type="checkbox" :class="checkboxClass" />
|
||||
</label>
|
||||
<label :class="toggleLabelClass">
|
||||
<div class="flex-1">
|
||||
<div class="text-sm font-medium text-slate-800 dark:text-white">FEC前向纠错</div>
|
||||
<div class="text-xs text-slate-400 mt-0.5">损失部分带宽提升稳定性</div>
|
||||
</div>
|
||||
<input v-model="formData.fec" type="checkbox" :class="checkboxClass" />
|
||||
</label>
|
||||
<label :class="toggleLabelClass">
|
||||
<div class="flex-1">
|
||||
<div class="text-sm font-medium text-slate-800 dark:text-white">LZ4压缩</div>
|
||||
<div class="text-xs text-slate-400 mt-0.5">减少传输数据量</div>
|
||||
</div>
|
||||
<input v-model="formData.compress" type="checkbox" :class="checkboxClass" />
|
||||
</label>
|
||||
<label :class="toggleLabelClass">
|
||||
<div class="flex-1">
|
||||
<div class="text-sm font-medium text-slate-800 dark:text-white">关闭P2P打洞</div>
|
||||
<div class="text-xs text-slate-400 mt-0.5">仅通过服务器中转</div>
|
||||
</div>
|
||||
<input v-model="formData.no_punch" type="checkbox" :class="checkboxClass" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 安全配置 -->
|
||||
<div class="card">
|
||||
<h4 :class="sectionTitleClass">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
|
||||
/>
|
||||
</svg>
|
||||
安全配置
|
||||
</h4>
|
||||
<div class="space-y-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-slate-600 dark:text-slate-300"
|
||||
>组网加密密码(连接公共服务器时建议填写,同一组网密码需要相同)</label
|
||||
>
|
||||
<input v-model="formData.password" type="password" placeholder="留空则不加密" class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-slate-600 dark:text-slate-300">服务端证书校验模式</label>
|
||||
<select v-model="formData.cert_mode" class="input">
|
||||
<option value="skip">跳过验证 (默认)</option>
|
||||
<option value="standard">系统证书验证</option>
|
||||
<option value="finger">证书指纹验证</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="formData.cert_mode === 'finger'">
|
||||
<label class="mb-2 block text-sm font-medium text-slate-600 dark:text-slate-300">
|
||||
证书指纹
|
||||
<span class="text-xs text-slate-500 ml-1">(服务端启动时日志会输出指纹)</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="formData.fingerprint"
|
||||
type="text"
|
||||
placeholder="例如: 3bdd8675606837cdf95d5e13445606315762315a78555f9da652940a25feaec1"
|
||||
class="input font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- NAT与路由 -->
|
||||
<div class="card">
|
||||
<h4 :class="sectionTitleClass">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="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-.98L15 9m0 0V7m0 2v6"
|
||||
/>
|
||||
</svg>
|
||||
NAT与路由 (点对网)
|
||||
</h4>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-slate-600 dark:text-slate-300">
|
||||
入栈网段
|
||||
<span class="text-xs text-slate-500 ml-1">格式: CIDR,目标IP</span>
|
||||
</label>
|
||||
<div class="space-y-2">
|
||||
<div v-for="(item, idx) in formData.input" :key="idx" class="flex gap-2">
|
||||
<input v-model="formData.input[idx]" type="text" placeholder="例如: 192.168.0.0/24,10.26.0.2" class="input flex-1" />
|
||||
<button @click="formData.input.splice(idx, 1)" :class="removeBtnClass">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
@click="formData.input.push('')"
|
||||
:class="addBtnClass"
|
||||
>
|
||||
+ 添加入栈网段
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-slate-600 dark:text-slate-300">
|
||||
出栈网段
|
||||
<span class="text-xs text-slate-500 ml-1">格式: CIDR (允许转发的网段)</span>
|
||||
</label>
|
||||
<div class="space-y-2">
|
||||
<div v-for="(item, idx) in formData.output" :key="idx" class="flex gap-2">
|
||||
<input v-model="formData.output[idx]" type="text" placeholder="例如: 0.0.0.0/0" class="input flex-1" />
|
||||
<button @click="formData.output.splice(idx, 1)" :class="removeBtnClass">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
@click="formData.output.push('')"
|
||||
:class="addBtnClass"
|
||||
>
|
||||
+ 添加出栈网段
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<label :class="toggleLabelClass">
|
||||
<div class="flex-1">
|
||||
<div class="text-sm font-medium text-slate-800 dark:text-white">关闭内置NAT</div>
|
||||
<div class="text-xs text-slate-400 mt-0.5">使用系统网卡转发</div>
|
||||
</div>
|
||||
<input v-model="formData.no_nat" type="checkbox" :class="checkboxClass" />
|
||||
</label>
|
||||
<label :class="toggleLabelClass">
|
||||
<div class="flex-1">
|
||||
<div class="text-sm font-medium text-slate-800 dark:text-white">关闭TUN网卡</div>
|
||||
<div class="text-xs text-slate-400 mt-0.5">仅作流量出口或端口映射</div>
|
||||
</div>
|
||||
<input v-model="formData.no_tun" type="checkbox" :class="checkboxClass" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 端口映射 -->
|
||||
<div class="card">
|
||||
<h4 :class="sectionTitleClass">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
端口映射
|
||||
</h4>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-slate-600 dark:text-slate-300">
|
||||
映射规则
|
||||
<span class="text-xs text-slate-500 ml-1">格式: 协议://监听地址-虚拟IP-目标地址</span>
|
||||
</label>
|
||||
<div class="space-y-2">
|
||||
<div v-for="(item, idx) in formData.port_mapping" :key="idx" class="flex gap-2">
|
||||
<input
|
||||
v-model="formData.port_mapping[idx]"
|
||||
type="text"
|
||||
placeholder="例如: tcp://0.0.0.0:81-10.0.0.2-10.0.0.2:80"
|
||||
class="input flex-1 font-mono"
|
||||
/>
|
||||
<button @click="formData.port_mapping.splice(idx, 1)" :class="removeBtnClass">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
@click="formData.port_mapping.push('')"
|
||||
:class="addBtnClass"
|
||||
>
|
||||
+ 添加映射规则
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<label :class="toggleLabelClass">
|
||||
<div class="flex-1">
|
||||
<div class="text-sm font-medium text-slate-800 dark:text-white">允许作为映射出口</div>
|
||||
<div class="text-xs text-slate-400 mt-0.5">允许其他设备使用本机作跳板</div>
|
||||
</div>
|
||||
<input v-model="formData.allow_mapping" type="checkbox" :class="checkboxClass" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 设备配置 -->
|
||||
<div class="card">
|
||||
<h4 :class="sectionTitleClass">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
设备配置
|
||||
</h4>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-slate-600 dark:text-slate-300">设备名称</label>
|
||||
<input v-model="formData.device_name" type="text" placeholder="默认为主机名" class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-slate-600 dark:text-slate-300">设备ID</label>
|
||||
<input v-model="formData.device_id" type="text" placeholder="自动生成" class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-slate-600 dark:text-slate-300">虚拟网卡名</label>
|
||||
<input v-model="formData.tun_name" type="text" placeholder="默认为vnt-tun" class="input" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- STUN配置 -->
|
||||
<div class="card">
|
||||
<h4 :class="sectionTitleClass">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
STUN配置 (高级)
|
||||
</h4>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-slate-600 dark:text-slate-300">UDP STUN服务器</label>
|
||||
<div class="space-y-2">
|
||||
<div v-for="(item, idx) in formData.udp_stun" :key="idx" class="flex gap-2">
|
||||
<input v-model="formData.udp_stun[idx]" type="text" placeholder="例如: stun.l.google.com:19302" class="input flex-1" />
|
||||
<button @click="formData.udp_stun.splice(idx, 1)" :class="removeBtnClass">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
@click="formData.udp_stun.push('')"
|
||||
:class="addBtnClass"
|
||||
>
|
||||
+ 添加UDP STUN
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-slate-600 dark:text-slate-300">TCP STUN服务器</label>
|
||||
<div class="space-y-2">
|
||||
<div v-for="(item, idx) in formData.tcp_stun" :key="idx" class="flex gap-2">
|
||||
<input v-model="formData.tcp_stun[idx]" type="text" placeholder="例如: stun.nextcloud.com:443" class="input flex-1" />
|
||||
<button @click="formData.tcp_stun.splice(idx, 1)" :class="removeBtnClass">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
@click="formData.tcp_stun.push('')"
|
||||
:class="addBtnClass"
|
||||
>
|
||||
+ 添加TCP STUN
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TOML模式 -->
|
||||
<div v-show="editMode === 'toml'" class="h-full">
|
||||
<textarea
|
||||
v-model="editorContent"
|
||||
class="h-full w-full resize-none bg-slate-50 p-4 font-mono text-sm text-slate-800 focus:outline-none dark:bg-slate-950 dark:text-slate-200"
|
||||
spellcheck="false"
|
||||
placeholder="# 在此处输入 TOML 配置..."
|
||||
></textarea>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex-1 text-left text-xs text-slate-500">
|
||||
<span v-if="editMode === 'form'">填写完成后保存即可生成配置文件</span>
|
||||
<span v-else>* 请使用标准 TOML 格式</span>
|
||||
</div>
|
||||
<button class="btn-ghost" @click="emit('close')">取消</button>
|
||||
<button class="btn-primary" @click="handleSave">保存配置</button>
|
||||
</template>
|
||||
</AppModal>
|
||||
</template>
|
||||
@@ -0,0 +1,135 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from "vue";
|
||||
import { useAppStore } from "../stores/app";
|
||||
import { useUiStore } from "../stores/ui";
|
||||
import { deleteConfig } from "../api";
|
||||
import EmptyState from "../components/EmptyState.vue";
|
||||
import ConfigEditor from "./ConfigEditor.vue";
|
||||
|
||||
const app = useAppStore();
|
||||
const ui = useUiStore();
|
||||
|
||||
const showEditor = ref(false);
|
||||
const editorFileName = ref(null);
|
||||
|
||||
// 配置对应的实例运行状态(无实例返回 null)
|
||||
const instStatus = (fileName) => {
|
||||
const inst = app.instanceList.find((i) => i.file_name === fileName);
|
||||
return inst ? inst.status : null;
|
||||
};
|
||||
|
||||
const cardClass = (fileName) => {
|
||||
const status = instStatus(fileName);
|
||||
if (status === "running") return "ring-2 ring-green-500/60";
|
||||
if (status === "starting") return "ring-2 ring-indigo-500/60";
|
||||
return "";
|
||||
};
|
||||
|
||||
const openEditor = (fileName) => {
|
||||
editorFileName.value = fileName;
|
||||
showEditor.value = true;
|
||||
};
|
||||
|
||||
const onSaved = () => {
|
||||
app.fetchConfigList();
|
||||
};
|
||||
|
||||
const handleDelete = async (fileName) => {
|
||||
const ok = await ui.confirm({
|
||||
title: "删除配置",
|
||||
message: `确定要删除配置 ${fileName} 吗?`,
|
||||
danger: true,
|
||||
confirmText: "删除",
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await deleteConfig(fileName);
|
||||
ui.toast.success("配置已删除");
|
||||
app.fetchConfigList();
|
||||
} catch (e) {
|
||||
ui.toast.error(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => app.fetchConfigList());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="page-title">配置</h1>
|
||||
<p class="page-subtitle">管理组网配置文件</p>
|
||||
</div>
|
||||
<button class="btn-primary" @click="openEditor(null)">
|
||||
<svg 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 4v16m8-8H4" />
|
||||
</svg>
|
||||
新建配置
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<EmptyState v-if="app.configList.length === 0" text="暂无配置,点击右上角新建" />
|
||||
|
||||
<div v-else class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
<div
|
||||
v-for="cfg in app.configList"
|
||||
:key="cfg.file_name"
|
||||
:class="cardClass(cfg.file_name)"
|
||||
class="card group relative flex min-h-[140px] cursor-pointer flex-col justify-between overflow-hidden"
|
||||
@click="openEditor(cfg.file_name)"
|
||||
>
|
||||
<div
|
||||
v-if="instStatus(cfg.file_name) === 'running'"
|
||||
class="absolute right-0 top-0 rounded-bl bg-green-500 px-2 py-1 text-xs text-white"
|
||||
>
|
||||
运行中
|
||||
</div>
|
||||
<div
|
||||
v-else-if="instStatus(cfg.file_name) === 'starting'"
|
||||
class="absolute right-0 top-0 rounded-bl bg-indigo-500 px-2 py-1 text-xs text-white"
|
||||
>
|
||||
启动中
|
||||
</div>
|
||||
<div class="flex items-start">
|
||||
<div class="mr-3 mt-1 rounded-lg bg-indigo-50 p-2 text-indigo-600 dark:bg-indigo-500/10 dark:text-indigo-400">
|
||||
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="overflow-hidden">
|
||||
<h3 class="truncate text-lg font-bold text-slate-900 dark:text-white" :title="cfg.config_name">
|
||||
{{ cfg.config_name || "Unnamed" }}
|
||||
</h3>
|
||||
<p class="truncate font-mono text-xs text-slate-400" :title="cfg.file_name">
|
||||
{{ cfg.file_name }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 flex justify-end opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<button
|
||||
class="mr-4 text-sm text-indigo-600 hover:text-indigo-500 dark:text-indigo-400"
|
||||
@click.stop="openEditor(cfg.file_name)"
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
<button class="text-sm text-red-500 hover:text-red-400" @click.stop="handleDelete(cfg.file_name)">
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfigEditor
|
||||
:show="showEditor"
|
||||
:file-name="editorFileName"
|
||||
@close="showEditor = false"
|
||||
@saved="onSaved"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,103 @@
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
import { useAppStore } from "../stores/app";
|
||||
import StartPanel from "../components/StartPanel.vue";
|
||||
import InstanceCard from "../components/InstanceCard.vue";
|
||||
import EmptyState from "../components/EmptyState.vue";
|
||||
|
||||
const app = useAppStore();
|
||||
|
||||
// 在线设备数:汇总各 running 实例 info 的在线数
|
||||
const onlineDevices = computed(() =>
|
||||
Object.values(app.instances).reduce(
|
||||
(sum, info) => sum + (info?.online_client_num || 0),
|
||||
0,
|
||||
),
|
||||
);
|
||||
|
||||
// 服务器连接:已连接的 running 实例数 / running 总数
|
||||
const serverConnected = computed(() => {
|
||||
const running = app.instanceList.filter((i) => i.status === "running");
|
||||
const connected = running.filter((i) => {
|
||||
const info = app.instances[i.file_name];
|
||||
return info?.server_info?.some((s) => s.connected);
|
||||
});
|
||||
return { connected: connected.length, total: running.length };
|
||||
});
|
||||
|
||||
const statusSummary = computed(() => {
|
||||
if (app.runningCount > 0) return `运行中 ${app.runningCount} 个实例`;
|
||||
if (app.startingCount > 0) return "有实例正在启动...";
|
||||
return "全部实例已停止";
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- 欢迎/状态区 -->
|
||||
<div class="flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="page-title">总览</h1>
|
||||
<p class="page-subtitle">{{ statusSummary }}</p>
|
||||
</div>
|
||||
<router-link to="/config" class="btn-ghost btn-sm">管理配置</router-link>
|
||||
</div>
|
||||
|
||||
<!-- 统计卡片 -->
|
||||
<div class="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||
<div class="card">
|
||||
<div class="text-xs font-medium muted">运行实例</div>
|
||||
<div class="mt-2 text-2xl font-bold tabular-nums text-slate-900 dark:text-white">
|
||||
{{ app.runningCount
|
||||
}}<span class="ml-1 text-sm font-normal muted">/ {{ app.instanceList.length }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="text-xs font-medium muted">配置总数</div>
|
||||
<div class="mt-2 text-2xl font-bold tabular-nums text-slate-900 dark:text-white">
|
||||
{{ app.configList.length }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="text-xs font-medium muted">在线设备</div>
|
||||
<div class="mt-2 text-2xl font-bold tabular-nums text-slate-900 dark:text-white">
|
||||
{{ onlineDevices }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="text-xs font-medium muted">服务器连接</div>
|
||||
<div class="mt-2 text-2xl font-bold tabular-nums text-slate-900 dark:text-white">
|
||||
<template v-if="serverConnected.total > 0">
|
||||
<span :class="serverConnected.connected > 0 ? 'text-green-600 dark:text-green-400' : 'text-red-500'">{{
|
||||
serverConnected.connected
|
||||
}}</span>
|
||||
<span class="text-sm font-normal muted">/ {{ serverConnected.total }} 已连接</span>
|
||||
</template>
|
||||
<span v-else class="muted">-</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 启动组网 -->
|
||||
<StartPanel />
|
||||
|
||||
<!-- 实例概览 -->
|
||||
<div>
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<h2 class="text-base font-bold text-slate-900 dark:text-white">实例概览</h2>
|
||||
<router-link
|
||||
to="/instances"
|
||||
class="text-sm font-medium text-indigo-600 hover:text-indigo-500 dark:text-indigo-400"
|
||||
>查看详情 →</router-link
|
||||
>
|
||||
</div>
|
||||
<EmptyState
|
||||
v-if="app.instanceList.length === 0"
|
||||
:text="app.configList.length === 0 ? '暂无配置,请先新建配置' : '暂无运行中的组网,请在上方选择配置启动'"
|
||||
/>
|
||||
<div v-else class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<InstanceCard v-for="inst in app.instanceList" :key="inst.file_name" :inst="inst" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,152 @@
|
||||
<script setup>
|
||||
import { useAppStore } from "../stores/app";
|
||||
import StartPanel from "../components/StartPanel.vue";
|
||||
import InstanceCard from "../components/InstanceCard.vue";
|
||||
import EmptyState from "../components/EmptyState.vue";
|
||||
|
||||
const app = useAppStore();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- 页面标题 -->
|
||||
<div>
|
||||
<h1 class="page-title">实例</h1>
|
||||
<p class="page-subtitle">管理组网实例,点击卡片查看详情</p>
|
||||
</div>
|
||||
|
||||
<!-- 启动组网 -->
|
||||
<StartPanel />
|
||||
|
||||
<!-- 组网实例 -->
|
||||
<EmptyState v-if="app.instanceList.length === 0" text="暂无运行中的组网,请在上方选择配置启动" />
|
||||
<div v-else class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<InstanceCard v-for="inst in app.instanceList" :key="inst.file_name" :inst="inst" selectable />
|
||||
</div>
|
||||
|
||||
<!-- 选中实例详情 -->
|
||||
<template v-if="app.selectedInstance && app.selectedInfo">
|
||||
<div class="card">
|
||||
<h2 class="mb-4 text-lg font-bold text-slate-900 dark:text-white">
|
||||
网络详情
|
||||
<span class="ml-2 text-sm font-medium text-indigo-600 dark:text-indigo-400">{{
|
||||
app.selectedConfigName
|
||||
}}</span>
|
||||
</h2>
|
||||
<div class="grid grid-cols-1 gap-4 text-sm md:grid-cols-2">
|
||||
<div class="flex justify-between border-b border-slate-100 pb-2 dark:border-slate-700">
|
||||
<span class="muted">虚拟 IP / 掩码</span>
|
||||
<span class="font-mono tabular-nums text-slate-900 dark:text-white"
|
||||
>{{ app.selectedInfo.ip || "-" }} / {{ app.selectedInfo.prefix_len || "-" }}</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex justify-between border-b border-slate-100 pb-2 dark:border-slate-700">
|
||||
<span class="muted">网关</span>
|
||||
<span class="font-mono text-slate-900 dark:text-white">{{ app.selectedInfo.gateway || "-" }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between border-b border-slate-100 pb-2 dark:border-slate-700">
|
||||
<span class="muted">网络编号</span>
|
||||
<span class="font-mono text-slate-900 dark:text-white">{{ app.selectedInfo.network_code || "-" }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between border-b border-slate-100 pb-2 dark:border-slate-700">
|
||||
<span class="muted">MTU</span>
|
||||
<span class="font-mono tabular-nums text-slate-900 dark:text-white">{{ app.selectedInfo.mtu || "" }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between border-b border-slate-100 pb-2 dark:border-slate-700">
|
||||
<span class="muted">NAT 类型</span>
|
||||
<span class="font-mono text-indigo-600 dark:text-indigo-400">{{
|
||||
app.selectedInfo.nat_type || "Unknown"
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="flex justify-between border-b border-slate-100 pb-2 dark:border-slate-700">
|
||||
<span class="muted">Public IPv6</span>
|
||||
<span
|
||||
class="max-w-[200px] truncate font-mono text-slate-900 dark:text-white"
|
||||
:title="app.selectedInfo.public_ipv6"
|
||||
>{{ app.selectedInfo.public_ipv6 || "-" }}</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex justify-between border-b border-slate-100 pb-2 dark:border-slate-700">
|
||||
<span class="muted">设备名称</span>
|
||||
<span class="text-slate-900 dark:text-white">{{ app.selectedInfo.name || "-" }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between border-b border-slate-100 pb-2 dark:border-slate-700">
|
||||
<span class="muted">设备 ID</span>
|
||||
<span
|
||||
class="max-w-[200px] truncate font-mono text-xs text-slate-500 dark:text-slate-400"
|
||||
:title="app.selectedInfo.device_id"
|
||||
>{{ app.selectedInfo.device_id || "-" }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<div
|
||||
v-for="feat in [
|
||||
{ key: 'encrypt', label: '加密' },
|
||||
{ key: 'compress', label: '压缩' },
|
||||
{ key: 'fec', label: 'FEC纠错' },
|
||||
{ key: 'rtx', label: 'QUIC传输' },
|
||||
]"
|
||||
:key="feat.key"
|
||||
class="flex items-center gap-2 rounded-lg bg-slate-50 px-3 py-2 dark:bg-slate-800/50"
|
||||
>
|
||||
<span
|
||||
class="h-2 w-2 rounded-full"
|
||||
:class="app.selectedInfo[feat.key] ? 'bg-green-500' : 'bg-slate-300 dark:bg-slate-600'"
|
||||
></span>
|
||||
<span class="text-sm text-slate-600 dark:text-slate-300">{{ feat.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<span class="mb-2 block text-sm muted">Public IPv4s</span>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span
|
||||
v-for="pip in app.selectedInfo.public_ipv4s"
|
||||
:key="pip"
|
||||
class="rounded border border-slate-200 bg-slate-50 px-2 py-1 font-mono text-xs tabular-nums text-green-700 dark:border-slate-600 dark:bg-slate-800 dark:text-green-300"
|
||||
>{{ pip }}</span
|
||||
>
|
||||
<span
|
||||
v-if="!app.selectedInfo.public_ipv4s || app.selectedInfo.public_ipv4s.length === 0"
|
||||
class="text-xs text-slate-400"
|
||||
>无</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card p-0 overflow-hidden">
|
||||
<div class="border-b border-slate-200 px-6 py-4 dark:border-slate-700">
|
||||
<h2 class="text-lg font-bold text-slate-900 dark:text-white">服务器连接列表</h2>
|
||||
</div>
|
||||
<div class="custom-scrollbar max-h-[400px] overflow-x-auto">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>地址</th>
|
||||
<th>状态</th>
|
||||
<th>延迟</th>
|
||||
<th>版本</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(server, idx) in app.selectedInfo.server_info" :key="idx">
|
||||
<td class="font-mono">{{ server.server }}</td>
|
||||
<td>
|
||||
<span :class="server.connected ? 'badge-green' : 'badge-red'">
|
||||
{{ server.connected ? "已连接" : "未连接" }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="tabular-nums">{{ server.server_rtt ? server.server_rtt + " ms" : "-" }}</td>
|
||||
<td>{{ server.server_version || "-" }}</td>
|
||||
</tr>
|
||||
<tr v-if="!app.selectedInfo.server_info || app.selectedInfo.server_info.length === 0">
|
||||
<td colspan="4" class="text-center text-slate-400">暂无数据</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,318 @@
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, onUnmounted, watch, inject } from "vue";
|
||||
import { useAppStore } from "../stores/app";
|
||||
import { getPeers } from "../api";
|
||||
import { formatTime, formatBytes, formatSpeed } from "../utils/format";
|
||||
import StatusDot from "../components/StatusDot.vue";
|
||||
import EmptyState from "../components/EmptyState.vue";
|
||||
import SpeedChart from "../components/SpeedChart.vue";
|
||||
|
||||
const app = useAppStore();
|
||||
const tooltipRef = inject("peerTooltip");
|
||||
const showPeerTooltip = (e, peer) => tooltipRef.value?.showPeerTooltip(e, peer);
|
||||
const hidePeerTooltip = () => tooltipRef.value?.hidePeerTooltip();
|
||||
|
||||
const peers = ref([]);
|
||||
let timer = null;
|
||||
// 记录上次流量数据和时间,用于前端计算网速
|
||||
let lastTrafficMap = {};
|
||||
let lastFetchTime = 0;
|
||||
// 展开状态和网速历史
|
||||
const expandedPeers = reactive({});
|
||||
const speedHistoryMap = {};
|
||||
const HISTORY_SIZE = 60;
|
||||
|
||||
const toggleExpand = (ip) => {
|
||||
expandedPeers[ip] = !expandedPeers[ip];
|
||||
};
|
||||
|
||||
const currentStatus = () => {
|
||||
const inst = app.instanceList.find((i) => i.file_name === app.selectedInstance);
|
||||
return inst ? inst.status : null;
|
||||
};
|
||||
|
||||
const resetPeerState = () => {
|
||||
peers.value = [];
|
||||
lastTrafficMap = {};
|
||||
lastFetchTime = 0;
|
||||
for (const key in speedHistoryMap) delete speedHistoryMap[key];
|
||||
for (const key in expandedPeers) delete expandedPeers[key];
|
||||
};
|
||||
|
||||
const fetchPeers = async () => {
|
||||
if (!app.selectedInstance || currentStatus() !== "running") {
|
||||
resetPeerState();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const list = (await getPeers(app.selectedInstance)) || [];
|
||||
const now = Date.now();
|
||||
const elapsed = lastFetchTime > 0 ? (now - lastFetchTime) / 1000 : 0;
|
||||
const newTrafficMap = {};
|
||||
for (const peer of list) {
|
||||
if (peer.traffic) {
|
||||
const key = peer.ip;
|
||||
const prev = lastTrafficMap[key];
|
||||
if (prev && elapsed > 0) {
|
||||
const txDiff = Math.max(0, peer.traffic.tx_bytes - prev.tx_bytes);
|
||||
const rxDiff = Math.max(0, peer.traffic.rx_bytes - prev.rx_bytes);
|
||||
peer.traffic.tx_speed = Math.round(txDiff / elapsed);
|
||||
peer.traffic.rx_speed = Math.round(rxDiff / elapsed);
|
||||
} else {
|
||||
peer.traffic.tx_speed = 0;
|
||||
peer.traffic.rx_speed = 0;
|
||||
}
|
||||
newTrafficMap[key] = { tx_bytes: peer.traffic.tx_bytes, rx_bytes: peer.traffic.rx_bytes };
|
||||
// 记录速度历史
|
||||
if (!speedHistoryMap[key]) speedHistoryMap[key] = { tx: [], rx: [] };
|
||||
speedHistoryMap[key].tx.push(peer.traffic.tx_speed);
|
||||
speedHistoryMap[key].rx.push(peer.traffic.rx_speed);
|
||||
if (speedHistoryMap[key].tx.length > HISTORY_SIZE) {
|
||||
speedHistoryMap[key].tx.shift();
|
||||
speedHistoryMap[key].rx.shift();
|
||||
}
|
||||
}
|
||||
}
|
||||
lastTrafficMap = newTrafficMap;
|
||||
lastFetchTime = now;
|
||||
peers.value = list;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchPeers();
|
||||
timer = setInterval(() => {
|
||||
if (app.isPageVisible) fetchPeers();
|
||||
}, 3000);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timer) clearInterval(timer);
|
||||
});
|
||||
|
||||
// 切换实例时重置并重新拉取
|
||||
watch(
|
||||
() => app.selectedInstance,
|
||||
() => {
|
||||
resetPeerState();
|
||||
fetchPeers();
|
||||
},
|
||||
);
|
||||
|
||||
// 监听选中实例状态变化,当变为 running 时立即获取数据
|
||||
watch(currentStatus, (newStatus) => {
|
||||
if (newStatus === "running") {
|
||||
fetchPeers();
|
||||
}
|
||||
});
|
||||
|
||||
const getRouteModeClass = (route) => {
|
||||
// 判断是否直连:metric === 1
|
||||
const isDirect = route.metric === 1;
|
||||
if (isDirect) {
|
||||
return "text-purple-600 font-medium dark:text-purple-400"; // 直连 - 紫色
|
||||
} else {
|
||||
return "text-blue-600 dark:text-blue-400"; // 客户端中继 - 蓝色
|
||||
}
|
||||
};
|
||||
|
||||
const getRouteModeText = (route) => {
|
||||
const isDirect = route.metric === 1;
|
||||
const isTcp = route.protocol.includes("Tcp");
|
||||
if (isDirect) {
|
||||
return isTcp ? "打洞TCP直连" : "打洞UDP直连";
|
||||
} else {
|
||||
return isTcp ? "客户端TCP中继" : "客户端UDP中继";
|
||||
}
|
||||
};
|
||||
|
||||
const keyEqualText = (keyEqual) =>
|
||||
keyEqual === 3
|
||||
? "己方加密对方未加密"
|
||||
: keyEqual === 4
|
||||
? "己方未加密对方加密"
|
||||
: keyEqual === 5
|
||||
? "密钥不一致"
|
||||
: "未知错误";
|
||||
|
||||
const switcherClass = (fileName) =>
|
||||
app.selectedInstance === fileName
|
||||
? "border-indigo-600 bg-indigo-600 text-white dark:border-indigo-500 dark:bg-indigo-500"
|
||||
: "border-slate-300 bg-white text-slate-600 hover:bg-slate-50 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<h1 class="page-title">设备列表</h1>
|
||||
<p class="page-subtitle">查看各实例的设备连接与流量状况</p>
|
||||
</div>
|
||||
|
||||
<!-- 实例切换器 -->
|
||||
<div v-if="app.instanceList.length > 0" class="scrollbar-hide flex items-center gap-2 overflow-x-auto">
|
||||
<button
|
||||
v-for="inst in app.instanceList"
|
||||
:key="inst.file_name"
|
||||
@click="app.selectedInstance = inst.file_name"
|
||||
:class="switcherClass(inst.file_name)"
|
||||
class="flex shrink-0 items-center rounded-lg border px-4 py-2 text-sm font-medium transition-colors"
|
||||
>
|
||||
<StatusDot :status="inst.status" size="w-2 h-2" class="mr-2" />
|
||||
{{ inst.config_name || inst.file_name }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<EmptyState v-if="!app.selectedInstance" text="暂无运行中的组网实例" />
|
||||
|
||||
<div v-else class="card overflow-hidden p-0">
|
||||
<div class="flex items-center justify-between border-b border-slate-200 px-6 py-4 dark:border-slate-700">
|
||||
<h2 class="text-base font-bold text-slate-900 dark:text-white">设备列表</h2>
|
||||
<div class="flex gap-4 text-sm muted">
|
||||
<span>
|
||||
Online:
|
||||
<span class="font-medium tabular-nums text-slate-900 dark:text-white">{{
|
||||
peers.filter((p) => p.online).length
|
||||
}}</span>
|
||||
</span>
|
||||
<span>
|
||||
Total:
|
||||
<span class="font-medium tabular-nums text-slate-900 dark:text-white">{{ peers.length }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="custom-scrollbar max-h-[600px] overflow-x-auto">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="w-8 px-2"></th>
|
||||
<th>IP地址</th>
|
||||
<th>名称</th>
|
||||
<th class="hidden md:table-cell">版本</th>
|
||||
<th>状态</th>
|
||||
<th>模式</th>
|
||||
<th>延迟</th>
|
||||
<th>丢包率</th>
|
||||
<th>流量</th>
|
||||
<th class="hidden md:table-cell">最后在线</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template v-for="peer in peers" :key="peer.ip">
|
||||
<tr class="transition-colors hover:bg-slate-50 dark:hover:bg-slate-800/50">
|
||||
<td class="cursor-pointer select-none px-2 text-center" @click="toggleExpand(peer.ip)">
|
||||
<svg
|
||||
class="inline-block h-4 w-4 text-slate-400 transition-transform duration-200"
|
||||
:class="{ 'rotate-90': expandedPeers[peer.ip] }"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</td>
|
||||
<td class="font-mono tabular-nums text-indigo-600 dark:text-indigo-400">
|
||||
<span
|
||||
class="cursor-help border-b border-dotted border-indigo-400/50 pb-0.5"
|
||||
@mouseenter="showPeerTooltip($event, peer)"
|
||||
@mouseleave="hidePeerTooltip"
|
||||
>
|
||||
{{ peer.ip }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ peer.name || "-" }}</td>
|
||||
<td class="hidden text-xs text-slate-400 md:table-cell">{{ peer.version || "-" }}</td>
|
||||
<td>
|
||||
<div class="flex items-center gap-2">
|
||||
<span :class="peer.online ? 'badge-green' : 'badge-gray'">{{
|
||||
peer.online ? "Online" : "Offline"
|
||||
}}</span>
|
||||
<!-- 加密状态图标 -->
|
||||
<div v-if="peer.online && peer.key_equal === 1" class="tooltip">
|
||||
<svg class="h-4 w-4 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="tooltip-text">双方加密传输</span>
|
||||
</div>
|
||||
<div v-else-if="peer.online && peer.key_equal === 2" class="tooltip">
|
||||
<svg class="h-4 w-4 text-yellow-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 11V7a4 4 0 118 0m-4 8v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="tooltip-text">双方未加密</span>
|
||||
</div>
|
||||
<div v-else-if="peer.online && [3, 4, 5].includes(peer.key_equal)" class="tooltip cursor-help">
|
||||
<svg class="h-4 w-4 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="tooltip-text">{{ keyEqualText(peer.key_equal) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span v-if="peer.online && peer.route" :class="getRouteModeClass(peer.route)">{{
|
||||
getRouteModeText(peer.route)
|
||||
}}</span>
|
||||
<span v-else-if="peer.online" class="text-yellow-600 dark:text-yellow-400">服务器中继</span>
|
||||
<span v-else class="text-slate-400">-</span>
|
||||
</td>
|
||||
<td class="tabular-nums">{{ peer.route ? peer.route.rtt + " ms" : "-" }}</td>
|
||||
<td>
|
||||
<span
|
||||
v-if="peer.packet_loss"
|
||||
:class="
|
||||
peer.packet_loss.loss_rate > 10
|
||||
? 'text-red-600 dark:text-red-400'
|
||||
: peer.packet_loss.loss_rate > 5
|
||||
? 'text-yellow-600 dark:text-yellow-400'
|
||||
: 'text-green-600 dark:text-green-400'
|
||||
"
|
||||
:title="'Sent: ' + peer.packet_loss.sent + ', Received: ' + peer.packet_loss.received"
|
||||
>{{ peer.packet_loss.loss_rate.toFixed(1) }}%</span
|
||||
>
|
||||
<span v-else class="text-slate-400">-</span>
|
||||
</td>
|
||||
<td class="text-xs">
|
||||
<div v-if="peer.traffic" class="leading-relaxed">
|
||||
<div class="text-green-600 tabular-nums dark:text-green-400">
|
||||
↑ {{ formatBytes(peer.traffic.tx_bytes) }} ({{ formatSpeed(peer.traffic.tx_speed) }})
|
||||
</div>
|
||||
<div class="text-blue-600 tabular-nums dark:text-blue-400">
|
||||
↓ {{ formatBytes(peer.traffic.rx_bytes) }} ({{ formatSpeed(peer.traffic.rx_speed) }})
|
||||
</div>
|
||||
</div>
|
||||
<span v-else class="text-slate-400">-</span>
|
||||
</td>
|
||||
<td class="hidden font-mono text-xs text-slate-400 md:table-cell">
|
||||
{{ formatTime(peer.last_connected_time) }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="expandedPeers[peer.ip]">
|
||||
<td colspan="10" class="p-0">
|
||||
<div class="border-t border-slate-200 bg-slate-50 px-4 py-3 dark:border-slate-700/50 dark:bg-slate-950/60">
|
||||
<SpeedChart :history="speedHistoryMap[peer.ip] || { tx: [], rx: [] }" :size="60" />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,122 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, watch } from "vue";
|
||||
import { useAppStore } from "../stores/app";
|
||||
import { getRoutes } from "../api";
|
||||
import StatusDot from "../components/StatusDot.vue";
|
||||
import EmptyState from "../components/EmptyState.vue";
|
||||
|
||||
const app = useAppStore();
|
||||
const routes = ref([]);
|
||||
let timer = null;
|
||||
|
||||
const currentStatus = () => {
|
||||
const inst = app.instanceList.find((i) => i.file_name === app.selectedInstance);
|
||||
return inst ? inst.status : null;
|
||||
};
|
||||
|
||||
const fetchRoutes = async () => {
|
||||
if (!app.selectedInstance || currentStatus() !== "running") {
|
||||
routes.value = [];
|
||||
return;
|
||||
}
|
||||
try {
|
||||
routes.value = (await getRoutes(app.selectedInstance)) || [];
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchRoutes();
|
||||
timer = setInterval(() => {
|
||||
if (app.isPageVisible) fetchRoutes();
|
||||
}, 3000);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timer) clearInterval(timer);
|
||||
});
|
||||
|
||||
// 切换实例时重新拉取
|
||||
watch(
|
||||
() => app.selectedInstance,
|
||||
() => {
|
||||
fetchRoutes();
|
||||
},
|
||||
);
|
||||
|
||||
// 监听选中实例状态变化,当变为 running 时立即获取数据
|
||||
watch(currentStatus, (newStatus) => {
|
||||
if (newStatus === "running") {
|
||||
fetchRoutes();
|
||||
}
|
||||
});
|
||||
|
||||
const switcherClass = (fileName) =>
|
||||
app.selectedInstance === fileName
|
||||
? "border-indigo-600 bg-indigo-600 text-white dark:border-indigo-500 dark:bg-indigo-500"
|
||||
: "border-slate-300 bg-white text-slate-600 hover:bg-slate-50 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<h1 class="page-title">路由</h1>
|
||||
<p class="page-subtitle">查看各实例的路由表</p>
|
||||
</div>
|
||||
|
||||
<!-- 实例切换器 -->
|
||||
<div v-if="app.instanceList.length > 0" class="scrollbar-hide flex items-center gap-2 overflow-x-auto">
|
||||
<button
|
||||
v-for="inst in app.instanceList"
|
||||
:key="inst.file_name"
|
||||
@click="app.selectedInstance = inst.file_name"
|
||||
:class="switcherClass(inst.file_name)"
|
||||
class="flex shrink-0 items-center rounded-lg border px-4 py-2 text-sm font-medium transition-colors"
|
||||
>
|
||||
<StatusDot :status="inst.status" size="w-2 h-2" class="mr-2" />
|
||||
{{ inst.config_name || inst.file_name }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<EmptyState v-if="!app.selectedInstance" text="暂无运行中的组网实例" />
|
||||
|
||||
<div v-else class="card overflow-hidden p-0">
|
||||
<div class="border-b border-slate-200 px-6 py-4 dark:border-slate-700">
|
||||
<h2 class="text-base font-bold text-slate-900 dark:text-white">路由表</h2>
|
||||
</div>
|
||||
<div class="custom-scrollbar max-h-[600px] overflow-x-auto">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>目标节点IP</th>
|
||||
<th>目标网络</th>
|
||||
<th>跳数</th>
|
||||
<th>延迟</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template v-for="item in routes" :key="item.ip">
|
||||
<tr
|
||||
v-for="(route, rIdx) in item.routes"
|
||||
:key="rIdx"
|
||||
class="hover:bg-slate-50 dark:hover:bg-slate-800/50"
|
||||
>
|
||||
<td
|
||||
v-if="rIdx === 0"
|
||||
:rowspan="item.routes.length"
|
||||
class="font-mono tabular-nums text-indigo-600 dark:text-indigo-400"
|
||||
>
|
||||
{{ item.ip }}
|
||||
</td>
|
||||
<td class="font-mono tabular-nums text-yellow-700 dark:text-yellow-300">{{ route.addr }}</td>
|
||||
<td class="tabular-nums">{{ route.metric }}</td>
|
||||
<td class="tabular-nums">{{ route.rtt }} ms</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from "vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue(), tailwindcss()],
|
||||
server: {
|
||||
proxy: {
|
||||
"/api": "http://127.0.0.1:19099",
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: "../static",
|
||||
emptyOutDir: true,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user