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:
lbl
2026-08-22 00:53:51 +08:00
parent 292d54fbc3
commit 7513465b61
37 changed files with 5090 additions and 3227 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+24 -3226
View File
@@ -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.
+27
View File
@@ -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>
+21
View File
@@ -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"
}
}
+1312
View File
@@ -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
+276
View File
@@ -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>
+73
View File
@@ -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",
});
+65
View File
@@ -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>
+89
View File
@@ -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>
+48
View File
@@ -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>
+24
View File
@@ -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>
+130
View File
@@ -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>
+136
View File
@@ -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>
+72
View File
@@ -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>
+17
View File
@@ -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>
+45
View File
@@ -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>
+7
View File
@@ -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");
+21
View File
@@ -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,
});
+252
View File
@@ -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,
};
});
+116
View File
@@ -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,
};
});
+64
View File
@@ -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,
};
});
+229
View File
@@ -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;
}
}
+55
View File
@@ -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;
};
+349
View File
@@ -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"]`;
+609
View File
@@ -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>
+135
View File
@@ -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>
+103
View File
@@ -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>
+152
View File
@@ -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>
+318
View File
@@ -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>
+122
View File
@@ -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>
+16
View File
@@ -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,
},
});